@okostream/mcp 0.12.8 → 0.12.10
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 +487 -142
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -70499,6 +70499,13 @@ var OKO_CONNECTION_STATUSES = [
|
|
|
70499
70499
|
"connected",
|
|
70500
70500
|
"not_configured",
|
|
70501
70501
|
"invalid_key",
|
|
70502
|
+
// 🔴 Next to `invalid_key`, not instead of it (A7, `LR3a-2`). The key is
|
|
70503
|
+
// valid and the account behind it is switched off or blocked — the one state
|
|
70504
|
+
// in which "create a fresh key" is not merely unhelpful but impossible, and
|
|
70505
|
+
// the state in which the platform used to give exactly that advice.
|
|
70506
|
+
// A genuinely revoked key still needs the create-a-key step, so the two
|
|
70507
|
+
// states are separate codes with separate steps.
|
|
70508
|
+
"account_inactive",
|
|
70502
70509
|
"update_required",
|
|
70503
70510
|
"rate_limited",
|
|
70504
70511
|
"unavailable"
|
|
@@ -70539,6 +70546,18 @@ function nextSteps(status, minimumVersion, latestVersion) {
|
|
|
70539
70546
|
label: "Restart or reconnect the MCP server after updating the key."
|
|
70540
70547
|
}
|
|
70541
70548
|
];
|
|
70549
|
+
case "account_inactive":
|
|
70550
|
+
return [
|
|
70551
|
+
{
|
|
70552
|
+
id: "contact_support",
|
|
70553
|
+
label: "Ask OKO support to restore organizer access for this account: the key is valid, the account is switched off or blocked. Creating another key will not help \u2014 key creation needs an active account too.",
|
|
70554
|
+
url: OKO_ONBOARDING_URLS.website
|
|
70555
|
+
},
|
|
70556
|
+
{
|
|
70557
|
+
id: "restart_client",
|
|
70558
|
+
label: "Restart or reconnect the MCP server once access is restored."
|
|
70559
|
+
}
|
|
70560
|
+
];
|
|
70542
70561
|
case "update_required":
|
|
70543
70562
|
return [
|
|
70544
70563
|
{
|
|
@@ -70566,7 +70585,7 @@ function nextSteps(status, minimumVersion, latestVersion) {
|
|
|
70566
70585
|
function buildConnectionStatus(status, options2 = {}) {
|
|
70567
70586
|
const connected = status === "connected";
|
|
70568
70587
|
const errorCode = connected ? null : status === "update_required" ? "CLIENT_UPGRADE_REQUIRED" : status === "rate_limited" ? "RATE_LIMITED" : status === "unavailable" ? "SERVICE_UNAVAILABLE" : "AUTH_REQUIRED";
|
|
70569
|
-
const message = connected ? "OKO MCP is connected to an active organizer account." : status === "not_configured" ? "OKO MCP is installed, but OKO_API_KEY is not configured." : status === "invalid_key" ? "OKO MCP is installed, but the configured API key is invalid or revoked." : status === "update_required" ? options2.serverMessage ?? "This OKO MCP client version is no longer supported." : status === "rate_limited" ? "OKO temporarily rate-limited the connection attempt." : "OKO MCP could not verify the organizer connection.";
|
|
70588
|
+
const message = connected ? "OKO MCP is connected to an active organizer account." : status === "not_configured" ? "OKO MCP is installed, but OKO_API_KEY is not configured." : status === "invalid_key" ? "OKO MCP is installed, but the configured API key is invalid or revoked." : status === "account_inactive" ? options2.serverMessage ?? "The OKO API key is valid, but the organizer account behind it is switched off or blocked." : status === "update_required" ? options2.serverMessage ?? "This OKO MCP client version is no longer supported." : status === "rate_limited" ? "OKO temporarily rate-limited the connection attempt." : "OKO MCP could not verify the organizer connection.";
|
|
70570
70589
|
return {
|
|
70571
70590
|
connected,
|
|
70572
70591
|
status,
|
|
@@ -70639,7 +70658,7 @@ var GUIDE_RESOURCES = [
|
|
|
70639
70658
|
"1. Call `oko_whoami` and inspect scopes.",
|
|
70640
70659
|
"2. If the workspace is unclear, call `oko_list_workspaces`.",
|
|
70641
70660
|
"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.",
|
|
70661
|
+
"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
70662
|
"5. Call `oko_configure_cards` with stable card ids before uploading card images.",
|
|
70644
70663
|
"6. Upload images with `oko_upload_card_images_batch` or `oko_upload_image`, then wait for variants.",
|
|
70645
70664
|
"7. Call `oko_validate_event_setup` and fix blocking findings.",
|
|
@@ -70731,8 +70750,9 @@ var GUIDE_RESOURCES = [
|
|
|
70731
70750
|
"1. Call `oko_create_telegram_link` and complete the one-time bot link in the organizer's account.",
|
|
70732
70751
|
"2. Add `@OKOStreamBot` to the target channel as an administrator with posting permission.",
|
|
70733
70752
|
"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
|
|
70753
|
+
"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
70754
|
"5. Publish or schedule with `oko_publish_telegram_post` only after showing the exact channel and copy.",
|
|
70755
|
+
" 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
70756
|
"6. Inspect delivery through `oko_list_telegram_posts`; avoid retrying `delivery_unknown` automatically.",
|
|
70737
70757
|
"",
|
|
70738
70758
|
"The MCP does not toggle the platform auto-reminder. Schedule an explicit reminder post or use web setup."
|
|
@@ -70855,6 +70875,18 @@ var DEFAULT_CHARACTER_LIMIT = 3e4;
|
|
|
70855
70875
|
var MAX_COMPACT_ARRAY_ITEMS = 25;
|
|
70856
70876
|
var MAX_COMPACT_OBJECT_KEYS = 60;
|
|
70857
70877
|
var MAX_COMPACT_STRING_LENGTH = 2e3;
|
|
70878
|
+
function isFirestorePermissionDenied(error3) {
|
|
70879
|
+
if (!error3 || typeof error3 !== "object")
|
|
70880
|
+
return false;
|
|
70881
|
+
const code = error3.code;
|
|
70882
|
+
return code === "permission-denied" || code === "firestore/permission-denied" || code === "PERMISSION_DENIED";
|
|
70883
|
+
}
|
|
70884
|
+
function isFirestoreNotFound(error3) {
|
|
70885
|
+
if (!error3 || typeof error3 !== "object")
|
|
70886
|
+
return false;
|
|
70887
|
+
const code = error3.code;
|
|
70888
|
+
return code === "not-found" || code === "firestore/not-found" || code === "NOT_FOUND";
|
|
70889
|
+
}
|
|
70858
70890
|
function serialize(value) {
|
|
70859
70891
|
if (value == null)
|
|
70860
70892
|
return value;
|
|
@@ -116130,6 +116162,41 @@ function readImageRoots() {
|
|
|
116130
116162
|
}
|
|
116131
116163
|
return roots;
|
|
116132
116164
|
}
|
|
116165
|
+
var FIREBASE_FIELDS = [
|
|
116166
|
+
["apiKey", ["OKO_FIREBASE_API_KEY", "NEXT_PUBLIC_FIREBASE_API_KEY"]],
|
|
116167
|
+
["authDomain", ["OKO_FIREBASE_AUTH_DOMAIN", "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN"]],
|
|
116168
|
+
["databaseURL", ["OKO_FIREBASE_DATABASE_URL", "NEXT_PUBLIC_FIREBASE_DATABASE_URL"]],
|
|
116169
|
+
["projectId", ["OKO_FIREBASE_PROJECT_ID", "NEXT_PUBLIC_FIREBASE_PROJECT_ID"]],
|
|
116170
|
+
["storageBucket", ["OKO_FIREBASE_STORAGE_BUCKET", "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET"]],
|
|
116171
|
+
["messagingSenderId", ["OKO_FIREBASE_MESSAGING_SENDER_ID", "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID"]],
|
|
116172
|
+
["appId", ["OKO_FIREBASE_APP_ID", "NEXT_PUBLIC_FIREBASE_APP_ID"]],
|
|
116173
|
+
["chatDatabaseURL", ["OKO_FIREBASE_CHAT_DB_URL", "NEXT_PUBLIC_FIREBASE_CHAT_DB_URL"]]
|
|
116174
|
+
];
|
|
116175
|
+
function firebaseConfigError(source) {
|
|
116176
|
+
const overridden = [];
|
|
116177
|
+
const missing = [];
|
|
116178
|
+
for (const [field2, names] of FIREBASE_FIELDS) {
|
|
116179
|
+
const label = names[0] ?? field2;
|
|
116180
|
+
let value = null;
|
|
116181
|
+
for (const name7 of names) {
|
|
116182
|
+
const candidate = normalizeOptionalEnv(source[name7]);
|
|
116183
|
+
if (candidate) {
|
|
116184
|
+
value = candidate;
|
|
116185
|
+
break;
|
|
116186
|
+
}
|
|
116187
|
+
}
|
|
116188
|
+
if (value === null)
|
|
116189
|
+
missing.push(label);
|
|
116190
|
+
else if (value !== DEFAULT_FIREBASE_CONFIG[field2])
|
|
116191
|
+
overridden.push(label);
|
|
116192
|
+
}
|
|
116193
|
+
if (overridden.length === 0 || missing.length === 0)
|
|
116194
|
+
return null;
|
|
116195
|
+
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.`;
|
|
116196
|
+
}
|
|
116197
|
+
var firebaseConfigProblem = firebaseConfigError(process.env);
|
|
116198
|
+
if (firebaseConfigProblem)
|
|
116199
|
+
throw new Error(firebaseConfigProblem);
|
|
116133
116200
|
var env = {
|
|
116134
116201
|
apiKey: readOptionalEnv("OKO_API_KEY"),
|
|
116135
116202
|
imageRoots: readImageRoots(),
|
|
@@ -116224,7 +116291,7 @@ function sanitizeScopes(scopes) {
|
|
|
116224
116291
|
}
|
|
116225
116292
|
|
|
116226
116293
|
// build/version.js
|
|
116227
|
-
var OKO_MCP_VERSION = "0.12.
|
|
116294
|
+
var OKO_MCP_VERSION = "0.12.10";
|
|
116228
116295
|
|
|
116229
116296
|
// build/auth.js
|
|
116230
116297
|
var state = null;
|
|
@@ -116297,6 +116364,17 @@ var OkoAuthError = class extends Error {
|
|
|
116297
116364
|
function versionField(value) {
|
|
116298
116365
|
return typeof value === "string" && parseVersion(value) ? value.trim() : void 0;
|
|
116299
116366
|
}
|
|
116367
|
+
function inactiveOrganizerError(body) {
|
|
116368
|
+
const parsed = body && typeof body === "object" ? body : {};
|
|
116369
|
+
if (parsed.error !== "organizer_inactive")
|
|
116370
|
+
return null;
|
|
116371
|
+
const serverMessage = typeof parsed.message === "string" && parsed.message.trim() ? parsed.message.trim() : void 0;
|
|
116372
|
+
return new OkoAuthError("account_inactive", serverMessage ?? "The OKO API key is valid, but the organizer account behind it is switched off or blocked. Creating another key will not help \u2014 contact OKO support to restore access.", { serverMessage });
|
|
116373
|
+
}
|
|
116374
|
+
function serverRefusalNote(body) {
|
|
116375
|
+
const parsed = body && typeof body === "object" ? body : {};
|
|
116376
|
+
return typeof parsed.message === "string" && parsed.message.trim() ? parsed.message.trim() : void 0;
|
|
116377
|
+
}
|
|
116300
116378
|
function upgradeRequiredError(body) {
|
|
116301
116379
|
const parsed = body && typeof body === "object" ? body : {};
|
|
116302
116380
|
const minimumVersion = versionField(parsed.minimumVersion);
|
|
@@ -116331,7 +116409,16 @@ async function exchangeApiKey() {
|
|
|
116331
116409
|
if (status === 429) {
|
|
116332
116410
|
throw new OkoAuthError("rate_limited", "OKO API key exchange is temporarily rate-limited. Wait one minute, then restart the MCP client.");
|
|
116333
116411
|
}
|
|
116334
|
-
|
|
116412
|
+
if (status === 401) {
|
|
116413
|
+
const inactive = inactiveOrganizerError(errorBody);
|
|
116414
|
+
if (inactive)
|
|
116415
|
+
throw inactive;
|
|
116416
|
+
}
|
|
116417
|
+
throw status === 401 ? new OkoAuthError("invalid_key", "OKO API key is invalid or revoked. Create a fresh key in the OKO MCP install center and replace OKO_API_KEY.") : new OkoAuthError("unavailable", [
|
|
116418
|
+
`OKO organizer authentication failed with HTTP ${status}.`,
|
|
116419
|
+
serverRefusalNote(errorBody),
|
|
116420
|
+
"Check your connection and try again."
|
|
116421
|
+
].filter(Boolean).join(" "));
|
|
116335
116422
|
}
|
|
116336
116423
|
const data = await response.json();
|
|
116337
116424
|
if (typeof data.customToken !== "string") {
|
|
@@ -116400,7 +116487,9 @@ async function getConnectionStatus() {
|
|
|
116400
116487
|
if (error3 instanceof OkoAuthError) {
|
|
116401
116488
|
return buildConnectionStatus(error3.status, authErrorOptions(error3));
|
|
116402
116489
|
}
|
|
116403
|
-
|
|
116490
|
+
const detail = error3 instanceof Error ? `${error3.name}: ${error3.message}` : String(error3);
|
|
116491
|
+
console.error(`[oko-mcp] connection status: unexpected error \u2014 ${detail}`);
|
|
116492
|
+
return buildConnectionStatus("unavailable", { serverMessage: `Unexpected client-side error: ${detail}` });
|
|
116404
116493
|
}
|
|
116405
116494
|
}
|
|
116406
116495
|
function authErrorOptions(error3) {
|
|
@@ -116455,14 +116544,14 @@ var telegramChannelSchema = external_exports.object({
|
|
|
116455
116544
|
chatId: external_exports.number().int(),
|
|
116456
116545
|
title: external_exports.string().max(200).optional(),
|
|
116457
116546
|
username: external_exports.string().max(120).nullable().optional()
|
|
116458
|
-
}).
|
|
116547
|
+
}).passthrough();
|
|
116459
116548
|
var canonicalTelegramChannelSchema = telegramChannelSchema.extend({
|
|
116460
116549
|
workspaceIds: external_exports.array(external_exports.string()).max(50),
|
|
116461
116550
|
owned: external_exports.boolean(),
|
|
116462
116551
|
subscriberCount: external_exports.number().int().nonnegative().nullable().optional(),
|
|
116463
116552
|
health: external_exports.enum(["connected", "needs_attention", "unknown"]).optional(),
|
|
116464
116553
|
healthCheckedAt: external_exports.number().int().nonnegative().nullable().optional()
|
|
116465
|
-
}).
|
|
116554
|
+
}).passthrough();
|
|
116466
116555
|
|
|
116467
116556
|
// build/tools/imagePipeline.js
|
|
116468
116557
|
var import_undici = __toESM(require_undici(), 1);
|
|
@@ -116502,6 +116591,24 @@ function isPublicUnicastAddress(address) {
|
|
|
116502
116591
|
return false;
|
|
116503
116592
|
return import_ipaddr.default.parse(normalized).range() === "unicast";
|
|
116504
116593
|
}
|
|
116594
|
+
function orderAddressCandidates(addresses, maxCandidates = 4) {
|
|
116595
|
+
const known = addresses.filter((item) => item.family === 4 || item.family === 6);
|
|
116596
|
+
const preferred = known[0]?.family;
|
|
116597
|
+
if (preferred === void 0)
|
|
116598
|
+
return [];
|
|
116599
|
+
const first = known.filter((item) => item.family === preferred);
|
|
116600
|
+
const second = known.filter((item) => item.family !== preferred);
|
|
116601
|
+
const ordered = [];
|
|
116602
|
+
for (let index = 0; index < Math.max(first.length, second.length); index += 1) {
|
|
116603
|
+
const fromFirst = first[index];
|
|
116604
|
+
const fromSecond = second[index];
|
|
116605
|
+
if (fromFirst)
|
|
116606
|
+
ordered.push(fromFirst);
|
|
116607
|
+
if (fromSecond)
|
|
116608
|
+
ordered.push(fromSecond);
|
|
116609
|
+
}
|
|
116610
|
+
return ordered.slice(0, maxCandidates);
|
|
116611
|
+
}
|
|
116505
116612
|
function pinnedLookup(address, family) {
|
|
116506
116613
|
return (_hostname, options2, callback) => {
|
|
116507
116614
|
if (options2 && typeof options2 === "object" && options2.all) {
|
|
@@ -116534,13 +116641,7 @@ function legacyEventAllowsMode(data, uid, mode) {
|
|
|
116534
116641
|
const fields = mode === "read" ? ["accessibleBy", "editors"] : [EVENT_ACCESS_FIELD_BY_MODE[mode]];
|
|
116535
116642
|
return fields.some((field2) => Array.isArray(data[field2]) && data[field2].includes(uid));
|
|
116536
116643
|
}
|
|
116537
|
-
function
|
|
116538
|
-
if (!error3 || typeof error3 !== "object")
|
|
116539
|
-
return false;
|
|
116540
|
-
const code = error3.code;
|
|
116541
|
-
return code === "permission-denied" || code === "firestore/permission-denied" || code === "PERMISSION_DENIED";
|
|
116542
|
-
}
|
|
116543
|
-
async function readEvent(eventId, uid, mode = "read") {
|
|
116644
|
+
async function readEventAccess(eventId, uid, mode) {
|
|
116544
116645
|
const [snap, grant] = await Promise.all([
|
|
116545
116646
|
getDoc(doc(firestore, "events", eventId)),
|
|
116546
116647
|
getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error3) => {
|
|
@@ -116549,10 +116650,19 @@ async function readEvent(eventId, uid, mode = "read") {
|
|
|
116549
116650
|
return null;
|
|
116550
116651
|
})
|
|
116551
116652
|
]);
|
|
116552
|
-
|
|
116553
|
-
|
|
116554
|
-
|
|
116555
|
-
|
|
116653
|
+
const data = snap.data() ?? {};
|
|
116654
|
+
const holds = (wanted) => grant === null ? legacyEventAllowsMode(data, uid, wanted) : data.ownerId === uid || grant.exists() && grantAllowsMode(grant.data(), wanted);
|
|
116655
|
+
return { data, exists: snap.exists(), visible: holds("read"), allowed: holds(mode) };
|
|
116656
|
+
}
|
|
116657
|
+
async function eventGrantsMode(eventId, uid, mode) {
|
|
116658
|
+
return (await readEventAccess(eventId, uid, mode)).allowed;
|
|
116659
|
+
}
|
|
116660
|
+
var EVENT_NOT_FOUND_MESSAGE = "Event not found.";
|
|
116661
|
+
async function readEvent(eventId, uid, mode = "read") {
|
|
116662
|
+
const { data, exists, visible, allowed } = await readEventAccess(eventId, uid, mode);
|
|
116663
|
+
if (!visible || !exists) {
|
|
116664
|
+
throw new Error(EVENT_NOT_FOUND_MESSAGE);
|
|
116665
|
+
}
|
|
116556
116666
|
if (!allowed) {
|
|
116557
116667
|
throw new Error(`Event does not grant ${mode} access to this organizer.`);
|
|
116558
116668
|
}
|
|
@@ -116615,10 +116725,6 @@ function nonPublicResolutionMessage(hostname2, addresses) {
|
|
|
116615
116725
|
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.";
|
|
116616
116726
|
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.`;
|
|
116617
116727
|
}
|
|
116618
|
-
var remoteFetchNotes = [];
|
|
116619
|
-
function takeRemoteFetchNotes() {
|
|
116620
|
-
return remoteFetchNotes.splice(0, remoteFetchNotes.length);
|
|
116621
|
-
}
|
|
116622
116728
|
async function resolveRemoteImageAddress(url) {
|
|
116623
116729
|
if (url.protocol !== "https:") {
|
|
116624
116730
|
throw new Error("Image URL must use https://.");
|
|
@@ -116635,18 +116741,18 @@ async function resolveRemoteImageAddress(url) {
|
|
|
116635
116741
|
if (!isPublicUnicastAddress(literalAddress)) {
|
|
116636
116742
|
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).`);
|
|
116637
116743
|
}
|
|
116638
|
-
return { address: literalAddress, family: literalFamily, transparentProxy: false };
|
|
116744
|
+
return { addresses: [{ address: literalAddress, family: literalFamily }], transparentProxy: false };
|
|
116639
116745
|
}
|
|
116640
116746
|
const addresses = await lookup(url.hostname, { all: true });
|
|
116641
116747
|
const transparentProxy = addresses.length > 0 && addresses.every((item) => isTransparentProxyAddress(item.address));
|
|
116642
116748
|
if (!transparentProxy && (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address)))) {
|
|
116643
116749
|
throw new Error(nonPublicResolutionMessage(url.hostname, addresses.map((item) => item.address)));
|
|
116644
116750
|
}
|
|
116645
|
-
const
|
|
116646
|
-
if (
|
|
116751
|
+
const candidates = orderAddressCandidates(addresses);
|
|
116752
|
+
if (candidates.length === 0) {
|
|
116647
116753
|
throw new Error("Image URL host could not be resolved safely.");
|
|
116648
116754
|
}
|
|
116649
|
-
return {
|
|
116755
|
+
return { addresses: candidates, transparentProxy };
|
|
116650
116756
|
}
|
|
116651
116757
|
async function responseBufferWithLimit(response, maxBytes) {
|
|
116652
116758
|
const body = response.body;
|
|
@@ -116697,32 +116803,52 @@ function shareLinkHint(url) {
|
|
|
116697
116803
|
}
|
|
116698
116804
|
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.";
|
|
116699
116805
|
}
|
|
116700
|
-
async function readRemoteImage(rawUrl, redirects = 0) {
|
|
116806
|
+
async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
|
|
116701
116807
|
if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
|
|
116702
116808
|
throw new Error("Image URL has too many redirects.");
|
|
116703
116809
|
}
|
|
116704
116810
|
const url = new URL(rawUrl);
|
|
116705
116811
|
const pinned = await resolveRemoteImageAddress(url);
|
|
116706
116812
|
if (pinned.transparentProxy && redirects === 0) {
|
|
116707
|
-
|
|
116813
|
+
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.`);
|
|
116814
|
+
}
|
|
116815
|
+
let lastConnectError = null;
|
|
116816
|
+
for (const candidate of pinned.addresses) {
|
|
116817
|
+
const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects);
|
|
116818
|
+
if ("buffer" in attempt)
|
|
116819
|
+
return attempt.buffer;
|
|
116820
|
+
if ("redirectTo" in attempt)
|
|
116821
|
+
return readRemoteImage(attempt.redirectTo, redirects + 1, notes);
|
|
116822
|
+
lastConnectError = attempt.connectError;
|
|
116708
116823
|
}
|
|
116824
|
+
throw new Error(`Image URL host ${url.hostname} could not be reached at any of its addresses (${pinned.addresses.map((item) => item.address).join(", ")}): ${lastConnectError?.message ?? "connection failed"}.`);
|
|
116825
|
+
}
|
|
116826
|
+
async function readRemoteImageFrom(url, rawUrl, candidate, redirects) {
|
|
116709
116827
|
const dispatcher = new import_undici.Agent({
|
|
116710
|
-
connect: { lookup: pinnedLookup(
|
|
116828
|
+
connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
|
|
116711
116829
|
});
|
|
116712
116830
|
const controller = new AbortController();
|
|
116713
116831
|
const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
|
|
116714
116832
|
try {
|
|
116715
|
-
|
|
116716
|
-
|
|
116717
|
-
|
|
116718
|
-
|
|
116719
|
-
|
|
116720
|
-
|
|
116833
|
+
let response;
|
|
116834
|
+
try {
|
|
116835
|
+
response = await (0, import_undici.fetch)(url, {
|
|
116836
|
+
redirect: "manual",
|
|
116837
|
+
signal: controller.signal,
|
|
116838
|
+
headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
|
|
116839
|
+
dispatcher
|
|
116840
|
+
});
|
|
116841
|
+
} catch (err) {
|
|
116842
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
116843
|
+
throw new Error("Image URL fetch timed out.");
|
|
116844
|
+
}
|
|
116845
|
+
return { connectError: err instanceof Error ? err : new Error(String(err)) };
|
|
116846
|
+
}
|
|
116721
116847
|
if (response.status >= 300 && response.status < 400) {
|
|
116722
116848
|
const location2 = response.headers.get("location");
|
|
116723
116849
|
if (!location2)
|
|
116724
116850
|
throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
|
|
116725
|
-
return
|
|
116851
|
+
return { redirectTo: new URL(location2, url).toString() };
|
|
116726
116852
|
}
|
|
116727
116853
|
if (!response.ok) {
|
|
116728
116854
|
throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
|
|
@@ -116738,7 +116864,7 @@ async function readRemoteImage(rawUrl, redirects = 0) {
|
|
|
116738
116864
|
if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
|
|
116739
116865
|
throw new Error("Image URL is larger than 15MB.");
|
|
116740
116866
|
}
|
|
116741
|
-
return await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES);
|
|
116867
|
+
return { buffer: await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES) };
|
|
116742
116868
|
} catch (err) {
|
|
116743
116869
|
if (err instanceof Error && err.name === "AbortError") {
|
|
116744
116870
|
throw new Error("Image URL fetch timed out.");
|
|
@@ -116827,10 +116953,10 @@ async function uploadImageFromSource(eventId, kind, cardId, source) {
|
|
|
116827
116953
|
serverFailure = refusal;
|
|
116828
116954
|
}
|
|
116829
116955
|
let buffer;
|
|
116956
|
+
const fetchNotes = [];
|
|
116830
116957
|
try {
|
|
116831
|
-
buffer = await readRemoteImage(source.url);
|
|
116958
|
+
buffer = await readRemoteImage(source.url, 0, fetchNotes);
|
|
116832
116959
|
} catch (localError) {
|
|
116833
|
-
takeRemoteFetchNotes();
|
|
116834
116960
|
throw new Error(remoteImageUnavailableMessage(source.url, serverFailure, localError));
|
|
116835
116961
|
}
|
|
116836
116962
|
const uploaded = await uploadMasterImage(eventId, kind, cardId, { imageBase64: buffer.toString("base64") });
|
|
@@ -116838,7 +116964,7 @@ async function uploadImageFromSource(eventId, kind, cardId, source) {
|
|
|
116838
116964
|
uploaded,
|
|
116839
116965
|
sourceBytes: buffer.byteLength,
|
|
116840
116966
|
sourceVia: "client",
|
|
116841
|
-
notes: [localFallbackNote(serverFailure), ...
|
|
116967
|
+
notes: [localFallbackNote(serverFailure), ...fetchNotes]
|
|
116842
116968
|
};
|
|
116843
116969
|
}
|
|
116844
116970
|
function readImageRef(eventData, kind, cardId) {
|
|
@@ -116945,20 +117071,23 @@ function normalizeNullableText(value) {
|
|
|
116945
117071
|
return null;
|
|
116946
117072
|
return value.trim() || null;
|
|
116947
117073
|
}
|
|
117074
|
+
var CARD_LINK_SCHEME = /^(https?:\/\/|mailto:|tel:|\/(?!\/))/i;
|
|
117075
|
+
var CARD_BARE_DOMAIN = /^[\p{L}\d-]+(\.[\p{L}\d-]+)+(:\d+)?(\/\S*)?$/u;
|
|
116948
117076
|
function normalizeCardButtonUrl(value) {
|
|
116949
117077
|
const trimmed = normalizeNullableText(value);
|
|
116950
117078
|
if (!trimmed)
|
|
116951
117079
|
return null;
|
|
117080
|
+
if (CARD_LINK_SCHEME.test(trimmed))
|
|
117081
|
+
return trimmed;
|
|
117082
|
+
if (CARD_BARE_DOMAIN.test(trimmed))
|
|
117083
|
+
return `https://${trimmed}`;
|
|
116952
117084
|
let url;
|
|
116953
117085
|
try {
|
|
116954
117086
|
url = new URL(trimmed);
|
|
116955
117087
|
} catch {
|
|
116956
|
-
throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
|
|
117088
|
+
throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
|
|
116957
117089
|
}
|
|
116958
|
-
|
|
116959
|
-
throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
|
|
116960
|
-
}
|
|
116961
|
-
return trimmed;
|
|
117090
|
+
throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
|
|
116962
117091
|
}
|
|
116963
117092
|
function eventFormIds(eventData) {
|
|
116964
117093
|
const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
|
|
@@ -117730,6 +117859,7 @@ function detectAndNormalize(url) {
|
|
|
117730
117859
|
}
|
|
117731
117860
|
|
|
117732
117861
|
// build/tools/formatting.js
|
|
117862
|
+
var RETIRED_SESSION_FIELDS = ["nmoEvaluation"];
|
|
117733
117863
|
function cursorMeta(count, max, hasMore, nextCursor) {
|
|
117734
117864
|
return {
|
|
117735
117865
|
count,
|
|
@@ -117753,8 +117883,8 @@ function assertEmbeddableUrl(value) {
|
|
|
117753
117883
|
} catch {
|
|
117754
117884
|
throw new Error(`Invalid videoUrl "${value}". Provide an https:// link or a full <iframe> embed code.`);
|
|
117755
117885
|
}
|
|
117756
|
-
if (url.protocol !== "https:"
|
|
117757
|
-
throw new Error(`Unsupported videoUrl protocol "${url.protocol}".
|
|
117886
|
+
if (url.protocol !== "https:") {
|
|
117887
|
+
throw new Error(`Unsupported videoUrl protocol "${url.protocol}". The player must be served over https:// \u2014 http:// links are refused by the platform.`);
|
|
117758
117888
|
}
|
|
117759
117889
|
return value;
|
|
117760
117890
|
}
|
|
@@ -117798,15 +117928,31 @@ function timestampCursorFrom(value) {
|
|
|
117798
117928
|
const millis = toMillis(value);
|
|
117799
117929
|
return millis == null ? null : String(millis);
|
|
117800
117930
|
}
|
|
117931
|
+
var ISO_WITH_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
117801
117932
|
function parseIsoBound(value, label) {
|
|
117802
117933
|
if (value === void 0)
|
|
117803
117934
|
return null;
|
|
117804
117935
|
const parsed = Date.parse(value);
|
|
117805
|
-
if (!Number.isFinite(parsed)) {
|
|
117806
|
-
throw new Error(`Invalid ${label}: expected an ISO timestamp such as 2026-08-28T00:00:00+03:00.`);
|
|
117936
|
+
if (!Number.isFinite(parsed) || !ISO_WITH_OFFSET.test(value.trim())) {
|
|
117937
|
+
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.`);
|
|
117807
117938
|
}
|
|
117808
117939
|
return Timestamp.fromMillis(parsed);
|
|
117809
117940
|
}
|
|
117941
|
+
function absoluteHttpUrl(value, label) {
|
|
117942
|
+
const trimmed = (value ?? "").trim();
|
|
117943
|
+
if (!trimmed)
|
|
117944
|
+
return null;
|
|
117945
|
+
let url;
|
|
117946
|
+
try {
|
|
117947
|
+
url = new URL(trimmed);
|
|
117948
|
+
} catch {
|
|
117949
|
+
throw new Error(`Invalid ${label} "${trimmed}": Telegram needs an absolute http(s) URL.`);
|
|
117950
|
+
}
|
|
117951
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
117952
|
+
throw new Error(`Unsupported ${label} protocol "${url.protocol}": Telegram needs an absolute http(s) URL.`);
|
|
117953
|
+
}
|
|
117954
|
+
return trimmed;
|
|
117955
|
+
}
|
|
117810
117956
|
function compactAnalyticsSession(raw, options2) {
|
|
117811
117957
|
const session = serialize(raw);
|
|
117812
117958
|
const timeline = Array.isArray(session.timeline) ? session.timeline : null;
|
|
@@ -117832,7 +117978,8 @@ function compactAnalyticsSession(raw, options2) {
|
|
|
117832
117978
|
session.finalSnapshotOmitted = true;
|
|
117833
117979
|
}
|
|
117834
117980
|
}
|
|
117835
|
-
|
|
117981
|
+
for (const field2 of RETIRED_SESSION_FIELDS)
|
|
117982
|
+
delete session[field2];
|
|
117836
117983
|
return session;
|
|
117837
117984
|
}
|
|
117838
117985
|
function numberMetric(value) {
|
|
@@ -117903,15 +118050,78 @@ function copyableTelegram(source) {
|
|
|
117903
118050
|
|
|
117904
118051
|
// build/tools/workspaceAccess.js
|
|
117905
118052
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
117906
|
-
|
|
117907
|
-
|
|
117908
|
-
|
|
118053
|
+
|
|
118054
|
+
// build/personalWorkspace.js
|
|
118055
|
+
var PERSONAL_WORKSPACE_NAME = "Personal";
|
|
118056
|
+
function personalWorkspaceRecord(uid) {
|
|
118057
|
+
return { id: uid, name: PERSONAL_WORKSPACE_NAME, ownerId: uid, personal: true };
|
|
118058
|
+
}
|
|
118059
|
+
function resolveWorkspaceForOwner(workspaceId, uid, snapshot) {
|
|
118060
|
+
if (!snapshot.exists) {
|
|
118061
|
+
if (workspaceId === uid)
|
|
118062
|
+
return personalWorkspaceRecord(uid);
|
|
117909
118063
|
throw new Error("Workspace not found.");
|
|
117910
|
-
|
|
118064
|
+
}
|
|
118065
|
+
const data = snapshot.data ?? {};
|
|
117911
118066
|
if (data.ownerId !== uid)
|
|
117912
118067
|
throw new Error("Only the workspace owner can manage this workspace.");
|
|
117913
118068
|
return data;
|
|
117914
118069
|
}
|
|
118070
|
+
function refusalMayMeanMissing(error3, workspaceId, uid) {
|
|
118071
|
+
if (workspaceId !== uid)
|
|
118072
|
+
return false;
|
|
118073
|
+
return isFirestorePermissionDenied(error3) || isFirestoreNotFound(error3);
|
|
118074
|
+
}
|
|
118075
|
+
function withSyntheticPersonalWorkspace(uid, rows) {
|
|
118076
|
+
if (rows.some((row) => row.id === uid))
|
|
118077
|
+
return rows;
|
|
118078
|
+
return [{ ...personalWorkspaceRecord(uid), isOwner: true }, ...rows];
|
|
118079
|
+
}
|
|
118080
|
+
function isSharedWorkspaceId(workspaceId) {
|
|
118081
|
+
return /^[A-Za-z0-9]{20}$/.test(workspaceId);
|
|
118082
|
+
}
|
|
118083
|
+
function workspaceMemberRemoval(existed, workspaceId, targetUid) {
|
|
118084
|
+
if (existed)
|
|
118085
|
+
return { removed: true };
|
|
118086
|
+
return {
|
|
118087
|
+
removed: false,
|
|
118088
|
+
missing: true,
|
|
118089
|
+
hints: [`No share for ${targetUid} exists in workspace ${workspaceId}: nothing was removed. List the members with oko_list_workspace_access before removing one.`]
|
|
118090
|
+
};
|
|
118091
|
+
}
|
|
118092
|
+
function personalWorkspaceLimitMessage(action) {
|
|
118093
|
+
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.`;
|
|
118094
|
+
}
|
|
118095
|
+
|
|
118096
|
+
// build/tools/workspaceAccess.js
|
|
118097
|
+
async function readWorkspaceForOwnerDetailed(workspaceId, uid) {
|
|
118098
|
+
let snap;
|
|
118099
|
+
try {
|
|
118100
|
+
snap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
118101
|
+
} catch (error3) {
|
|
118102
|
+
if (!refusalMayMeanMissing(error3, workspaceId, uid))
|
|
118103
|
+
throw error3;
|
|
118104
|
+
const owned = await getDocs(query2(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
118105
|
+
const existing = owned.docs.find((item) => item.id === workspaceId);
|
|
118106
|
+
if (existing) {
|
|
118107
|
+
return {
|
|
118108
|
+
workspace: resolveWorkspaceForOwner(workspaceId, uid, { exists: true, data: existing.data() }),
|
|
118109
|
+
synthesized: false
|
|
118110
|
+
};
|
|
118111
|
+
}
|
|
118112
|
+
return { workspace: personalWorkspaceRecord(uid), synthesized: true };
|
|
118113
|
+
}
|
|
118114
|
+
return {
|
|
118115
|
+
workspace: resolveWorkspaceForOwner(workspaceId, uid, {
|
|
118116
|
+
exists: snap.exists(),
|
|
118117
|
+
data: snap.exists() ? snap.data() : void 0
|
|
118118
|
+
}),
|
|
118119
|
+
synthesized: !snap.exists()
|
|
118120
|
+
};
|
|
118121
|
+
}
|
|
118122
|
+
async function readWorkspaceForOwner(workspaceId, uid) {
|
|
118123
|
+
return (await readWorkspaceForOwnerDetailed(workspaceId, uid)).workspace;
|
|
118124
|
+
}
|
|
117915
118125
|
function normalizeWorkspacePermissions(value) {
|
|
117916
118126
|
const out = {};
|
|
117917
118127
|
for (const key of WORKSPACE_PERMISSION_KEYS) {
|
|
@@ -117938,7 +118148,7 @@ async function listVisibleWorkspaces(uid, includeShared) {
|
|
|
117938
118148
|
out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
|
|
117939
118149
|
}
|
|
117940
118150
|
}
|
|
117941
|
-
return out;
|
|
118151
|
+
return withSyntheticPersonalWorkspace(uid, out);
|
|
117942
118152
|
}
|
|
117943
118153
|
async function getChatRole(eventId, uid) {
|
|
117944
118154
|
const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
|
|
@@ -118024,7 +118234,7 @@ function registerChatTools(server2) {
|
|
|
118024
118234
|
const { uid } = await authed(["chat:moderate"]);
|
|
118025
118235
|
await readEvent(eventId, uid, "moderate");
|
|
118026
118236
|
requireConfirm(confirm, "oko_delete_chat_message", { eventId, messageId });
|
|
118027
|
-
await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: Date.now(), by: uid });
|
|
118237
|
+
await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: Date.now(), by: uid, reason: "manual" });
|
|
118028
118238
|
return textResult({ eventId, messageId, deleted: true });
|
|
118029
118239
|
});
|
|
118030
118240
|
server2.registerTool("oko_mute_user", {
|
|
@@ -118112,7 +118322,7 @@ function registerChatTools(server2) {
|
|
|
118112
118322
|
title: "Set chat settings",
|
|
118113
118323
|
description: toolDescription({
|
|
118114
118324
|
summary: "Set slow mode and automod settings for an event chat.",
|
|
118115
|
-
args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle.", "confirm: must be true."],
|
|
118325
|
+
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."],
|
|
118116
118326
|
returns: "eventId and updated RTDB setting paths.",
|
|
118117
118327
|
examples: ["Use slowModeMs=5000 to slow busy chats; use automodEnabled=false as an emergency kill switch."]
|
|
118118
118328
|
}),
|
|
@@ -118120,19 +118330,22 @@ function registerChatTools(server2) {
|
|
|
118120
118330
|
eventId: eventIdField,
|
|
118121
118331
|
slowModeMs: external_exports.number().int().min(0).max(6e4).optional().describe("Slow mode delay in milliseconds."),
|
|
118122
118332
|
automodEnabled: external_exports.boolean().optional().describe("Enable or disable chat automoderation."),
|
|
118333
|
+
allowViewerLinks: external_exports.boolean().optional().describe("Allow viewers to post links in chat (default false: the rule blocks links from viewers)."),
|
|
118123
118334
|
confirm: external_exports.boolean().optional().describe("Required true to change live chat settings.")
|
|
118124
118335
|
},
|
|
118125
118336
|
outputSchema: basicEventOutputSchema,
|
|
118126
118337
|
annotations: idempotentWriteTool
|
|
118127
|
-
}, async ({ eventId, slowModeMs, automodEnabled, confirm }) => {
|
|
118338
|
+
}, async ({ eventId, slowModeMs, automodEnabled, allowViewerLinks, confirm }) => {
|
|
118128
118339
|
const { uid } = await authed(["chat:moderate"]);
|
|
118129
118340
|
await readEvent(eventId, uid, "moderate");
|
|
118130
|
-
requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null });
|
|
118341
|
+
requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null, allowViewerLinks: allowViewerLinks ?? null });
|
|
118131
118342
|
const patch = {};
|
|
118132
118343
|
if (slowModeMs !== void 0)
|
|
118133
118344
|
patch[`settings/${eventId}/slowModeMs`] = slowModeMs;
|
|
118134
118345
|
if (automodEnabled !== void 0)
|
|
118135
118346
|
patch[`settings/${eventId}/automodEnabled`] = automodEnabled;
|
|
118347
|
+
if (allowViewerLinks !== void 0)
|
|
118348
|
+
patch[`settings/${eventId}/allowViewerLinks`] = allowViewerLinks;
|
|
118136
118349
|
if (Object.keys(patch).length === 0) {
|
|
118137
118350
|
throw new Error("Provide slowModeMs and/or automodEnabled to change chat settings.");
|
|
118138
118351
|
}
|
|
@@ -118319,6 +118532,17 @@ function validateEventSetup(eventId, event) {
|
|
|
118319
118532
|
}
|
|
118320
118533
|
|
|
118321
118534
|
// build/tools/events.js
|
|
118535
|
+
function telegramBlocksPatch(args) {
|
|
118536
|
+
if (args.telegramChannelIds === void 0)
|
|
118537
|
+
return {};
|
|
118538
|
+
return {
|
|
118539
|
+
"telegram.enabled": args.telegramChannelIds.length > 0,
|
|
118540
|
+
"telegram.channelIds": args.telegramChannelIds
|
|
118541
|
+
};
|
|
118542
|
+
}
|
|
118543
|
+
function configureBlocksScopes(args) {
|
|
118544
|
+
return Object.keys(telegramBlocksPatch(args)).length > 0 ? ["setup:write", "telegram:write"] : ["setup:write"];
|
|
118545
|
+
}
|
|
118322
118546
|
function registerEventTools(server2) {
|
|
118323
118547
|
server2.registerTool("oko_list_my_events", {
|
|
118324
118548
|
title: "List my events",
|
|
@@ -118386,7 +118610,8 @@ function registerEventTools(server2) {
|
|
|
118386
118610
|
return { id: item.id, ...data };
|
|
118387
118611
|
});
|
|
118388
118612
|
}
|
|
118389
|
-
|
|
118613
|
+
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".'] } : {};
|
|
118614
|
+
return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor), ...projectionHint }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
|
|
118390
118615
|
}
|
|
118391
118616
|
throw new Error("Event list not received: the eventAccess index returned an empty result.");
|
|
118392
118617
|
});
|
|
@@ -118398,7 +118623,7 @@ function registerEventTools(server2) {
|
|
|
118398
118623
|
returns: "full event document fields plus id and public event URL.",
|
|
118399
118624
|
examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
|
|
118400
118625
|
notes: [
|
|
118401
|
-
"blocks.chat.mode
|
|
118626
|
+
"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."
|
|
118402
118627
|
]
|
|
118403
118628
|
}),
|
|
118404
118629
|
inputSchema: { eventId: eventIdField },
|
|
@@ -118558,20 +118783,21 @@ function registerEventTools(server2) {
|
|
|
118558
118783
|
title: "Configure event blocks",
|
|
118559
118784
|
description: toolDescription({
|
|
118560
118785
|
summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
|
|
118561
|
-
exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "chat": { "enabled": true
|
|
118786
|
+
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" } },
|
|
118562
118787
|
args: [
|
|
118563
118788
|
"eventId: event to edit.",
|
|
118564
118789
|
"chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
|
|
118565
118790
|
"forms: replaces configured lead forms when provided.",
|
|
118566
|
-
"telegramChannelIds: replaces configured Telegram channels when provided."
|
|
118791
|
+
"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."
|
|
118567
118792
|
],
|
|
118568
118793
|
returns: "event id and the list of Firestore fields updated.",
|
|
118569
|
-
examples: ["Use after oko_create_webinar to
|
|
118794
|
+
examples: ["Use after oko_create_webinar to enable chat, bind a CTA/form, and configure other audience blocks."],
|
|
118570
118795
|
notes: [
|
|
118571
118796
|
"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.",
|
|
118572
118797
|
"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.",
|
|
118573
118798
|
'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.',
|
|
118574
|
-
"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."
|
|
118799
|
+
"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.",
|
|
118800
|
+
"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."
|
|
118575
118801
|
]
|
|
118576
118802
|
}),
|
|
118577
118803
|
inputSchema: {
|
|
@@ -118586,8 +118812,12 @@ function registerEventTools(server2) {
|
|
|
118586
118812
|
outputSchema: updateOutputSchema,
|
|
118587
118813
|
annotations: writeTool
|
|
118588
118814
|
}, async (args) => {
|
|
118589
|
-
const { uid } = await authed(
|
|
118815
|
+
const { uid } = await authed(configureBlocksScopes(args));
|
|
118590
118816
|
const current = await readEvent(args.eventId, uid, "edit");
|
|
118817
|
+
const telegramPatch = telegramBlocksPatch(args);
|
|
118818
|
+
if (Object.keys(telegramPatch).length > 0 && !await eventGrantsMode(args.eventId, uid, "notify")) {
|
|
118819
|
+
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.");
|
|
118820
|
+
}
|
|
118591
118821
|
const patch = {};
|
|
118592
118822
|
if (args.chat?.enabled !== void 0)
|
|
118593
118823
|
patch["blocks.chat.enabled"] = args.chat.enabled;
|
|
@@ -118643,10 +118873,7 @@ function registerEventTools(server2) {
|
|
|
118643
118873
|
placeholder: field2.placeholder?.trim() || null
|
|
118644
118874
|
}))
|
|
118645
118875
|
}));
|
|
118646
|
-
|
|
118647
|
-
patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
|
|
118648
|
-
patch["telegram.channelIds"] = args.telegramChannelIds;
|
|
118649
|
-
}
|
|
118876
|
+
Object.assign(patch, telegramPatch);
|
|
118650
118877
|
const formsAfterPatch = patch.forms ?? current.forms;
|
|
118651
118878
|
const knownFormIds = new Set((Array.isArray(formsAfterPatch) ? formsAfterPatch : []).map((form) => typeof form?.id === "string" ? form.id : null).filter((id) => Boolean(id)));
|
|
118652
118879
|
for (const [field2, value] of [
|
|
@@ -118659,6 +118886,9 @@ function registerEventTools(server2) {
|
|
|
118659
118886
|
}
|
|
118660
118887
|
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
118661
118888
|
const hints = [];
|
|
118889
|
+
if (args.chat?.visibility !== void 0) {
|
|
118890
|
+
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.");
|
|
118891
|
+
}
|
|
118662
118892
|
const currentBlocks = current.blocks && typeof current.blocks === "object" ? current.blocks : {};
|
|
118663
118893
|
const bannerHasImage = Boolean(safeString(currentBlocks.promoBanner?.imageUrl) || safeString(currentBlocks.promoBanner?.imageMasterUrl));
|
|
118664
118894
|
if (args.promoBanner?.enabled === false && bannerHasImage) {
|
|
@@ -118758,6 +118988,9 @@ function registerEventTools(server2) {
|
|
|
118758
118988
|
const warnings = removedCardsWithImages.length > 0 ? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`] : [];
|
|
118759
118989
|
if (finalCards.repairedIdPositions.length > 0) {
|
|
118760
118990
|
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.`);
|
|
118991
|
+
if (args.items === void 0) {
|
|
118992
|
+
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.");
|
|
118993
|
+
}
|
|
118761
118994
|
}
|
|
118762
118995
|
return textResult({
|
|
118763
118996
|
eventId: args.eventId,
|
|
@@ -118867,8 +119100,16 @@ function registerImageTools(server2) {
|
|
|
118867
119100
|
}
|
|
118868
119101
|
}
|
|
118869
119102
|
const uploadedCardIds = new Set(images.map((image) => image.cardId).filter((cardId) => !failures.some((failure) => failure.cardId === cardId)));
|
|
118870
|
-
|
|
118871
|
-
const warnings =
|
|
119103
|
+
let variants = [];
|
|
119104
|
+
const warnings = [];
|
|
119105
|
+
if (uploadedCardIds.size > 0) {
|
|
119106
|
+
try {
|
|
119107
|
+
variants = await waitForImageVariantTargets(uid, eventId, [...uploadedCardIds].map((cardId) => ({ kind: "card", cardId })), timeoutSec);
|
|
119108
|
+
} catch (error3) {
|
|
119109
|
+
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.`);
|
|
119110
|
+
}
|
|
119111
|
+
}
|
|
119112
|
+
warnings.push(...variants.filter((item) => !item.ready).map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`));
|
|
118872
119113
|
warnings.push(...failures.map((failure) => `Card ${failure.cardId} upload failed: ${failure.error}`));
|
|
118873
119114
|
return textResult({
|
|
118874
119115
|
eventId,
|
|
@@ -119030,14 +119271,37 @@ async function recordToolAudit(toolName, outcome, args, startedAt, error3) {
|
|
|
119030
119271
|
// build/tools/instrumentation.js
|
|
119031
119272
|
var REPORT_URL = `${APP_ORIGIN}/contact`;
|
|
119032
119273
|
var KEYS_URL = `${APP_ORIGIN}/app/mcp`;
|
|
119033
|
-
var
|
|
119034
|
-
|
|
119035
|
-
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.",
|
|
119036
|
-
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."
|
|
119274
|
+
var PERMISSION_GATED_ARGS = {
|
|
119275
|
+
telegramChannelIds: "telegramChannelIds \u2014 needs the telegram:write scope and the notification permission on the event"
|
|
119037
119276
|
};
|
|
119277
|
+
var SCOPE_WORKAROUNDS = {
|
|
119278
|
+
"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." },
|
|
119279
|
+
"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." },
|
|
119280
|
+
"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." },
|
|
119281
|
+
// 🔴 The most frequent refusal of this whole release (`ADV-A6-19`): an
|
|
119282
|
+
// `operator` key calling oko_configure_blocks with channel ids. The advice
|
|
119283
|
+
// promises that the rest of the call would work — true only when the key can
|
|
119284
|
+
// configure blocks at all.
|
|
119285
|
+
"oko_configure_blocks|telegram:write": {
|
|
119286
|
+
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.",
|
|
119287
|
+
requires: ["setup:write"]
|
|
119288
|
+
}
|
|
119289
|
+
};
|
|
119290
|
+
function scopeWorkaroundFor(toolName, missing, granted) {
|
|
119291
|
+
const entry = SCOPE_WORKAROUNDS[`${toolName}|${missing}`];
|
|
119292
|
+
if (!entry)
|
|
119293
|
+
return void 0;
|
|
119294
|
+
if (entry.requires?.some((scope) => !granted.includes(scope)))
|
|
119295
|
+
return void 0;
|
|
119296
|
+
return entry.text;
|
|
119297
|
+
}
|
|
119038
119298
|
function presetsWithScope2(scope) {
|
|
119039
119299
|
return Object.entries(MCP_PRESETS).filter(([, scopes]) => scopes.includes(scope)).map(([name7]) => name7);
|
|
119040
119300
|
}
|
|
119301
|
+
function permissionGatedArgsIn(args) {
|
|
119302
|
+
const source = args && typeof args === "object" ? args : {};
|
|
119303
|
+
return Object.entries(PERMISSION_GATED_ARGS).filter(([key]) => source[key] !== void 0).map(([, explanation]) => explanation);
|
|
119304
|
+
}
|
|
119041
119305
|
function idsFromArgs(args) {
|
|
119042
119306
|
if (!args || typeof args !== "object")
|
|
119043
119307
|
return {};
|
|
@@ -119052,7 +119316,7 @@ function idsFromArgs(args) {
|
|
|
119052
119316
|
function scopeRejectionResult(toolName, scope) {
|
|
119053
119317
|
const granted = currentScopes();
|
|
119054
119318
|
const presets = presetsWithScope2(scope);
|
|
119055
|
-
const workaround =
|
|
119319
|
+
const workaround = scopeWorkaroundFor(toolName, scope, granted);
|
|
119056
119320
|
return {
|
|
119057
119321
|
...textResult({
|
|
119058
119322
|
errorCode: "SCOPE_REQUIRED",
|
|
@@ -119091,22 +119355,27 @@ function platformRejectionResult(toolName, args, readOnly) {
|
|
|
119091
119355
|
isError: true
|
|
119092
119356
|
};
|
|
119093
119357
|
}
|
|
119358
|
+
const gated = permissionGatedArgsIn(args);
|
|
119094
119359
|
return {
|
|
119095
119360
|
...textResult({
|
|
119096
119361
|
errorCode: "PLATFORM_REJECTED_WRITE",
|
|
119097
119362
|
tool: toolName,
|
|
119098
119363
|
...ids,
|
|
119099
|
-
message: "OKO's security rules refused this write. The request reached the platform and was rejected server-side; nothing was changed.",
|
|
119364
|
+
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.",
|
|
119365
|
+
mayBeCausedBy: [
|
|
119366
|
+
`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"}.`,
|
|
119367
|
+
`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.`
|
|
119368
|
+
],
|
|
119100
119369
|
notCausedBy: [
|
|
119101
|
-
`API key scopes: this key holds ${currentScopes().join(", ") || "no scopes"}, and the tool's scope check passed before the write.`,
|
|
119102
119370
|
`Client version: ${OKO_MCP_VERSION}${update2.updateAvailable ? ` (an update to ${update2.latest} is available, but rules apply to every version equally)` : " (current)"}.`,
|
|
119103
119371
|
"Wording or parameter choice: the same rule rejects the same data regardless of phrasing."
|
|
119104
119372
|
],
|
|
119105
119373
|
whatToDo: [
|
|
119106
119374
|
"Do not retry with different parameters and do not create the event again: retries hit the same rule and only produce duplicates.",
|
|
119107
|
-
|
|
119375
|
+
...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.`] : [],
|
|
119376
|
+
"If no such field is involved, tell the organizer plainly that the OKO platform refused this change.",
|
|
119108
119377
|
`Ask them to report it to OKO at ${REPORT_URL} with the "report" block below, so it can be fixed on the platform side.`,
|
|
119109
|
-
...toolName === "oko_create_webinar" ? ["If the organizer needs the page today: oko_duplicate_event of an existing event
|
|
119378
|
+
...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."] : []
|
|
119110
119379
|
],
|
|
119111
119380
|
report: {
|
|
119112
119381
|
tool: toolName,
|
|
@@ -119175,6 +119444,17 @@ function installToolInstrumentation(server2) {
|
|
|
119175
119444
|
}
|
|
119176
119445
|
|
|
119177
119446
|
// build/tools/leads.js
|
|
119447
|
+
function leadSubmittedMs(lead) {
|
|
119448
|
+
const value = lead.submittedAt;
|
|
119449
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
119450
|
+
return value;
|
|
119451
|
+
if (typeof value === "string") {
|
|
119452
|
+
const parsed = Date.parse(value);
|
|
119453
|
+
if (Number.isFinite(parsed))
|
|
119454
|
+
return parsed;
|
|
119455
|
+
}
|
|
119456
|
+
return 0;
|
|
119457
|
+
}
|
|
119178
119458
|
function registerLeadTools(server2) {
|
|
119179
119459
|
server2.registerTool("oko_list_leads", {
|
|
119180
119460
|
title: "List form leads",
|
|
@@ -119187,7 +119467,7 @@ function registerLeadTools(server2) {
|
|
|
119187
119467
|
"limit: max rows, default 50, max 200.",
|
|
119188
119468
|
"cursor: lead document id returned as nextCursor by the previous page."
|
|
119189
119469
|
],
|
|
119190
|
-
returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
119470
|
+
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.",
|
|
119191
119471
|
examples: ["Use formId to export one form; if Firestore requests an index, create it from the error link and rerun."]
|
|
119192
119472
|
}),
|
|
119193
119473
|
inputSchema: {
|
|
@@ -119203,10 +119483,21 @@ function registerLeadTools(server2) {
|
|
|
119203
119483
|
const { uid } = await authed(["leads:read"]);
|
|
119204
119484
|
await readEvent(eventId, uid, "analytics");
|
|
119205
119485
|
const base = collection(firestore, "events", eventId, "leads");
|
|
119206
|
-
|
|
119486
|
+
let constraints;
|
|
119487
|
+
if (formId) {
|
|
119488
|
+
constraints = [where("formId", "==", formId), orderBy(documentId$1()), ...cursor ? [startAfter(cursor)] : [], limit(max + 1)];
|
|
119489
|
+
} else {
|
|
119490
|
+
const cursorSnap = cursor ? await getDoc(doc(base, cursor)) : null;
|
|
119491
|
+
if (cursor && !cursorSnap?.exists()) {
|
|
119492
|
+
throw new Error(`Lead cursor ${cursor} no longer exists (the submission was deleted). Restart the listing without a cursor.`);
|
|
119493
|
+
}
|
|
119494
|
+
constraints = [orderBy("submittedAt", "desc"), ...cursorSnap ? [startAfter(cursorSnap)] : [], limit(max + 1)];
|
|
119495
|
+
}
|
|
119207
119496
|
const snap = await getDocs(query2(base, ...constraints));
|
|
119208
119497
|
const page = snap.docs.slice(0, max);
|
|
119209
119498
|
const leads = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
119499
|
+
if (formId)
|
|
119500
|
+
leads.sort((a, b) => leadSubmittedMs(b) - leadSubmittedMs(a));
|
|
119210
119501
|
const hasMore = snap.docs.length > max;
|
|
119211
119502
|
const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
|
|
119212
119503
|
const meta2 = cursorMeta(leads.length, max, hasMore, nextCursor);
|
|
@@ -119248,6 +119539,13 @@ function registerLeadTools(server2) {
|
|
|
119248
119539
|
|
|
119249
119540
|
// build/tools/lifecycle.js
|
|
119250
119541
|
function registerLifecycleTools(server2) {
|
|
119542
|
+
const moveToTrash = async (toolName, { eventId, confirm }) => {
|
|
119543
|
+
const { uid } = await authed(["events:delete"]);
|
|
119544
|
+
const event = await readEvent(eventId, uid, "delete");
|
|
119545
|
+
requireConfirm(confirm, toolName, { eventId, title: event.title });
|
|
119546
|
+
const trash = httpsCallable(functions, "trashEvent");
|
|
119547
|
+
return textResult((await trash({ eventId })).data);
|
|
119548
|
+
};
|
|
119251
119549
|
server2.registerTool("oko_delete_event", {
|
|
119252
119550
|
title: "Move event to trash",
|
|
119253
119551
|
description: toolDescription({
|
|
@@ -119260,14 +119558,7 @@ function registerLifecycleTools(server2) {
|
|
|
119260
119558
|
inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
|
|
119261
119559
|
outputSchema: external_exports.object({ trashed: external_exports.string(), purgeAfter: external_exports.number().nullable() }).passthrough(),
|
|
119262
119560
|
annotations: destructiveTool
|
|
119263
|
-
}, async ({ eventId, confirm }) => {
|
|
119264
|
-
const { uid } = await authed(["events:delete"]);
|
|
119265
|
-
const data = await readEvent(eventId, uid, "delete");
|
|
119266
|
-
requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
|
|
119267
|
-
const trash = httpsCallable(functions, "trashEvent");
|
|
119268
|
-
const result = await trash({ eventId });
|
|
119269
|
-
return textResult(result.data);
|
|
119270
|
-
});
|
|
119561
|
+
}, async ({ eventId, confirm }) => moveToTrash("oko_delete_event", { eventId, confirm }));
|
|
119271
119562
|
server2.registerTool("oko_trash_event", {
|
|
119272
119563
|
title: "Move event to trash",
|
|
119273
119564
|
description: toolDescription({
|
|
@@ -119280,13 +119571,7 @@ function registerLifecycleTools(server2) {
|
|
|
119280
119571
|
inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
|
|
119281
119572
|
outputSchema: external_exports.object({ trashed: external_exports.string(), purgeAfter: external_exports.number().nullable() }).passthrough(),
|
|
119282
119573
|
annotations: destructiveTool
|
|
119283
|
-
}, async ({ eventId, confirm }) => {
|
|
119284
|
-
const { uid } = await authed(["events:delete"]);
|
|
119285
|
-
const event = await readEvent(eventId, uid, "delete");
|
|
119286
|
-
requireConfirm(confirm, "oko_trash_event", { eventId, title: event.title });
|
|
119287
|
-
const trash = httpsCallable(functions, "trashEvent");
|
|
119288
|
-
return textResult((await trash({ eventId })).data);
|
|
119289
|
-
});
|
|
119574
|
+
}, async ({ eventId, confirm }) => moveToTrash("oko_trash_event", { eventId, confirm }));
|
|
119290
119575
|
server2.registerTool("oko_restore_event", {
|
|
119291
119576
|
title: "Restore event",
|
|
119292
119577
|
description: toolDescription({
|
|
@@ -119346,7 +119631,8 @@ function registerLifecycleTools(server2) {
|
|
|
119346
119631
|
notes: [
|
|
119347
119632
|
"Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions or leads.",
|
|
119348
119633
|
"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.",
|
|
119349
|
-
"Requires only events:write plus createEvents in the target workspace. To move (not copy) an event, use oko_move_event with the same scope."
|
|
119634
|
+
"Requires only events:write plus createEvents in the target workspace. To move (not copy) an event, use oko_move_event with the same scope.",
|
|
119635
|
+
"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."
|
|
119350
119636
|
]
|
|
119351
119637
|
}),
|
|
119352
119638
|
inputSchema: {
|
|
@@ -119360,6 +119646,8 @@ function registerLifecycleTools(server2) {
|
|
|
119360
119646
|
const source = await readEvent(eventId, uid, "read");
|
|
119361
119647
|
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
119362
119648
|
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
119649
|
+
const sourceHasTelegram = !!source.telegram && typeof source.telegram === "object";
|
|
119650
|
+
const mayCopyTelegram = sourceHasTelegram && currentScopes().includes("telegram:write") && await eventGrantsMode(eventId, uid, "notify");
|
|
119363
119651
|
const newRef = await addEventWithRtdbMirror({
|
|
119364
119652
|
ownerId: uid,
|
|
119365
119653
|
workspaceId,
|
|
@@ -119375,7 +119663,7 @@ function registerLifecycleTools(server2) {
|
|
|
119375
119663
|
// function skips events with this flag forever: a copy of an event whose
|
|
119376
119664
|
// reminder has already gone out would never get its own — silently
|
|
119377
119665
|
// (review 02.09, R3b-5).
|
|
119378
|
-
...
|
|
119666
|
+
...mayCopyTelegram ? { telegram: copyableTelegram(source.telegram) } : {}
|
|
119379
119667
|
});
|
|
119380
119668
|
return textResult({
|
|
119381
119669
|
id: newRef.id,
|
|
@@ -119383,7 +119671,9 @@ function registerLifecycleTools(server2) {
|
|
|
119383
119671
|
workspaceId,
|
|
119384
119672
|
...eventLinks(newRef.id),
|
|
119385
119673
|
liveStateNotCopied: ["chatMode", "placeholderVisible", "liveBadge", "block visibility overrides"],
|
|
119674
|
+
telegramCopied: mayCopyTelegram,
|
|
119386
119675
|
hints: [
|
|
119676
|
+
...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."] : [],
|
|
119387
119677
|
"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.",
|
|
119388
119678
|
"The copy is live at publicUrl immediately; edit title, eventDate, and video with oko_update_event, then run oko_validate_event_setup."
|
|
119389
119679
|
]
|
|
@@ -119414,19 +119704,31 @@ function registerLifecycleTools(server2) {
|
|
|
119414
119704
|
requireConfirm(confirm, "oko_move_event", { eventId, targetWorkspaceId });
|
|
119415
119705
|
const moveEvent = httpsCallable(functions, "moveEventToWorkspace");
|
|
119416
119706
|
const result = await moveEvent({ eventId, targetWorkspaceId });
|
|
119707
|
+
if (result.data.rtdbMirrorUpdated === false) {
|
|
119708
|
+
return textResult({
|
|
119709
|
+
...result.data,
|
|
119710
|
+
hints: [
|
|
119711
|
+
"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."
|
|
119712
|
+
]
|
|
119713
|
+
});
|
|
119714
|
+
}
|
|
119417
119715
|
return textResult(result.data);
|
|
119418
119716
|
});
|
|
119419
119717
|
}
|
|
119420
119718
|
|
|
119421
119719
|
// build/tools/live.js
|
|
119720
|
+
var CHAT_LIVE_SETTINGS = ["mode", "slowModeMs", "automodEnabled", "allowViewerLinks"];
|
|
119422
119721
|
function registerLiveTools(server2) {
|
|
119423
119722
|
server2.registerTool("oko_get_live_state", {
|
|
119424
119723
|
title: "Get live state",
|
|
119425
119724
|
description: toolDescription({
|
|
119426
119725
|
summary: "Read main RTDB live controls, aggregate analytics, and chat settings for an event.",
|
|
119427
119726
|
args: ["eventId: event to inspect."],
|
|
119428
|
-
returns: "eventId, agg metrics, ctrl live overrides, and chat settings.",
|
|
119429
|
-
examples: ["Use during a webinar before changing live block visibility or starting/ending sessions."]
|
|
119727
|
+
returns: "eventId, agg metrics, ctrl live overrides, and chat settings (mode, slowModeMs, automodEnabled, allowViewerLinks).",
|
|
119728
|
+
examples: ["Use during a webinar before changing live block visibility or starting/ending sessions."],
|
|
119729
|
+
notes: [
|
|
119730
|
+
"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."
|
|
119731
|
+
]
|
|
119430
119732
|
}),
|
|
119431
119733
|
inputSchema: { eventId: eventIdField },
|
|
119432
119734
|
outputSchema: liveStateOutputSchema,
|
|
@@ -119434,12 +119736,18 @@ function registerLiveTools(server2) {
|
|
|
119434
119736
|
}, async ({ eventId }) => {
|
|
119435
119737
|
const { uid } = await authed(["analytics:read"]);
|
|
119436
119738
|
await readEvent(eventId, uid, "analytics");
|
|
119437
|
-
const [aggSnap, ctrlSnap,
|
|
119739
|
+
const [aggSnap, ctrlSnap, ...chatSnaps] = await Promise.all([
|
|
119438
119740
|
get(ref(rtdb, `events/${eventId}/agg`)),
|
|
119439
119741
|
get(ref(rtdb, `events/${eventId}/ctrl`)),
|
|
119440
|
-
get(ref(chatRtdb, `settings/${eventId}`))
|
|
119742
|
+
...CHAT_LIVE_SETTINGS.map((field2) => get(ref(chatRtdb, `settings/${eventId}/${field2}`)))
|
|
119441
119743
|
]);
|
|
119442
|
-
|
|
119744
|
+
const chat = {};
|
|
119745
|
+
CHAT_LIVE_SETTINGS.forEach((field2, index) => {
|
|
119746
|
+
const value = chatSnaps[index]?.val();
|
|
119747
|
+
if (value !== null && value !== void 0)
|
|
119748
|
+
chat[field2] = value;
|
|
119749
|
+
});
|
|
119750
|
+
return textResult({ eventId, agg: aggSnap.val() ?? {}, ctrl: ctrlSnap.val() ?? {}, chat });
|
|
119443
119751
|
});
|
|
119444
119752
|
server2.registerTool("oko_set_block_visibility", {
|
|
119445
119753
|
title: "Set live block visibility",
|
|
@@ -119484,18 +119792,15 @@ function registerLiveTools(server2) {
|
|
|
119484
119792
|
};
|
|
119485
119793
|
if (block === "chat") {
|
|
119486
119794
|
const modeRef = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
|
|
119487
|
-
const
|
|
119488
|
-
if (visible === null) {
|
|
119489
|
-
|
|
119490
|
-
} else if (visible === false) {
|
|
119491
|
-
await set(modeRef, "off");
|
|
119492
|
-
await remove(legacyRef);
|
|
119795
|
+
const patch = { chatVisible: null };
|
|
119796
|
+
if (visible === null || visible === false) {
|
|
119797
|
+
patch.chatMode = visible === false ? "off" : null;
|
|
119493
119798
|
} else {
|
|
119494
119799
|
const current = await get(modeRef);
|
|
119495
119800
|
if (current.val() === "off")
|
|
119496
|
-
|
|
119497
|
-
await remove(legacyRef);
|
|
119801
|
+
patch.chatMode = null;
|
|
119498
119802
|
}
|
|
119803
|
+
await update(ref(rtdb, `events/${eventId}/ctrl`), patch);
|
|
119499
119804
|
return textResult({ eventId, block, visible, hints });
|
|
119500
119805
|
}
|
|
119501
119806
|
const target = ref(rtdb, `events/${eventId}/ctrl/${paths[block]}`);
|
|
@@ -119520,7 +119825,7 @@ function registerLiveTools(server2) {
|
|
|
119520
119825
|
const { uid } = await authed(["live:control"]);
|
|
119521
119826
|
await readEvent(eventId, uid, "live");
|
|
119522
119827
|
requireConfirm(confirm, "oko_refresh_viewers", { eventId });
|
|
119523
|
-
await
|
|
119828
|
+
await runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1);
|
|
119524
119829
|
return textResult({ eventId, refreshed: true });
|
|
119525
119830
|
});
|
|
119526
119831
|
server2.registerTool("oko_set_live_badge", {
|
|
@@ -119564,23 +119869,26 @@ function registerLiveTools(server2) {
|
|
|
119564
119869
|
server2.registerTool("oko_set_chat_mode", {
|
|
119565
119870
|
title: "Set chat mode",
|
|
119566
119871
|
description: toolDescription({
|
|
119567
|
-
summary: "
|
|
119872
|
+
summary: "Open or close chat sending on the public event page.",
|
|
119568
119873
|
exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "mode": "off", "confirm": true },
|
|
119569
119874
|
args: [
|
|
119570
119875
|
"eventId: event to control.",
|
|
119571
|
-
"mode: public (anyone
|
|
119876
|
+
"mode: public (anyone can send), off (input closed, history stays visible), or null to reset to the product default (public).",
|
|
119572
119877
|
"confirm: must be true."
|
|
119573
119878
|
],
|
|
119574
119879
|
returns: "eventId and the effective chat mode.",
|
|
119575
119880
|
examples: ['Use mode="off" to pause a chat storm; use mode=null or "public" to reopen.'],
|
|
119576
119881
|
notes: [
|
|
119577
119882
|
'This is the full replacement for the legacy pause via oko_set_block_visibility(block="chat"): prefer oko_set_chat_mode.',
|
|
119578
|
-
"History always
|
|
119883
|
+
"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."
|
|
119579
119884
|
]
|
|
119580
119885
|
}),
|
|
119581
119886
|
inputSchema: {
|
|
119582
119887
|
eventId: eventIdField,
|
|
119583
|
-
|
|
119888
|
+
// LD5a-2 / A8 (owner decision 03.09): "auth" and "owner" were promised
|
|
119889
|
+
// here and enforced nowhere — the rule and every client only check
|
|
119890
|
+
// `mode !== "off"`. The contract now says what the server does.
|
|
119891
|
+
mode: external_exports.enum(["public", "off"]).nullable().describe("Chat mode: public or off; null resets the override to the default."),
|
|
119584
119892
|
confirm: external_exports.boolean().optional().describe("Required true to change the live chat mode.")
|
|
119585
119893
|
},
|
|
119586
119894
|
outputSchema: basicEventOutputSchema,
|
|
@@ -119589,11 +119897,7 @@ function registerLiveTools(server2) {
|
|
|
119589
119897
|
const { uid } = await authed(["live:control"]);
|
|
119590
119898
|
await readEvent(eventId, uid, "live");
|
|
119591
119899
|
requireConfirm(confirm, "oko_set_chat_mode", { eventId, mode });
|
|
119592
|
-
|
|
119593
|
-
if (mode === null)
|
|
119594
|
-
await remove(target);
|
|
119595
|
-
else
|
|
119596
|
-
await set(target, mode);
|
|
119900
|
+
await update(ref(rtdb, `events/${eventId}/ctrl`), { chatMode: mode, chatVisible: null });
|
|
119597
119901
|
return textResult({ eventId, mode });
|
|
119598
119902
|
});
|
|
119599
119903
|
server2.registerTool("oko_force_viewer_reload", {
|
|
@@ -119686,7 +119990,7 @@ function registerNotificationTools(server2) {
|
|
|
119686
119990
|
],
|
|
119687
119991
|
returns: "markedRead count and notification ids updated.",
|
|
119688
119992
|
examples: ["Use after oko_list_notifications when the user asks to clear the bell/unread state."],
|
|
119689
|
-
notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications."]
|
|
119993
|
+
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)."]
|
|
119690
119994
|
}),
|
|
119691
119995
|
inputSchema: {
|
|
119692
119996
|
notificationIds: external_exports.array(external_exports.string().min(1).max(160)).max(100).optional().describe("Specific notification document ids."),
|
|
@@ -119696,7 +120000,7 @@ function registerNotificationTools(server2) {
|
|
|
119696
120000
|
outputSchema: notificationsMarkOutputSchema,
|
|
119697
120001
|
annotations: idempotentWriteTool
|
|
119698
120002
|
}, async ({ notificationIds, allUnread, limit: max }) => {
|
|
119699
|
-
const { uid } = await authed(["workspace:
|
|
120003
|
+
const { uid } = await authed(["workspace:read"]);
|
|
119700
120004
|
if (Boolean(notificationIds?.length) === allUnread) {
|
|
119701
120005
|
throw new Error("Provide either notificationIds or allUnread=true.");
|
|
119702
120006
|
}
|
|
@@ -120152,7 +120456,7 @@ function registerTelegramTools(server2) {
|
|
|
120152
120456
|
"limit: max posts, default 30, max 100.",
|
|
120153
120457
|
"cursor: createdAt cursor returned by previous page."
|
|
120154
120458
|
],
|
|
120155
|
-
returns: "posts plus cursor pagination metadata.",
|
|
120459
|
+
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).",
|
|
120156
120460
|
examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."]
|
|
120157
120461
|
}),
|
|
120158
120462
|
inputSchema: {
|
|
@@ -120218,18 +120522,21 @@ function registerTelegramTools(server2) {
|
|
|
120218
120522
|
args: [
|
|
120219
120523
|
"eventId: event used for access checks and default button URL.",
|
|
120220
120524
|
"channelId: Telegram channel id configured for the organizer.",
|
|
120221
|
-
"caption: post caption, max 4096 chars.",
|
|
120525
|
+
"caption: post caption, max 4096 chars, parsed as Telegram HTML.",
|
|
120222
120526
|
"scheduleAt: optional ISO datetime; omitted means immediate send.",
|
|
120223
120527
|
"confirm: must be true for both immediate and scheduled publication."
|
|
120224
120528
|
],
|
|
120225
120529
|
returns: "postId, scheduled flag, and sent flag.",
|
|
120226
120530
|
examples: ["Use scheduleAt for scheduled posts; for immediate send, show the exact channel/caption and ask for confirmation."],
|
|
120227
|
-
notes: [
|
|
120531
|
+
notes: [
|
|
120532
|
+
"External side effect: immediate sends can publish to real Telegram channels.",
|
|
120533
|
+
"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."
|
|
120534
|
+
]
|
|
120228
120535
|
}),
|
|
120229
120536
|
inputSchema: {
|
|
120230
120537
|
eventId: eventIdField,
|
|
120231
120538
|
channelId: external_exports.number().int().describe("Telegram channel id."),
|
|
120232
|
-
caption: external_exports.string().min(1).max(4096).describe("Telegram post caption."),
|
|
120539
|
+
caption: external_exports.string().min(1).max(4096).describe("Telegram post caption, parsed as Telegram HTML; escape &, < and > in text taken from event fields."),
|
|
120233
120540
|
imageUrl: external_exports.string().nullable().optional().describe("Optional image URL."),
|
|
120234
120541
|
buttonLabel: external_exports.string().max(64).nullable().optional().describe("Optional inline button label."),
|
|
120235
120542
|
buttonUrl: external_exports.string().max(1e3).nullable().optional().describe("Optional inline button URL; defaults to event URL when buttonLabel is set."),
|
|
@@ -120267,8 +120574,10 @@ function registerTelegramTools(server2) {
|
|
|
120267
120574
|
channelId: args.channelId,
|
|
120268
120575
|
channelKey: String(args.channelId),
|
|
120269
120576
|
caption: args.caption.trim(),
|
|
120270
|
-
|
|
120271
|
-
|
|
120577
|
+
// R3c-10 (04.09): a wrong protocol used to surface only from Telegram at
|
|
120578
|
+
// delivery, leaving the post `failed`; the check now precedes the write.
|
|
120579
|
+
imageUrl: absoluteHttpUrl(args.imageUrl, "imageUrl"),
|
|
120580
|
+
buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: absoluteHttpUrl(args.buttonUrl, "buttonUrl") ?? eventUrl(args.eventId) }] : [],
|
|
120272
120581
|
// 🔴 Immediate sending is `pending`, as in the web, not `draft`. A break
|
|
120273
120582
|
// between creating the document and sending left the post a "draft",
|
|
120274
120583
|
// indistinguishable from one never sent: CF15 picks up only `scheduled`,
|
|
@@ -120342,8 +120651,17 @@ function registerWorkspaceTools(server2) {
|
|
|
120342
120651
|
annotations: idempotentWriteTool
|
|
120343
120652
|
}, async ({ workspaceId, name: name7 }) => {
|
|
120344
120653
|
const { uid } = await authed(["workspace:write"]);
|
|
120345
|
-
await
|
|
120346
|
-
|
|
120654
|
+
const { synthesized } = await readWorkspaceForOwnerDetailed(workspaceId, uid);
|
|
120655
|
+
if (synthesized) {
|
|
120656
|
+
await setDoc(doc(firestore, "workspaces", workspaceId), {
|
|
120657
|
+
name: name7.trim(),
|
|
120658
|
+
ownerId: uid,
|
|
120659
|
+
personal: true,
|
|
120660
|
+
createdAt: serverTimestamp2()
|
|
120661
|
+
});
|
|
120662
|
+
} else {
|
|
120663
|
+
await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name7.trim() });
|
|
120664
|
+
}
|
|
120347
120665
|
return textResult({ workspaceId, updated: ["name"] });
|
|
120348
120666
|
});
|
|
120349
120667
|
server2.registerTool("oko_delete_workspace", {
|
|
@@ -120361,12 +120679,12 @@ function registerWorkspaceTools(server2) {
|
|
|
120361
120679
|
}, async ({ workspaceId, confirm }) => {
|
|
120362
120680
|
const { uid } = await authed(["workspace:write"]);
|
|
120363
120681
|
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
120364
|
-
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
120365
120682
|
if (workspace.personal === true)
|
|
120366
120683
|
throw new Error("Personal workspace cannot be deleted.");
|
|
120684
|
+
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
120367
120685
|
const removeWorkspace = httpsCallable(functions, "deleteWorkspaceCompletely");
|
|
120368
120686
|
const removed = await removeWorkspace({ workspaceId });
|
|
120369
|
-
return textResult({ ...removed.data,
|
|
120687
|
+
return textResult({ ...removed.data, deleted: removed.data.deleted === true });
|
|
120370
120688
|
});
|
|
120371
120689
|
server2.registerTool("oko_list_workspace_access", {
|
|
120372
120690
|
title: "List workspace access",
|
|
@@ -120387,16 +120705,26 @@ function registerWorkspaceTools(server2) {
|
|
|
120387
120705
|
getDocs(query2(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)))
|
|
120388
120706
|
]);
|
|
120389
120707
|
const members = [];
|
|
120708
|
+
let anyProfileRestricted = false;
|
|
120390
120709
|
for (const share of sharesSnap.docs) {
|
|
120391
|
-
const profile = await getDoc(doc(firestore, "userProfiles", share.id)).then((snap) => snap.exists() ? serialize(snap.data()) : null).catch(() =>
|
|
120710
|
+
const profile = await getDoc(doc(firestore, "userProfiles", share.id)).then((snap) => ({ value: snap.exists() ? serialize(snap.data()) : null, restricted: false })).catch((error3) => {
|
|
120711
|
+
if (isFirestorePermissionDenied(error3))
|
|
120712
|
+
return { value: null, restricted: true };
|
|
120713
|
+
console.error(`[oko-mcp] userProfiles/${share.id} read failed: ${error3 instanceof Error ? error3.message : String(error3)}`);
|
|
120714
|
+
return { value: null, restricted: false, failed: true };
|
|
120715
|
+
});
|
|
120716
|
+
if (profile.restricted)
|
|
120717
|
+
anyProfileRestricted = true;
|
|
120392
120718
|
members.push({
|
|
120393
120719
|
uid: share.id,
|
|
120394
120720
|
...serialize(share.data()),
|
|
120395
|
-
profile
|
|
120721
|
+
profile: profile.value,
|
|
120722
|
+
profileAccess: profile.restricted ? "restricted" : "failed" in profile ? "unavailable" : "readable"
|
|
120396
120723
|
});
|
|
120397
120724
|
}
|
|
120398
120725
|
const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
|
|
120399
|
-
|
|
120726
|
+
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."] : [];
|
|
120727
|
+
return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites, hints });
|
|
120400
120728
|
});
|
|
120401
120729
|
server2.registerTool("oko_update_workspace_member", {
|
|
120402
120730
|
title: "Update workspace member",
|
|
@@ -120420,6 +120748,11 @@ function registerWorkspaceTools(server2) {
|
|
|
120420
120748
|
}, async (args) => {
|
|
120421
120749
|
const { uid } = await authed(["workspace:write"]);
|
|
120422
120750
|
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
120751
|
+
if (!isSharedWorkspaceId(args.workspaceId))
|
|
120752
|
+
throw new Error(personalWorkspaceLimitMessage("adding or changing members"));
|
|
120753
|
+
if (args.uid === workspace.ownerId) {
|
|
120754
|
+
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.");
|
|
120755
|
+
}
|
|
120423
120756
|
requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
|
|
120424
120757
|
const shareRef = doc(firestore, "workspaces", args.workspaceId, "shares", args.uid);
|
|
120425
120758
|
const existing = await getDoc(shareRef);
|
|
@@ -120464,9 +120797,15 @@ function registerWorkspaceTools(server2) {
|
|
|
120464
120797
|
}, async ({ workspaceId, uid: targetUid, confirm }) => {
|
|
120465
120798
|
const { uid } = await authed(["workspace:write"]);
|
|
120466
120799
|
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
120800
|
+
if (!isSharedWorkspaceId(workspaceId))
|
|
120801
|
+
throw new Error(personalWorkspaceLimitMessage("removing members"));
|
|
120467
120802
|
requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
|
|
120468
|
-
|
|
120469
|
-
|
|
120803
|
+
const shareRef = doc(firestore, "workspaces", workspaceId, "shares", targetUid);
|
|
120804
|
+
const outcome = workspaceMemberRemoval((await getDoc(shareRef)).exists(), workspaceId, targetUid);
|
|
120805
|
+
if (outcome.removed === false)
|
|
120806
|
+
return textResult({ workspaceId, uid: targetUid, ...outcome });
|
|
120807
|
+
await deleteDoc(shareRef);
|
|
120808
|
+
return textResult({ workspaceId, uid: targetUid, ...outcome });
|
|
120470
120809
|
});
|
|
120471
120810
|
server2.registerTool("oko_list_workspace_invites", {
|
|
120472
120811
|
title: "List workspace invites",
|
|
@@ -120543,7 +120882,11 @@ function registerWorkspaceTools(server2) {
|
|
|
120543
120882
|
"workspaceId: workspace owned by the authenticated organizer.",
|
|
120544
120883
|
"code: optional invite code, 16-64 chars; omitted means generated (recommended).",
|
|
120545
120884
|
"email: optional intended recipient email.",
|
|
120546
|
-
|
|
120885
|
+
// The schema is `workspacePermissionsSchema`: nine booleans, all
|
|
120886
|
+
// required, no extra keys. "optional whitelist" read as "send the
|
|
120887
|
+
// ones you want", and every such call was rejected by validation
|
|
120888
|
+
// before it reached the platform.
|
|
120889
|
+
"permissions: required object of all nine workspace permission booleans (createEvents, editSetup, deleteEvents, controlLive, moderateChat, startSession, viewAnalytics, downloadReports, sendNotifications) \u2014 every key must be present, extra keys are rejected; set false for the ones the invitee should not get.",
|
|
120547
120890
|
"confirm: must be true."
|
|
120548
120891
|
],
|
|
120549
120892
|
returns: "workspaceId, invite code, inviteUrl, and created=true.",
|
|
@@ -120569,6 +120912,8 @@ function registerWorkspaceTools(server2) {
|
|
|
120569
120912
|
}, async (args) => {
|
|
120570
120913
|
const { uid } = await authed(["workspace:write"]);
|
|
120571
120914
|
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
120915
|
+
if (!isSharedWorkspaceId(args.workspaceId))
|
|
120916
|
+
throw new Error(personalWorkspaceLimitMessage("creating invites"));
|
|
120572
120917
|
requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
|
|
120573
120918
|
const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
|
|
120574
120919
|
const url = inviteUrl(code);
|