@okostream/mcp 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -38
- package/dist/server.js +1 -1
- package/dist/tools.js +1117 -31
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import sharp from "sharp";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { addDoc, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
|
|
9
|
+
import { addDoc, arrayRemove, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
|
|
10
10
|
import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
|
|
11
11
|
import { httpsCallable } from "firebase/functions";
|
|
12
12
|
import { deleteObject, getDownloadURL, ref as storageRef, uploadBytes, } from "firebase/storage";
|
|
@@ -17,6 +17,13 @@ import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
|
|
|
17
17
|
const boolOrNull = z.boolean().nullable();
|
|
18
18
|
const IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
|
|
19
19
|
const IMAGE_VARIANT_NAMES = ["thumb_320.webp", "card_800.webp", "hero_1600.webp"];
|
|
20
|
+
const CARD_IMAGE_RATIOS = ["auto", "1:1", "4:3", "3:2", "16:9", "2:3", "3:4"];
|
|
21
|
+
const CARD_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
|
|
22
|
+
const MAX_CARD_ITEMS = 6;
|
|
23
|
+
const MAX_CARD_TITLE_LENGTH = 160;
|
|
24
|
+
const MAX_CARD_TEXT_LENGTH = 500;
|
|
25
|
+
const MAX_CARD_BUTTON_LABEL_LENGTH = 80;
|
|
26
|
+
const MAX_CARD_BUTTON_URL_LENGTH = 1000;
|
|
20
27
|
const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
21
28
|
const MAX_SOURCE_IMAGE_PIXELS = 50_000_000;
|
|
22
29
|
const MAX_MASTER_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
@@ -32,6 +39,10 @@ const MAX_LIST_LIMIT = 100;
|
|
|
32
39
|
const MAX_EXPORT_LIMIT = 200;
|
|
33
40
|
const ANALYTICS_DEFAULT_LIMIT = 10;
|
|
34
41
|
const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
|
|
42
|
+
const MAX_NMO_IMPORT_PARTICIPANTS = 200;
|
|
43
|
+
const MAX_WORKSPACE_NAME_LENGTH = 120;
|
|
44
|
+
const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
|
|
45
|
+
const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed"];
|
|
35
46
|
const readOnlyTool = {
|
|
36
47
|
readOnlyHint: true,
|
|
37
48
|
destructiveHint: false,
|
|
@@ -133,6 +144,25 @@ const deleteImageOutputSchema = z.object({
|
|
|
133
144
|
storageDeletesAttempted: z.number().int().nonnegative(),
|
|
134
145
|
storageDeleteWarnings: z.array(z.string()),
|
|
135
146
|
}).passthrough();
|
|
147
|
+
const workspaceAccessOutputSchema = z.object({
|
|
148
|
+
workspaceId: z.string(),
|
|
149
|
+
workspace: unknownRecordSchema,
|
|
150
|
+
members: z.array(unknownRecordSchema),
|
|
151
|
+
invites: z.array(unknownRecordSchema),
|
|
152
|
+
}).passthrough();
|
|
153
|
+
const telegramChannelsOutputSchema = z.object({
|
|
154
|
+
channels: z.array(unknownRecordSchema),
|
|
155
|
+
count: z.number().int().nonnegative(),
|
|
156
|
+
}).passthrough();
|
|
157
|
+
const telegramPostsOutputSchema = z.object({
|
|
158
|
+
posts: z.array(unknownRecordSchema),
|
|
159
|
+
...cursorMetaSchema,
|
|
160
|
+
}).passthrough();
|
|
161
|
+
const nmoImportOutputSchema = z.object({
|
|
162
|
+
eventId: z.string(),
|
|
163
|
+
summary: unknownRecordSchema,
|
|
164
|
+
results: z.array(unknownRecordSchema),
|
|
165
|
+
}).passthrough();
|
|
136
166
|
const formFieldSchema = z.object({
|
|
137
167
|
id: z.string().min(1).optional(),
|
|
138
168
|
label: z.string().max(120),
|
|
@@ -156,6 +186,44 @@ const imageSourceSchema = z.object({
|
|
|
156
186
|
}).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
|
|
157
187
|
message: "Provide exactly one image source: path or url.",
|
|
158
188
|
});
|
|
189
|
+
const cardIdSchema = z.string()
|
|
190
|
+
.regex(CARD_ID_PATTERN, "Use 1-80 characters: letters, numbers, underscore, or hyphen.")
|
|
191
|
+
.describe("Stable card id. Omit to generate one; keep it to preserve images and analytics continuity.");
|
|
192
|
+
const cardItemInputSchema = z.object({
|
|
193
|
+
id: cardIdSchema.optional(),
|
|
194
|
+
title: z.string().max(MAX_CARD_TITLE_LENGTH).nullable().optional(),
|
|
195
|
+
text: z.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
|
|
196
|
+
buttonLabel: z.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
|
|
197
|
+
buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
|
|
198
|
+
formId: cardIdSchema.nullable().optional(),
|
|
199
|
+
}).strict();
|
|
200
|
+
const emailSchema = z.string().email().max(320);
|
|
201
|
+
const nmoParticipantInputSchema = z.object({
|
|
202
|
+
email: emailSchema.describe("Participant email."),
|
|
203
|
+
fullName: z.string().min(2).max(200).describe("Participant full name."),
|
|
204
|
+
position: z.string().max(200).nullable().optional().describe("Optional medical position."),
|
|
205
|
+
org: z.string().max(200).nullable().optional().describe("Optional organization/clinic."),
|
|
206
|
+
}).strict();
|
|
207
|
+
const telegramChannelSchema = z.object({
|
|
208
|
+
chatId: z.number().int(),
|
|
209
|
+
title: z.string().max(200).optional(),
|
|
210
|
+
username: z.string().max(120).nullable().optional(),
|
|
211
|
+
}).strict();
|
|
212
|
+
const cardsConfigOutputSchema = z.object({
|
|
213
|
+
eventId: z.string(),
|
|
214
|
+
updated: z.array(z.string()),
|
|
215
|
+
cards: z.object({
|
|
216
|
+
enabled: z.boolean(),
|
|
217
|
+
visibility: z.enum(["always", "hidden"]),
|
|
218
|
+
cardImageRatio: z.enum(CARD_IMAGE_RATIOS),
|
|
219
|
+
items: z.array(unknownRecordSchema),
|
|
220
|
+
}).passthrough(),
|
|
221
|
+
generatedCardIds: z.array(z.string()),
|
|
222
|
+
removedCardIds: z.array(z.string()),
|
|
223
|
+
removedCardsWithImages: z.array(z.string()),
|
|
224
|
+
preservedImageCardIds: z.array(z.string()),
|
|
225
|
+
warnings: z.array(z.string()),
|
|
226
|
+
}).passthrough();
|
|
159
227
|
const workspacePermissionsSchema = z.object({
|
|
160
228
|
createEvents: z.boolean().optional(),
|
|
161
229
|
editSetup: z.boolean().optional(),
|
|
@@ -167,6 +235,17 @@ const workspacePermissionsSchema = z.object({
|
|
|
167
235
|
downloadReports: z.boolean().optional(),
|
|
168
236
|
sendNotifications: z.boolean().optional(),
|
|
169
237
|
}).strict().default({});
|
|
238
|
+
const WORKSPACE_PERMISSION_KEYS = [
|
|
239
|
+
"createEvents",
|
|
240
|
+
"editSetup",
|
|
241
|
+
"deleteEvents",
|
|
242
|
+
"controlLive",
|
|
243
|
+
"moderateChat",
|
|
244
|
+
"startSession",
|
|
245
|
+
"viewAnalytics",
|
|
246
|
+
"downloadReports",
|
|
247
|
+
"sendNotifications",
|
|
248
|
+
];
|
|
170
249
|
const eventIdField = z.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
|
|
171
250
|
const limitField = (max = MAX_LIST_LIMIT, defaultValue = DEFAULT_LIST_LIMIT) => z.number().int().min(1).max(max).default(defaultValue).describe(`Maximum items to return, 1-${max}.`);
|
|
172
251
|
const cursorField = z.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
|
|
@@ -441,6 +520,112 @@ function cardItemsWithImage(eventData, cardId, patch) {
|
|
|
441
520
|
throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
|
|
442
521
|
return next;
|
|
443
522
|
}
|
|
523
|
+
function readImageVariants(value) {
|
|
524
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
525
|
+
return null;
|
|
526
|
+
const data = value;
|
|
527
|
+
if (typeof data.thumbUrl !== "string" || typeof data.cardUrl !== "string" || typeof data.heroUrl !== "string") {
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
thumbUrl: data.thumbUrl,
|
|
532
|
+
cardUrl: data.cardUrl,
|
|
533
|
+
heroUrl: data.heroUrl,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function readCardsConfig(eventData) {
|
|
537
|
+
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
538
|
+
const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
539
|
+
const rawItems = Array.isArray(raw.items) ? raw.items : [];
|
|
540
|
+
const items = rawItems
|
|
541
|
+
.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
542
|
+
.map((item) => {
|
|
543
|
+
const fallbackId = randomUUID();
|
|
544
|
+
return {
|
|
545
|
+
id: typeof item.id === "string" && CARD_ID_PATTERN.test(item.id) ? item.id : fallbackId,
|
|
546
|
+
title: safeString(item.title).trim() || null,
|
|
547
|
+
text: safeString(item.text).trim() || null,
|
|
548
|
+
buttonLabel: safeString(item.buttonLabel).trim() || null,
|
|
549
|
+
buttonUrl: safeString(item.buttonUrl).trim() || null,
|
|
550
|
+
imageUrl: safeString(item.imageUrl).trim() || null,
|
|
551
|
+
imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
|
|
552
|
+
imageVariants: readImageVariants(item.imageVariants),
|
|
553
|
+
formId: safeString(item.formId).trim() || null,
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
const ratio = safeString(raw.cardImageRatio);
|
|
557
|
+
return {
|
|
558
|
+
enabled: raw.enabled === true,
|
|
559
|
+
visibility: raw.visibility === "hidden" ? "hidden" : "always",
|
|
560
|
+
cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
|
|
561
|
+
items,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function normalizeNullableText(value) {
|
|
565
|
+
if (value == null)
|
|
566
|
+
return null;
|
|
567
|
+
return value.trim() || null;
|
|
568
|
+
}
|
|
569
|
+
function normalizeCardButtonUrl(value) {
|
|
570
|
+
const trimmed = normalizeNullableText(value);
|
|
571
|
+
if (!trimmed)
|
|
572
|
+
return null;
|
|
573
|
+
let url;
|
|
574
|
+
try {
|
|
575
|
+
url = new URL(trimmed);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
|
|
579
|
+
}
|
|
580
|
+
if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
|
|
581
|
+
throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
|
|
582
|
+
}
|
|
583
|
+
return trimmed;
|
|
584
|
+
}
|
|
585
|
+
function eventFormIds(eventData) {
|
|
586
|
+
const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
|
|
587
|
+
return new Set(rawForms
|
|
588
|
+
.map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "")
|
|
589
|
+
.filter(Boolean));
|
|
590
|
+
}
|
|
591
|
+
function cardHasImage(item) {
|
|
592
|
+
return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
|
|
593
|
+
}
|
|
594
|
+
function buildCardsItems({ inputs, existing, formIds, }) {
|
|
595
|
+
const existingById = new Map(existing.map((item) => [item.id, item]));
|
|
596
|
+
const seen = new Set();
|
|
597
|
+
const generatedCardIds = [];
|
|
598
|
+
const preservedImageCardIds = [];
|
|
599
|
+
const items = inputs.map((input) => {
|
|
600
|
+
const id = input.id ?? randomUUID();
|
|
601
|
+
if (seen.has(id))
|
|
602
|
+
throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
|
|
603
|
+
seen.add(id);
|
|
604
|
+
if (!CARD_ID_PATTERN.test(id))
|
|
605
|
+
throw new Error(`Invalid card id "${id}". Use 1-80 characters: letters, numbers, underscore, or hyphen.`);
|
|
606
|
+
if (!input.id)
|
|
607
|
+
generatedCardIds.push(id);
|
|
608
|
+
const formId = normalizeNullableText(input.formId);
|
|
609
|
+
if (formId && !formIds.has(formId)) {
|
|
610
|
+
throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
|
|
611
|
+
}
|
|
612
|
+
const previous = existingById.get(id);
|
|
613
|
+
if (previous && cardHasImage(previous))
|
|
614
|
+
preservedImageCardIds.push(id);
|
|
615
|
+
return {
|
|
616
|
+
id,
|
|
617
|
+
title: normalizeNullableText(input.title),
|
|
618
|
+
text: normalizeNullableText(input.text),
|
|
619
|
+
buttonLabel: normalizeNullableText(input.buttonLabel),
|
|
620
|
+
buttonUrl: normalizeCardButtonUrl(input.buttonUrl),
|
|
621
|
+
imageUrl: previous?.imageUrl ?? null,
|
|
622
|
+
imageMasterUrl: previous?.imageMasterUrl ?? null,
|
|
623
|
+
imageVariants: previous?.imageVariants ?? null,
|
|
624
|
+
formId,
|
|
625
|
+
};
|
|
626
|
+
});
|
|
627
|
+
return { items, generatedCardIds, preservedImageCardIds };
|
|
628
|
+
}
|
|
444
629
|
async function deleteStoragePaths(paths) {
|
|
445
630
|
const warnings = [];
|
|
446
631
|
for (const path of [...new Set(paths.filter(Boolean))]) {
|
|
@@ -494,6 +679,32 @@ function normalizeVideo(videoUrl) {
|
|
|
494
679
|
function eventUrl(eventId) {
|
|
495
680
|
return `https://okostream.app/event/${eventId}`;
|
|
496
681
|
}
|
|
682
|
+
function setupUrl(eventId) {
|
|
683
|
+
return `https://okostream.app/setup/${eventId}`;
|
|
684
|
+
}
|
|
685
|
+
function analyticsUrl(eventId) {
|
|
686
|
+
return `https://okostream.app/analytics/${eventId}`;
|
|
687
|
+
}
|
|
688
|
+
function nmoRegisterUrl(eventId) {
|
|
689
|
+
return `${eventUrl(eventId)}/register`;
|
|
690
|
+
}
|
|
691
|
+
function nmoLoginUrl(eventId) {
|
|
692
|
+
return `${eventUrl(eventId)}/login`;
|
|
693
|
+
}
|
|
694
|
+
function eventLinks(eventId, eventData) {
|
|
695
|
+
const nmoMode = eventData?.nmoMode === true;
|
|
696
|
+
return {
|
|
697
|
+
url: eventUrl(eventId),
|
|
698
|
+
setupUrl: setupUrl(eventId),
|
|
699
|
+
analyticsUrl: analyticsUrl(eventId),
|
|
700
|
+
registrationUrl: nmoRegisterUrl(eventId),
|
|
701
|
+
loginUrl: nmoLoginUrl(eventId),
|
|
702
|
+
...(nmoMode ? {
|
|
703
|
+
nmoRegistrationUrl: nmoRegisterUrl(eventId),
|
|
704
|
+
nmoLoginUrl: nmoLoginUrl(eventId),
|
|
705
|
+
} : {}),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
497
708
|
function assertEventReadable(data, uid) {
|
|
498
709
|
const access = Array.isArray(data.accessibleBy) ? data.accessibleBy : [];
|
|
499
710
|
const editors = Array.isArray(data.editors) ? data.editors : [];
|
|
@@ -556,6 +767,193 @@ async function buildWorkspaceAccess(uid, workspaceId) {
|
|
|
556
767
|
}
|
|
557
768
|
return { accessibleBy: [...accessibleBy], editors: [...editors] };
|
|
558
769
|
}
|
|
770
|
+
async function buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId) {
|
|
771
|
+
const access = await buildWorkspaceAccess(uid, targetWorkspaceId);
|
|
772
|
+
const accessibleBy = new Set(access.accessibleBy);
|
|
773
|
+
const editors = new Set(access.editors);
|
|
774
|
+
if (originalOwnerId)
|
|
775
|
+
accessibleBy.add(originalOwnerId);
|
|
776
|
+
return { accessibleBy: [...accessibleBy], editors: [...editors] };
|
|
777
|
+
}
|
|
778
|
+
async function readWorkspaceForOwner(workspaceId, uid) {
|
|
779
|
+
const snap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
780
|
+
if (!snap.exists())
|
|
781
|
+
throw new Error("Workspace not found.");
|
|
782
|
+
const data = snap.data();
|
|
783
|
+
if (data.ownerId !== uid)
|
|
784
|
+
throw new Error("Only the workspace owner can manage this workspace.");
|
|
785
|
+
return data;
|
|
786
|
+
}
|
|
787
|
+
function normalizeWorkspacePermissions(value) {
|
|
788
|
+
const out = {};
|
|
789
|
+
for (const key of WORKSPACE_PERMISSION_KEYS) {
|
|
790
|
+
if (value[key] === true)
|
|
791
|
+
out[key] = true;
|
|
792
|
+
else if (value[key] === false)
|
|
793
|
+
out[key] = false;
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
function randomInviteCode() {
|
|
798
|
+
return Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
799
|
+
}
|
|
800
|
+
function normalizeEmail(value) {
|
|
801
|
+
return value.trim().toLowerCase();
|
|
802
|
+
}
|
|
803
|
+
function normalizeOptionalText(value, max) {
|
|
804
|
+
const text = value?.trim() ?? "";
|
|
805
|
+
return text ? text.slice(0, max) : null;
|
|
806
|
+
}
|
|
807
|
+
function parseCsvLine(line) {
|
|
808
|
+
const cells = [];
|
|
809
|
+
let current = "";
|
|
810
|
+
let quoted = false;
|
|
811
|
+
for (let i = 0; i < line.length; i++) {
|
|
812
|
+
const char = line[i];
|
|
813
|
+
const next = line[i + 1];
|
|
814
|
+
if (quoted && char === "\"" && next === "\"") {
|
|
815
|
+
current += "\"";
|
|
816
|
+
i++;
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (char === "\"") {
|
|
820
|
+
quoted = !quoted;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (!quoted && char === ",") {
|
|
824
|
+
cells.push(current.trim());
|
|
825
|
+
current = "";
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
current += char;
|
|
829
|
+
}
|
|
830
|
+
cells.push(current.trim());
|
|
831
|
+
return cells;
|
|
832
|
+
}
|
|
833
|
+
function parseNmoParticipantsCsv(csv) {
|
|
834
|
+
const lines = csv
|
|
835
|
+
.split(/\r?\n/)
|
|
836
|
+
.map((line) => line.trim())
|
|
837
|
+
.filter(Boolean);
|
|
838
|
+
if (lines.length < 2)
|
|
839
|
+
throw new Error("CSV must include a header row and at least one participant row.");
|
|
840
|
+
const headers = parseCsvLine(lines[0] ?? "").map((header) => header.trim().toLowerCase());
|
|
841
|
+
const alias = {
|
|
842
|
+
email: "email",
|
|
843
|
+
mail: "email",
|
|
844
|
+
"e-mail": "email",
|
|
845
|
+
fullname: "fullName",
|
|
846
|
+
full_name: "fullName",
|
|
847
|
+
name: "fullName",
|
|
848
|
+
fio: "fullName",
|
|
849
|
+
"фио": "fullName",
|
|
850
|
+
"имя": "fullName",
|
|
851
|
+
position: "position",
|
|
852
|
+
"должность": "position",
|
|
853
|
+
org: "org",
|
|
854
|
+
organization: "org",
|
|
855
|
+
organisation: "org",
|
|
856
|
+
clinic: "org",
|
|
857
|
+
"организация": "org",
|
|
858
|
+
};
|
|
859
|
+
return lines.slice(1).map((line, index) => {
|
|
860
|
+
const cells = parseCsvLine(line);
|
|
861
|
+
const row = {};
|
|
862
|
+
headers.forEach((header, cellIndex) => {
|
|
863
|
+
const key = alias[header];
|
|
864
|
+
if (key)
|
|
865
|
+
row[key] = cells[cellIndex] ?? "";
|
|
866
|
+
});
|
|
867
|
+
const parsed = nmoParticipantInputSchema.safeParse(row);
|
|
868
|
+
if (!parsed.success) {
|
|
869
|
+
throw new Error(`Invalid NMO CSV row ${index + 2}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
870
|
+
}
|
|
871
|
+
return parsed.data;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
async function readTelegramChannels(uid, includeWorkspaceChannels) {
|
|
875
|
+
const channelsById = new Map();
|
|
876
|
+
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
877
|
+
const profileChannels = Array.isArray(profileSnap.data()?.telegramChannels) ? profileSnap.data()?.telegramChannels : [];
|
|
878
|
+
for (const raw of profileChannels) {
|
|
879
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
880
|
+
continue;
|
|
881
|
+
const ch = raw;
|
|
882
|
+
if (typeof ch.chatId !== "number")
|
|
883
|
+
continue;
|
|
884
|
+
channelsById.set(ch.chatId, { ...serialize(ch), source: "profile" });
|
|
885
|
+
}
|
|
886
|
+
if (includeWorkspaceChannels) {
|
|
887
|
+
const workspaces = await listVisibleWorkspaces(uid, true);
|
|
888
|
+
for (const workspace of workspaces) {
|
|
889
|
+
const workspaceId = safeString(workspace.id);
|
|
890
|
+
const workspaceName = safeString(workspace.name);
|
|
891
|
+
const channels = Array.isArray(workspace.telegramChannels) ? workspace.telegramChannels : [];
|
|
892
|
+
for (const raw of channels) {
|
|
893
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
894
|
+
continue;
|
|
895
|
+
const ch = raw;
|
|
896
|
+
if (typeof ch.chatId !== "number")
|
|
897
|
+
continue;
|
|
898
|
+
const prev = channelsById.get(ch.chatId) ?? {};
|
|
899
|
+
const visibleInWorkspaces = Array.isArray(prev.visibleInWorkspaces) ? prev.visibleInWorkspaces : [];
|
|
900
|
+
channelsById.set(ch.chatId, {
|
|
901
|
+
...prev,
|
|
902
|
+
...serialize(ch),
|
|
903
|
+
source: prev.source ? "profile+workspace" : "workspace",
|
|
904
|
+
visibleInWorkspaces: [...visibleInWorkspaces, { id: workspaceId, name: workspaceName }],
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return [...channelsById.values()].sort((a, b) => safeString(a.title).localeCompare(safeString(b.title)));
|
|
910
|
+
}
|
|
911
|
+
async function listVisibleWorkspaces(uid, includeShared) {
|
|
912
|
+
const out = [];
|
|
913
|
+
const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
914
|
+
ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
|
|
915
|
+
if (includeShared) {
|
|
916
|
+
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
917
|
+
const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
|
|
918
|
+
for (const workspaceId of sharedIds) {
|
|
919
|
+
if (out.some((row) => row.id === workspaceId))
|
|
920
|
+
continue;
|
|
921
|
+
const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
922
|
+
const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
|
|
923
|
+
if (wsSnap.exists())
|
|
924
|
+
out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
}
|
|
929
|
+
async function syncTelegramChannelToWorkspaces(uid, channel, workspaceIds) {
|
|
930
|
+
const updated = [];
|
|
931
|
+
if (workspaceIds.length === 0)
|
|
932
|
+
return updated;
|
|
933
|
+
const profileRef = doc(firestore, "userProfiles", uid);
|
|
934
|
+
const profileSnap = await getDoc(profileRef);
|
|
935
|
+
const channels = Array.isArray(profileSnap.data()?.telegramChannels) ? [...profileSnap.data().telegramChannels] : [];
|
|
936
|
+
const nextProfileChannels = channels.map((item) => {
|
|
937
|
+
if (item.chatId !== channel.chatId)
|
|
938
|
+
return item;
|
|
939
|
+
const current = Array.isArray(item.workspaceIds) ? item.workspaceIds.filter((id) => typeof id === "string") : [];
|
|
940
|
+
return { ...item, workspaceIds: [...new Set([...current, ...workspaceIds])] };
|
|
941
|
+
});
|
|
942
|
+
await setDoc(profileRef, { telegramChannels: nextProfileChannels }, { merge: true });
|
|
943
|
+
for (const workspaceId of workspaceIds) {
|
|
944
|
+
await readWorkspaceForOwner(workspaceId, uid);
|
|
945
|
+
const wsRef = doc(firestore, "workspaces", workspaceId);
|
|
946
|
+
const wsSnap = await getDoc(wsRef);
|
|
947
|
+
const wsChannels = Array.isArray(wsSnap.data()?.telegramChannels) ? wsSnap.data().telegramChannels : [];
|
|
948
|
+
if (!wsChannels.some((item) => item.chatId === channel.chatId)) {
|
|
949
|
+
await updateDoc(wsRef, {
|
|
950
|
+
telegramChannels: [...wsChannels, { chatId: channel.chatId, title: channel.title ?? "", username: channel.username ?? null }],
|
|
951
|
+
});
|
|
952
|
+
updated.push(workspaceId);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return updated;
|
|
956
|
+
}
|
|
559
957
|
async function readAgg(eventId) {
|
|
560
958
|
const snap = await get(ref(rtdb, `events/${eventId}/agg`));
|
|
561
959
|
const agg = (snap.val() ?? {});
|
|
@@ -832,23 +1230,211 @@ export function registerTools(server) {
|
|
|
832
1230
|
annotations: readOnlyTool,
|
|
833
1231
|
}, async ({ includeShared }) => {
|
|
834
1232
|
const { uid } = await authed(["workspace:read"]);
|
|
835
|
-
const out =
|
|
836
|
-
const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
837
|
-
ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
|
|
838
|
-
if (includeShared) {
|
|
839
|
-
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
840
|
-
const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
|
|
841
|
-
for (const workspaceId of sharedIds) {
|
|
842
|
-
if (out.some((row) => typeof row === "object" && row !== null && row.id === workspaceId))
|
|
843
|
-
continue;
|
|
844
|
-
const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
845
|
-
const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
|
|
846
|
-
if (wsSnap.exists())
|
|
847
|
-
out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
|
|
848
|
-
}
|
|
849
|
-
}
|
|
1233
|
+
const out = await listVisibleWorkspaces(uid, includeShared);
|
|
850
1234
|
return textResult({ workspaces: out, count: out.length });
|
|
851
1235
|
});
|
|
1236
|
+
server.registerTool("oko_create_workspace", {
|
|
1237
|
+
title: "Create workspace",
|
|
1238
|
+
description: toolDescription({
|
|
1239
|
+
summary: "Create a shared OKO workspace owned by the authenticated organizer.",
|
|
1240
|
+
args: ["name: workspace display name."],
|
|
1241
|
+
returns: "workspaceId, name, and created=true.",
|
|
1242
|
+
examples: ["Use when the organizer wants to create a new team/workspace for events."],
|
|
1243
|
+
}),
|
|
1244
|
+
inputSchema: { name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("Workspace name.") },
|
|
1245
|
+
outputSchema: z.object({ workspaceId: z.string(), created: z.boolean() }).passthrough(),
|
|
1246
|
+
annotations: writeTool,
|
|
1247
|
+
}, async ({ name }) => {
|
|
1248
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1249
|
+
const workspaceRef = await addDoc(collection(firestore, "workspaces"), {
|
|
1250
|
+
name: name.trim(),
|
|
1251
|
+
ownerId: uid,
|
|
1252
|
+
personal: false,
|
|
1253
|
+
createdAt: serverTimestamp(),
|
|
1254
|
+
});
|
|
1255
|
+
return textResult({ workspaceId: workspaceRef.id, name: name.trim(), created: true });
|
|
1256
|
+
});
|
|
1257
|
+
server.registerTool("oko_update_workspace", {
|
|
1258
|
+
title: "Update workspace",
|
|
1259
|
+
description: toolDescription({
|
|
1260
|
+
summary: "Rename a workspace owned by the authenticated organizer.",
|
|
1261
|
+
args: ["workspaceId: workspace to update.", "name: new workspace display name."],
|
|
1262
|
+
returns: "workspaceId and updated fields.",
|
|
1263
|
+
examples: ["Use for workspace rename requests."],
|
|
1264
|
+
}),
|
|
1265
|
+
inputSchema: { workspaceId: z.string().min(1), name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("New workspace name.") },
|
|
1266
|
+
outputSchema: updateOutputSchema,
|
|
1267
|
+
annotations: idempotentWriteTool,
|
|
1268
|
+
}, async ({ workspaceId, name }) => {
|
|
1269
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1270
|
+
await readWorkspaceForOwner(workspaceId, uid);
|
|
1271
|
+
await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name.trim() });
|
|
1272
|
+
return textResult({ workspaceId, updated: ["name"] });
|
|
1273
|
+
});
|
|
1274
|
+
server.registerTool("oko_delete_workspace", {
|
|
1275
|
+
title: "Delete workspace",
|
|
1276
|
+
description: toolDescription({
|
|
1277
|
+
summary: "Delete an empty non-personal workspace owned by the authenticated organizer.",
|
|
1278
|
+
args: ["workspaceId: workspace to delete.", "confirm: must be true."],
|
|
1279
|
+
returns: "workspaceId and deleted=true.",
|
|
1280
|
+
examples: ["Use after listing workspace access and confirming the workspace is empty."],
|
|
1281
|
+
notes: ["Personal workspaces cannot be deleted. This tool refuses to delete workspaces that still contain events; move/delete events first."],
|
|
1282
|
+
}),
|
|
1283
|
+
inputSchema: { workspaceId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for workspace deletion.") },
|
|
1284
|
+
outputSchema: z.object({ workspaceId: z.string(), deleted: z.boolean() }).passthrough(),
|
|
1285
|
+
annotations: destructiveTool,
|
|
1286
|
+
}, async ({ workspaceId, confirm }) => {
|
|
1287
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1288
|
+
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1289
|
+
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
1290
|
+
if (workspace.personal === true)
|
|
1291
|
+
throw new Error("Personal workspace cannot be deleted.");
|
|
1292
|
+
const eventsSnap = await getDocs(query(collection(firestore, "events"), where("workspaceId", "==", workspaceId), limit(1)));
|
|
1293
|
+
if (!eventsSnap.empty)
|
|
1294
|
+
throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
|
|
1295
|
+
await deleteDoc(doc(firestore, "workspaces", workspaceId));
|
|
1296
|
+
return textResult({ workspaceId, deleted: true });
|
|
1297
|
+
});
|
|
1298
|
+
server.registerTool("oko_list_workspace_access", {
|
|
1299
|
+
title: "List workspace access",
|
|
1300
|
+
description: toolDescription({
|
|
1301
|
+
summary: "List workspace members/shares and pending invites.",
|
|
1302
|
+
args: ["workspaceId: workspace owned by the authenticated organizer."],
|
|
1303
|
+
returns: "workspace document, members array, invites array.",
|
|
1304
|
+
examples: ["Use before changing permissions, removing a member, or revoking an invite."],
|
|
1305
|
+
}),
|
|
1306
|
+
inputSchema: { workspaceId: z.string().min(1) },
|
|
1307
|
+
outputSchema: workspaceAccessOutputSchema,
|
|
1308
|
+
annotations: readOnlyTool,
|
|
1309
|
+
}, async ({ workspaceId }) => {
|
|
1310
|
+
const { uid } = await authed(["workspace:read"]);
|
|
1311
|
+
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1312
|
+
const [sharesSnap, invitesSnap] = await Promise.all([
|
|
1313
|
+
getDocs(collection(firestore, "workspaces", workspaceId, "shares")),
|
|
1314
|
+
getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId))),
|
|
1315
|
+
]);
|
|
1316
|
+
const members = [];
|
|
1317
|
+
for (const share of sharesSnap.docs) {
|
|
1318
|
+
const profileSnap = await getDoc(doc(firestore, "userProfiles", share.id));
|
|
1319
|
+
members.push({
|
|
1320
|
+
uid: share.id,
|
|
1321
|
+
...serialize(share.data()),
|
|
1322
|
+
profile: profileSnap.exists() ? serialize(profileSnap.data()) : null,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
|
|
1326
|
+
return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites });
|
|
1327
|
+
});
|
|
1328
|
+
server.registerTool("oko_update_workspace_member", {
|
|
1329
|
+
title: "Update workspace member",
|
|
1330
|
+
description: toolDescription({
|
|
1331
|
+
summary: "Create or update a workspace share/member permissions document.",
|
|
1332
|
+
args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
|
|
1333
|
+
returns: "workspaceId, uid, and updated=true.",
|
|
1334
|
+
examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
|
|
1335
|
+
notes: ["Changing a share triggers OKO CF18 fan-out to event access, RTDB workspaceMembers, and chat roles."],
|
|
1336
|
+
}),
|
|
1337
|
+
inputSchema: {
|
|
1338
|
+
workspaceId: z.string().min(1),
|
|
1339
|
+
uid: z.string().min(1).describe("Member Firebase uid."),
|
|
1340
|
+
email: z.string().email().optional().describe("Optional member email cache."),
|
|
1341
|
+
comment: z.string().max(500).default(""),
|
|
1342
|
+
permissions: workspacePermissionsSchema,
|
|
1343
|
+
confirm: z.boolean().optional().describe("Required true for share updates."),
|
|
1344
|
+
},
|
|
1345
|
+
outputSchema: updateOutputSchema,
|
|
1346
|
+
annotations: writeTool,
|
|
1347
|
+
}, async (args) => {
|
|
1348
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1349
|
+
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
1350
|
+
requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
|
|
1351
|
+
await setDoc(doc(firestore, "workspaces", args.workspaceId, "shares", args.uid), {
|
|
1352
|
+
email: args.email ?? "",
|
|
1353
|
+
comment: args.comment.trim(),
|
|
1354
|
+
permissions: normalizeWorkspacePermissions(args.permissions),
|
|
1355
|
+
sharedAt: serverTimestamp(),
|
|
1356
|
+
sharedBy: uid,
|
|
1357
|
+
}, { merge: true });
|
|
1358
|
+
return textResult({ workspaceId: args.workspaceId, uid: args.uid, updated: true });
|
|
1359
|
+
});
|
|
1360
|
+
server.registerTool("oko_remove_workspace_member", {
|
|
1361
|
+
title: "Remove workspace member",
|
|
1362
|
+
description: toolDescription({
|
|
1363
|
+
summary: "Remove a member/share from a workspace.",
|
|
1364
|
+
args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
|
|
1365
|
+
returns: "workspaceId, uid, removed=true.",
|
|
1366
|
+
examples: ["Use after listing workspace access and confirming the exact uid."],
|
|
1367
|
+
notes: ["Removing a share triggers OKO CF18 reverse fan-out."],
|
|
1368
|
+
}),
|
|
1369
|
+
inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
|
|
1370
|
+
outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
|
|
1371
|
+
annotations: destructiveTool,
|
|
1372
|
+
}, async ({ workspaceId, uid: targetUid, confirm }) => {
|
|
1373
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1374
|
+
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1375
|
+
requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
|
|
1376
|
+
await deleteDoc(doc(firestore, "workspaces", workspaceId, "shares", targetUid));
|
|
1377
|
+
return textResult({ workspaceId, uid: targetUid, removed: true });
|
|
1378
|
+
});
|
|
1379
|
+
server.registerTool("oko_list_workspace_invites", {
|
|
1380
|
+
title: "List workspace invites",
|
|
1381
|
+
description: toolDescription({
|
|
1382
|
+
summary: "List workspace invite links for a workspace owned by the organizer.",
|
|
1383
|
+
args: ["workspaceId: workspace owned by the organizer."],
|
|
1384
|
+
returns: "invites array with code, claim status, permissions, and inviteUrl.",
|
|
1385
|
+
examples: ["Use before revoking or sharing an invite code."],
|
|
1386
|
+
}),
|
|
1387
|
+
inputSchema: { workspaceId: z.string().min(1) },
|
|
1388
|
+
outputSchema: z.object({ workspaceId: z.string(), invites: z.array(unknownRecordSchema), count: z.number().int().nonnegative() }).passthrough(),
|
|
1389
|
+
annotations: readOnlyTool,
|
|
1390
|
+
}, async ({ workspaceId }) => {
|
|
1391
|
+
const { uid } = await authed(["workspace:read"]);
|
|
1392
|
+
await readWorkspaceForOwner(workspaceId, uid);
|
|
1393
|
+
const snap = await getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)));
|
|
1394
|
+
const invites = snap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()), inviteUrl: `https://okostream.app/invite/${invite.id}` }));
|
|
1395
|
+
return textResult({ workspaceId, invites, count: invites.length });
|
|
1396
|
+
});
|
|
1397
|
+
server.registerTool("oko_revoke_workspace_invite", {
|
|
1398
|
+
title: "Revoke workspace invite",
|
|
1399
|
+
description: toolDescription({
|
|
1400
|
+
summary: "Delete a pending workspace invite code.",
|
|
1401
|
+
args: ["code: invite code.", "confirm: must be true."],
|
|
1402
|
+
returns: "code and revoked=true.",
|
|
1403
|
+
examples: ["Use after confirming the exact invite code."],
|
|
1404
|
+
}),
|
|
1405
|
+
inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH), confirm: z.boolean().optional().describe("Required true for invite revocation.") },
|
|
1406
|
+
outputSchema: z.object({ code: z.string(), revoked: z.boolean() }).passthrough(),
|
|
1407
|
+
annotations: destructiveTool,
|
|
1408
|
+
}, async ({ code, confirm }) => {
|
|
1409
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1410
|
+
const normalizedCode = code.trim().toUpperCase();
|
|
1411
|
+
const inviteSnap = await getDoc(doc(firestore, "workspaceInvites", normalizedCode));
|
|
1412
|
+
if (!inviteSnap.exists())
|
|
1413
|
+
throw new Error("Workspace invite not found.");
|
|
1414
|
+
const invite = inviteSnap.data();
|
|
1415
|
+
if (invite.createdBy !== uid)
|
|
1416
|
+
throw new Error("Only the invite creator/workspace owner can revoke this invite.");
|
|
1417
|
+
requireConfirm(confirm, "oko_revoke_workspace_invite", { code: normalizedCode, workspaceId: invite.workspaceId, claimed: invite.claimed });
|
|
1418
|
+
await deleteDoc(doc(firestore, "workspaceInvites", normalizedCode));
|
|
1419
|
+
return textResult({ code: normalizedCode, revoked: true });
|
|
1420
|
+
});
|
|
1421
|
+
server.registerTool("oko_claim_workspace_invite", {
|
|
1422
|
+
title: "Claim workspace invite",
|
|
1423
|
+
description: toolDescription({
|
|
1424
|
+
summary: "Accept a workspace invite code for the authenticated organizer.",
|
|
1425
|
+
args: ["code: invite code from /invite/{code}."],
|
|
1426
|
+
returns: "success plus workspace id/name returned by OKO CF19.",
|
|
1427
|
+
examples: ["Use when the organizer wants to join a workspace from an invite code."],
|
|
1428
|
+
}),
|
|
1429
|
+
inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH) },
|
|
1430
|
+
outputSchema: unknownRecordSchema,
|
|
1431
|
+
annotations: writeTool,
|
|
1432
|
+
}, async ({ code }) => {
|
|
1433
|
+
await authed(["workspace:write"]);
|
|
1434
|
+
const claimInvite = httpsCallable(functions, "claimWorkspaceInvite");
|
|
1435
|
+
const result = await claimInvite({ code: code.trim().toUpperCase() });
|
|
1436
|
+
return textResult({ ...serialize(result.data) });
|
|
1437
|
+
});
|
|
852
1438
|
server.registerTool("oko_list_my_events", {
|
|
853
1439
|
title: "List my events",
|
|
854
1440
|
description: toolDescription({
|
|
@@ -899,7 +1485,7 @@ export function registerTools(server) {
|
|
|
899
1485
|
}, async ({ eventId }) => {
|
|
900
1486
|
const { uid } = await authed(["events:read"]);
|
|
901
1487
|
const data = await readEvent(eventId, uid, "read");
|
|
902
|
-
return textResult({ id: eventId, ...serialize(data),
|
|
1488
|
+
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId, data) });
|
|
903
1489
|
});
|
|
904
1490
|
server.registerTool("oko_create_webinar", {
|
|
905
1491
|
title: "Create webinar",
|
|
@@ -955,7 +1541,7 @@ export function registerTools(server) {
|
|
|
955
1541
|
ownerId: uid,
|
|
956
1542
|
workspaceId,
|
|
957
1543
|
});
|
|
958
|
-
return textResult({ id: docRef.id,
|
|
1544
|
+
return textResult({ id: docRef.id, ...eventLinks(docRef.id), workspaceId });
|
|
959
1545
|
});
|
|
960
1546
|
server.registerTool("oko_update_event", {
|
|
961
1547
|
title: "Update event metadata",
|
|
@@ -1110,6 +1696,92 @@ export function registerTools(server) {
|
|
|
1110
1696
|
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
1111
1697
|
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
1112
1698
|
});
|
|
1699
|
+
server.registerTool("oko_configure_cards", {
|
|
1700
|
+
title: "Configure event cards",
|
|
1701
|
+
description: toolDescription({
|
|
1702
|
+
summary: "Configure the Cards setup block and replace its card list. Images are managed separately through oko_upload_image and are preserved by matching card id.",
|
|
1703
|
+
args: [
|
|
1704
|
+
"eventId: event to edit.",
|
|
1705
|
+
"enabled: optional Cards block enabled flag.",
|
|
1706
|
+
"visibility: optional default visibility, always or hidden.",
|
|
1707
|
+
"cardImageRatio: optional image ratio, one of auto, 1:1, 4:3, 3:2, 16:9, 2:3, 3:4.",
|
|
1708
|
+
"items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId.",
|
|
1709
|
+
],
|
|
1710
|
+
returns: "event id, updated Firestore fields, normalized cards config, generated card ids, preserved image ids, removed card ids, and warnings.",
|
|
1711
|
+
examples: [
|
|
1712
|
+
"Use after oko_create_webinar to create cards before uploading per-card images.",
|
|
1713
|
+
"Call oko_get_event first and pass the full desired items array when editing existing cards.",
|
|
1714
|
+
"Use formId instead of buttonUrl when a card button should open a lead form.",
|
|
1715
|
+
],
|
|
1716
|
+
notes: [
|
|
1717
|
+
"This tool replaces blocks.cards.items when items is provided; omitted text fields become null.",
|
|
1718
|
+
"Existing card images are preserved only when the same card id remains in the replacement list.",
|
|
1719
|
+
"buttonUrl allows http(s), mailto, or tel URLs; javascript/data URLs are rejected.",
|
|
1720
|
+
"If a removed card had an image, Storage cleanup is not automatic; this matches the web Setup behavior.",
|
|
1721
|
+
],
|
|
1722
|
+
}),
|
|
1723
|
+
inputSchema: {
|
|
1724
|
+
eventId: eventIdField,
|
|
1725
|
+
enabled: z.boolean().optional().describe("Whether the Cards block is enabled in setup."),
|
|
1726
|
+
visibility: z.enum(["always", "hidden"]).optional().describe("Default visibility when no live override exists."),
|
|
1727
|
+
cardImageRatio: z.enum(CARD_IMAGE_RATIOS).optional().describe("Image aspect ratio for all cards."),
|
|
1728
|
+
items: z.array(cardItemInputSchema).max(MAX_CARD_ITEMS).optional().describe("Full replacement list, max 6 cards. Omit to update only block settings."),
|
|
1729
|
+
},
|
|
1730
|
+
outputSchema: cardsConfigOutputSchema,
|
|
1731
|
+
annotations: writeTool,
|
|
1732
|
+
}, async (args) => {
|
|
1733
|
+
const { uid } = await authed(["setup:write"]);
|
|
1734
|
+
const event = await readEvent(args.eventId, uid, "edit");
|
|
1735
|
+
const currentCards = readCardsConfig(event);
|
|
1736
|
+
const patch = {};
|
|
1737
|
+
const finalCards = {
|
|
1738
|
+
enabled: args.enabled ?? currentCards.enabled,
|
|
1739
|
+
visibility: args.visibility ?? currentCards.visibility,
|
|
1740
|
+
cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
|
|
1741
|
+
items: currentCards.items,
|
|
1742
|
+
};
|
|
1743
|
+
const generatedCardIds = [];
|
|
1744
|
+
const preservedImageCardIds = [];
|
|
1745
|
+
let removedCardIds = [];
|
|
1746
|
+
let removedCardsWithImages = [];
|
|
1747
|
+
if (args.enabled !== undefined)
|
|
1748
|
+
patch["blocks.cards.enabled"] = args.enabled;
|
|
1749
|
+
if (args.visibility !== undefined)
|
|
1750
|
+
patch["blocks.cards.visibility"] = args.visibility;
|
|
1751
|
+
if (args.cardImageRatio !== undefined)
|
|
1752
|
+
patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
|
|
1753
|
+
if (args.items !== undefined) {
|
|
1754
|
+
const built = buildCardsItems({
|
|
1755
|
+
inputs: args.items,
|
|
1756
|
+
existing: currentCards.items,
|
|
1757
|
+
formIds: eventFormIds(event),
|
|
1758
|
+
});
|
|
1759
|
+
finalCards.items = built.items;
|
|
1760
|
+
generatedCardIds.push(...built.generatedCardIds);
|
|
1761
|
+
preservedImageCardIds.push(...built.preservedImageCardIds);
|
|
1762
|
+
const nextIds = new Set(built.items.map((item) => item.id));
|
|
1763
|
+
const removed = currentCards.items.filter((item) => !nextIds.has(item.id));
|
|
1764
|
+
removedCardIds = removed.map((item) => item.id);
|
|
1765
|
+
removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
|
|
1766
|
+
patch["blocks.cards.items"] = built.items;
|
|
1767
|
+
}
|
|
1768
|
+
if (Object.keys(patch).length > 0) {
|
|
1769
|
+
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
1770
|
+
}
|
|
1771
|
+
const warnings = removedCardsWithImages.length > 0
|
|
1772
|
+
? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
|
|
1773
|
+
: [];
|
|
1774
|
+
return textResult({
|
|
1775
|
+
eventId: args.eventId,
|
|
1776
|
+
updated: Object.keys(patch),
|
|
1777
|
+
cards: finalCards,
|
|
1778
|
+
generatedCardIds,
|
|
1779
|
+
removedCardIds,
|
|
1780
|
+
removedCardsWithImages,
|
|
1781
|
+
preservedImageCardIds,
|
|
1782
|
+
warnings,
|
|
1783
|
+
});
|
|
1784
|
+
});
|
|
1113
1785
|
server.registerTool("oko_upload_image", {
|
|
1114
1786
|
title: "Upload event image",
|
|
1115
1787
|
description: toolDescription({
|
|
@@ -1286,6 +1958,71 @@ export function registerTools(server) {
|
|
|
1286
1958
|
await deleteDoc(doc(firestore, "events", eventId));
|
|
1287
1959
|
return textResult({ deleted: eventId });
|
|
1288
1960
|
});
|
|
1961
|
+
server.registerTool("oko_duplicate_event", {
|
|
1962
|
+
title: "Duplicate event",
|
|
1963
|
+
description: toolDescription({
|
|
1964
|
+
summary: "Duplicate an event setup into the same or another workspace without analytics/session history.",
|
|
1965
|
+
args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
|
|
1966
|
+
returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
|
|
1967
|
+
examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
|
|
1968
|
+
notes: ["Copies title, video, meta, blocks, forms, telegram config, NMO settings; does not copy analytics sessions/leads/participants."],
|
|
1969
|
+
}),
|
|
1970
|
+
inputSchema: {
|
|
1971
|
+
eventId: eventIdField,
|
|
1972
|
+
targetWorkspaceId: z.string().min(1).optional().describe("Target workspace id; omit to use the source workspace or personal workspace."),
|
|
1973
|
+
},
|
|
1974
|
+
outputSchema: createWebinarOutputSchema,
|
|
1975
|
+
annotations: writeTool,
|
|
1976
|
+
}, async ({ eventId, targetWorkspaceId }) => {
|
|
1977
|
+
const { uid } = await authed(["events:write"]);
|
|
1978
|
+
const source = await readEvent(eventId, uid, "read");
|
|
1979
|
+
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
1980
|
+
const access = await buildWorkspaceAccess(uid, workspaceId);
|
|
1981
|
+
const newRef = await addDoc(collection(firestore, "events"), {
|
|
1982
|
+
ownerId: uid,
|
|
1983
|
+
workspaceId,
|
|
1984
|
+
accessibleBy: access.accessibleBy,
|
|
1985
|
+
editors: access.editors,
|
|
1986
|
+
createdAt: serverTimestamp(),
|
|
1987
|
+
title: safeString(source.title, "Untitled event"),
|
|
1988
|
+
pinned: false,
|
|
1989
|
+
video: source.video ?? { originalUrl: null, embedUrl: null },
|
|
1990
|
+
meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
|
|
1991
|
+
blocks: source.blocks ?? {},
|
|
1992
|
+
...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
|
|
1993
|
+
...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
|
|
1994
|
+
...(source.nmoMode === true ? { nmoMode: true } : {}),
|
|
1995
|
+
...(source.nmoCriteria && typeof source.nmoCriteria === "object" ? { nmoCriteria: source.nmoCriteria } : {}),
|
|
1996
|
+
...(source.nmoExtraFields && typeof source.nmoExtraFields === "object" ? { nmoExtraFields: source.nmoExtraFields } : {}),
|
|
1997
|
+
});
|
|
1998
|
+
await update(ref(rtdb, `events/${newRef.id}`), { ownerId: uid, workspaceId });
|
|
1999
|
+
return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
|
|
2000
|
+
});
|
|
2001
|
+
server.registerTool("oko_move_event", {
|
|
2002
|
+
title: "Move event",
|
|
2003
|
+
description: toolDescription({
|
|
2004
|
+
summary: "Move an event to another workspace and recompute accessibleBy/editors.",
|
|
2005
|
+
args: ["eventId: event to move.", "targetWorkspaceId: target workspace id."],
|
|
2006
|
+
returns: "eventId, targetWorkspaceId, updated Firestore fields, and RTDB mirror status.",
|
|
2007
|
+
examples: ["Use when an event belongs in another workspace. Call oko_list_workspaces first to choose the target."],
|
|
2008
|
+
notes: ["Reads target workspace shares from Firestore with permissions; does not use RTDB workspaceMembers."],
|
|
2009
|
+
}),
|
|
2010
|
+
inputSchema: { eventId: eventIdField, targetWorkspaceId: z.string().min(1).describe("Target workspace id.") },
|
|
2011
|
+
outputSchema: updateOutputSchema,
|
|
2012
|
+
annotations: writeTool,
|
|
2013
|
+
}, async ({ eventId, targetWorkspaceId }) => {
|
|
2014
|
+
const { uid } = await authed(["events:write", "workspace:write"]);
|
|
2015
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
2016
|
+
const originalOwnerId = safeString(event.ownerId) || null;
|
|
2017
|
+
const access = await buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId);
|
|
2018
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
2019
|
+
workspaceId: targetWorkspaceId,
|
|
2020
|
+
accessibleBy: access.accessibleBy,
|
|
2021
|
+
editors: access.editors,
|
|
2022
|
+
});
|
|
2023
|
+
await set(ref(rtdb, `events/${eventId}/workspaceId`), targetWorkspaceId);
|
|
2024
|
+
return textResult({ eventId, targetWorkspaceId, updated: ["workspaceId", "accessibleBy", "editors"], rtdbMirrorUpdated: true });
|
|
2025
|
+
});
|
|
1289
2026
|
server.registerTool("oko_get_live_state", {
|
|
1290
2027
|
title: "Get live state",
|
|
1291
2028
|
description: toolDescription({
|
|
@@ -1629,6 +2366,103 @@ export function registerTools(server) {
|
|
|
1629
2366
|
]);
|
|
1630
2367
|
return textResult({ eventId, sessionId, ended: true });
|
|
1631
2368
|
});
|
|
2369
|
+
server.registerTool("oko_fix_orphan_session", {
|
|
2370
|
+
title: "Fix orphan analytics session",
|
|
2371
|
+
description: toolDescription({
|
|
2372
|
+
summary: "Clear stuck active-session pointers when an analytics session cannot be ended normally.",
|
|
2373
|
+
args: [
|
|
2374
|
+
"eventId: event with stuck session pointers.",
|
|
2375
|
+
"deleteRtdbSessionData: optional, also delete events/{eventId}/sessions/{sessionId}; default false.",
|
|
2376
|
+
"confirm: must be true.",
|
|
2377
|
+
],
|
|
2378
|
+
returns: "eventId, cleared sessionId if found, and cleaned paths.",
|
|
2379
|
+
examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
|
|
2380
|
+
notes: ["This does not stop the video stream; OKO Start/End controls analytics sessions only."],
|
|
2381
|
+
}),
|
|
2382
|
+
inputSchema: {
|
|
2383
|
+
eventId: eventIdField,
|
|
2384
|
+
deleteRtdbSessionData: z.boolean().default(false).describe("Also delete RTDB session data for the stuck session."),
|
|
2385
|
+
confirm: z.boolean().optional().describe("Required true for clearing session pointers."),
|
|
2386
|
+
},
|
|
2387
|
+
outputSchema: basicEventOutputSchema,
|
|
2388
|
+
annotations: destructiveTool,
|
|
2389
|
+
}, async ({ eventId, deleteRtdbSessionData, confirm }) => {
|
|
2390
|
+
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2391
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
2392
|
+
const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
|
|
2393
|
+
const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
|
|
2394
|
+
requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
|
|
2395
|
+
const cleanup = [
|
|
2396
|
+
updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
|
|
2397
|
+
remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
2398
|
+
];
|
|
2399
|
+
if (sessionId)
|
|
2400
|
+
cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
|
|
2401
|
+
if (sessionId && deleteRtdbSessionData)
|
|
2402
|
+
cleanup.push(remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)));
|
|
2403
|
+
const results = await Promise.allSettled(cleanup);
|
|
2404
|
+
const warnings = results
|
|
2405
|
+
.filter((result) => result.status === "rejected")
|
|
2406
|
+
.map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
2407
|
+
return textResult({
|
|
2408
|
+
eventId,
|
|
2409
|
+
sessionId: sessionId || null,
|
|
2410
|
+
fixed: warnings.length === 0,
|
|
2411
|
+
cleared: ["events.activeAnalyticsSessionId", "ctrl/activeSessionId", ...(sessionId ? [`activeSessions/${sessionId}`] : []), ...(sessionId && deleteRtdbSessionData ? [`events/${eventId}/sessions/${sessionId}`] : [])],
|
|
2412
|
+
warnings,
|
|
2413
|
+
});
|
|
2414
|
+
});
|
|
2415
|
+
server.registerTool("oko_delete_session", {
|
|
2416
|
+
title: "Delete analytics session",
|
|
2417
|
+
description: toolDescription({
|
|
2418
|
+
summary: "Delete an analytics session document and optionally its RTDB session data.",
|
|
2419
|
+
args: [
|
|
2420
|
+
"eventId: event id.",
|
|
2421
|
+
"sessionId: analytics session id.",
|
|
2422
|
+
"deleteRtdbData: best-effort delete of events/{eventId}/sessions/{sessionId}; default false.",
|
|
2423
|
+
"confirm: must be true.",
|
|
2424
|
+
],
|
|
2425
|
+
returns: "eventId, sessionId, deleted=true, and cleaned paths.",
|
|
2426
|
+
examples: ["Use only after confirming the exact session id with the organizer."],
|
|
2427
|
+
notes: ["Destructive. If the session is active, active pointers are cleared first. RTDB session data cleanup is best-effort because rules may deny deleting historical session snapshots."],
|
|
2428
|
+
}),
|
|
2429
|
+
inputSchema: {
|
|
2430
|
+
eventId: eventIdField,
|
|
2431
|
+
sessionId: z.string().min(1).describe("analyticsSessions document id."),
|
|
2432
|
+
deleteRtdbData: z.boolean().default(false).describe("Best-effort delete RTDB session data for this session."),
|
|
2433
|
+
confirm: z.boolean().optional().describe("Required true for session deletion."),
|
|
2434
|
+
},
|
|
2435
|
+
outputSchema: basicEventOutputSchema,
|
|
2436
|
+
annotations: destructiveTool,
|
|
2437
|
+
}, async ({ eventId, sessionId, deleteRtdbData, confirm }) => {
|
|
2438
|
+
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2439
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
2440
|
+
requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
|
|
2441
|
+
const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
|
|
2442
|
+
const cleanup = [];
|
|
2443
|
+
if (isActive) {
|
|
2444
|
+
cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
|
|
2445
|
+
cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
|
|
2446
|
+
cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
|
|
2447
|
+
}
|
|
2448
|
+
cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
|
|
2449
|
+
const results = await Promise.allSettled(cleanup);
|
|
2450
|
+
const warnings = results
|
|
2451
|
+
.filter((result) => result.status === "rejected")
|
|
2452
|
+
.map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
2453
|
+
if (warnings.length > 0)
|
|
2454
|
+
throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
|
|
2455
|
+
const rtdbCleanupWarnings = [];
|
|
2456
|
+
if (deleteRtdbData) {
|
|
2457
|
+
try {
|
|
2458
|
+
await remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`));
|
|
2459
|
+
}
|
|
2460
|
+
catch (err) {
|
|
2461
|
+
rtdbCleanupWarnings.push(err instanceof Error ? err.message : String(err));
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
return textResult({ eventId, sessionId, deleted: true, wasActive: isActive, deleteRtdbData, rtdbCleanupWarnings });
|
|
2465
|
+
});
|
|
1632
2466
|
server.registerTool("oko_get_analytics", {
|
|
1633
2467
|
title: "Get analytics sessions",
|
|
1634
2468
|
description: toolDescription({
|
|
@@ -1783,6 +2617,42 @@ export function registerTools(server) {
|
|
|
1783
2617
|
await update(ref(chatRtdb), deletes);
|
|
1784
2618
|
return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
|
|
1785
2619
|
});
|
|
2620
|
+
server.registerTool("oko_unmute_user", {
|
|
2621
|
+
title: "Unmute chat user",
|
|
2622
|
+
description: toolDescription({
|
|
2623
|
+
summary: "Remove a chat mute for a user.",
|
|
2624
|
+
args: ["eventId: target event.", "uid: Firebase uid to unmute.", "confirm: must be true."],
|
|
2625
|
+
returns: "eventId, targetUid, and unmuted=true.",
|
|
2626
|
+
examples: ["Use when a user was muted by mistake or the organizer wants to restore chat access."],
|
|
2627
|
+
}),
|
|
2628
|
+
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unmute."), confirm: z.boolean().optional().describe("Required true for unmute.") },
|
|
2629
|
+
outputSchema: basicEventOutputSchema,
|
|
2630
|
+
annotations: idempotentWriteTool,
|
|
2631
|
+
}, async ({ eventId, uid: targetUid, confirm }) => {
|
|
2632
|
+
const actor = await authed(["chat:moderate"]);
|
|
2633
|
+
await readEvent(eventId, actor.uid, "edit");
|
|
2634
|
+
requireConfirm(confirm, "oko_unmute_user", { eventId, targetUid });
|
|
2635
|
+
await remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`));
|
|
2636
|
+
return textResult({ eventId, targetUid, unmuted: true });
|
|
2637
|
+
});
|
|
2638
|
+
server.registerTool("oko_unban_user", {
|
|
2639
|
+
title: "Unban chat user",
|
|
2640
|
+
description: toolDescription({
|
|
2641
|
+
summary: "Remove a chat ban for a user.",
|
|
2642
|
+
args: ["eventId: target event.", "uid: Firebase uid to unban.", "confirm: must be true."],
|
|
2643
|
+
returns: "eventId, targetUid, and unbanned=true.",
|
|
2644
|
+
examples: ["Use when a user was banned by mistake or the organizer wants to restore event chat access."],
|
|
2645
|
+
}),
|
|
2646
|
+
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unban."), confirm: z.boolean().optional().describe("Required true for unban.") },
|
|
2647
|
+
outputSchema: basicEventOutputSchema,
|
|
2648
|
+
annotations: idempotentWriteTool,
|
|
2649
|
+
}, async ({ eventId, uid: targetUid, confirm }) => {
|
|
2650
|
+
const actor = await authed(["chat:moderate"]);
|
|
2651
|
+
await readEvent(eventId, actor.uid, "edit");
|
|
2652
|
+
requireConfirm(confirm, "oko_unban_user", { eventId, targetUid });
|
|
2653
|
+
await remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`));
|
|
2654
|
+
return textResult({ eventId, targetUid, unbanned: true });
|
|
2655
|
+
});
|
|
1786
2656
|
server.registerTool("oko_set_chat_settings", {
|
|
1787
2657
|
title: "Set chat settings",
|
|
1788
2658
|
description: toolDescription({
|
|
@@ -1888,6 +2758,116 @@ export function registerTools(server) {
|
|
|
1888
2758
|
}
|
|
1889
2759
|
return textResult({ participants: rows, ...meta });
|
|
1890
2760
|
});
|
|
2761
|
+
server.registerTool("oko_add_nmo_participant", {
|
|
2762
|
+
title: "Add NMO participant",
|
|
2763
|
+
description: toolDescription({
|
|
2764
|
+
summary: "Add or update one NMO participant for an event through OKO organizer import.",
|
|
2765
|
+
args: ["eventId: NMO event.", "email/fullName/position/org: participant fields."],
|
|
2766
|
+
returns: "eventId, summary, and per-row import result.",
|
|
2767
|
+
examples: ["Use when the organizer dictates one doctor/participant to add before the webinar."],
|
|
2768
|
+
notes: ["This does not return a participant login token; use registration/login URLs from oko_get_event for attendee access."],
|
|
2769
|
+
}),
|
|
2770
|
+
inputSchema: { eventId: eventIdField, participant: nmoParticipantInputSchema },
|
|
2771
|
+
outputSchema: nmoImportOutputSchema,
|
|
2772
|
+
annotations: writeTool,
|
|
2773
|
+
}, async ({ eventId, participant }) => {
|
|
2774
|
+
const { uid } = await authed(["nmo:write"]);
|
|
2775
|
+
await readEvent(eventId, uid, "edit");
|
|
2776
|
+
const importFn = httpsCallable(functions, "importNmoParticipants");
|
|
2777
|
+
const result = await importFn({
|
|
2778
|
+
eventId,
|
|
2779
|
+
participants: [{
|
|
2780
|
+
email: normalizeEmail(participant.email),
|
|
2781
|
+
fullName: participant.fullName.trim(),
|
|
2782
|
+
position: normalizeOptionalText(participant.position, 200),
|
|
2783
|
+
org: normalizeOptionalText(participant.org, 200),
|
|
2784
|
+
}],
|
|
2785
|
+
});
|
|
2786
|
+
const data = serialize(result.data);
|
|
2787
|
+
const results = Array.isArray(data.results) ? data.results : [];
|
|
2788
|
+
const first = results[0];
|
|
2789
|
+
if (!first || first.ok !== true) {
|
|
2790
|
+
throw new Error(`NMO participant import failed: ${safeString(first?.error, "unknown error")}`);
|
|
2791
|
+
}
|
|
2792
|
+
return textResult({ eventId, ...data });
|
|
2793
|
+
});
|
|
2794
|
+
server.registerTool("oko_import_nmo_participants", {
|
|
2795
|
+
title: "Import NMO participants",
|
|
2796
|
+
description: toolDescription({
|
|
2797
|
+
summary: "Bulk import NMO participants from structured rows or CSV text.",
|
|
2798
|
+
args: [
|
|
2799
|
+
"eventId: NMO event.",
|
|
2800
|
+
"participants: optional array of rows { email, fullName, position?, org? }.",
|
|
2801
|
+
"csv: optional CSV text with headers email, fullName, position, org.",
|
|
2802
|
+
],
|
|
2803
|
+
returns: "eventId, summary, and per-row results with ok/error.",
|
|
2804
|
+
examples: ["Use csv when the organizer pastes a participant list from a spreadsheet."],
|
|
2805
|
+
notes: ["Provide exactly one of participants or csv. Max 200 rows. The callable returns partial success for bulk imports."],
|
|
2806
|
+
}),
|
|
2807
|
+
inputSchema: {
|
|
2808
|
+
eventId: eventIdField,
|
|
2809
|
+
participants: z.array(nmoParticipantInputSchema).max(MAX_NMO_IMPORT_PARTICIPANTS).optional(),
|
|
2810
|
+
csv: z.string().max(80_000).optional().describe("CSV with headers email, fullName, position, org."),
|
|
2811
|
+
},
|
|
2812
|
+
outputSchema: nmoImportOutputSchema,
|
|
2813
|
+
annotations: writeTool,
|
|
2814
|
+
}, async ({ eventId, participants, csv }) => {
|
|
2815
|
+
const { uid } = await authed(["nmo:write"]);
|
|
2816
|
+
await readEvent(eventId, uid, "edit");
|
|
2817
|
+
if (Boolean(participants) === Boolean(csv))
|
|
2818
|
+
throw new Error("Provide exactly one of participants or csv.");
|
|
2819
|
+
const rows = participants ?? parseNmoParticipantsCsv(csv ?? "");
|
|
2820
|
+
if (rows.length === 0 || rows.length > MAX_NMO_IMPORT_PARTICIPANTS)
|
|
2821
|
+
throw new Error(`Import supports 1-${MAX_NMO_IMPORT_PARTICIPANTS} participants.`);
|
|
2822
|
+
const importFn = httpsCallable(functions, "importNmoParticipants");
|
|
2823
|
+
const result = await importFn({
|
|
2824
|
+
eventId,
|
|
2825
|
+
participants: rows.map((row) => ({
|
|
2826
|
+
email: normalizeEmail(row.email),
|
|
2827
|
+
fullName: row.fullName.trim(),
|
|
2828
|
+
position: normalizeOptionalText(row.position, 200),
|
|
2829
|
+
org: normalizeOptionalText(row.org, 200),
|
|
2830
|
+
})),
|
|
2831
|
+
});
|
|
2832
|
+
return textResult({ eventId, ...serialize(result.data) });
|
|
2833
|
+
});
|
|
2834
|
+
server.registerTool("oko_remove_nmo_participant", {
|
|
2835
|
+
title: "Remove NMO participant",
|
|
2836
|
+
description: toolDescription({
|
|
2837
|
+
summary: "Remove an NMO participant and immediately revoke event access.",
|
|
2838
|
+
args: ["eventId: NMO event.", "uid or email: participant to remove.", "confirm: must be true."],
|
|
2839
|
+
returns: "eventId, participant uid/email, and removed=true.",
|
|
2840
|
+
examples: ["Use after listing participants and confirming the exact participant."],
|
|
2841
|
+
notes: ["Order is intentional: arrayRemove from events.participantUids first, then delete participant doc."],
|
|
2842
|
+
}),
|
|
2843
|
+
inputSchema: {
|
|
2844
|
+
eventId: eventIdField,
|
|
2845
|
+
uid: z.string().min(1).optional().describe("Participant Firebase uid."),
|
|
2846
|
+
email: emailSchema.optional().describe("Participant email; used to resolve uid if uid is omitted."),
|
|
2847
|
+
confirm: z.boolean().optional().describe("Required true for participant removal."),
|
|
2848
|
+
},
|
|
2849
|
+
outputSchema: z.object({ eventId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
|
|
2850
|
+
annotations: destructiveTool,
|
|
2851
|
+
}, async ({ eventId, uid: participantUid, email, confirm }) => {
|
|
2852
|
+
const { uid } = await authed(["nmo:write"]);
|
|
2853
|
+
await readEvent(eventId, uid, "edit");
|
|
2854
|
+
let targetUid = participantUid;
|
|
2855
|
+
const normalizedEmail = email ? normalizeEmail(email) : null;
|
|
2856
|
+
if (!targetUid && normalizedEmail) {
|
|
2857
|
+
const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), where("email", "==", normalizedEmail), limit(2)));
|
|
2858
|
+
if (snap.empty)
|
|
2859
|
+
throw new Error(`NMO participant with email ${normalizedEmail} not found.`);
|
|
2860
|
+
if (snap.docs.length > 1)
|
|
2861
|
+
throw new Error(`Multiple NMO participants found for email ${normalizedEmail}; use uid.`);
|
|
2862
|
+
targetUid = snap.docs[0].id;
|
|
2863
|
+
}
|
|
2864
|
+
if (!targetUid)
|
|
2865
|
+
throw new Error("Provide uid or email.");
|
|
2866
|
+
requireConfirm(confirm, "oko_remove_nmo_participant", { eventId, uid: targetUid, email: normalizedEmail });
|
|
2867
|
+
await updateDoc(doc(firestore, "events", eventId), { participantUids: arrayRemove(targetUid) });
|
|
2868
|
+
await deleteDoc(doc(firestore, "eventParticipants", eventId, "items", targetUid));
|
|
2869
|
+
return textResult({ eventId, uid: targetUid, email: normalizedEmail, removed: true });
|
|
2870
|
+
});
|
|
1891
2871
|
server.registerTool("oko_list_leads", {
|
|
1892
2872
|
title: "List form leads",
|
|
1893
2873
|
description: toolDescription({
|
|
@@ -1930,6 +2910,116 @@ export function registerTools(server) {
|
|
|
1930
2910
|
}
|
|
1931
2911
|
return textResult({ leads, ...meta });
|
|
1932
2912
|
});
|
|
2913
|
+
server.registerTool("oko_list_telegram_channels", {
|
|
2914
|
+
title: "List Telegram channels",
|
|
2915
|
+
description: toolDescription({
|
|
2916
|
+
summary: "List Telegram channels available to the authenticated organizer.",
|
|
2917
|
+
args: ["includeWorkspaceChannels: include channels exposed through visible workspaces, default true."],
|
|
2918
|
+
returns: "channels with chatId, title, username, source, and visible workspace metadata.",
|
|
2919
|
+
examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
|
|
2920
|
+
}),
|
|
2921
|
+
inputSchema: { includeWorkspaceChannels: z.boolean().default(true).describe("Include workspace-level channels visible to this organizer.") },
|
|
2922
|
+
outputSchema: telegramChannelsOutputSchema,
|
|
2923
|
+
annotations: readOnlyTool,
|
|
2924
|
+
}, async ({ includeWorkspaceChannels }) => {
|
|
2925
|
+
const { uid } = await authed(["telegram:write"]);
|
|
2926
|
+
const channels = await readTelegramChannels(uid, includeWorkspaceChannels);
|
|
2927
|
+
return textResult({ channels, count: channels.length });
|
|
2928
|
+
});
|
|
2929
|
+
server.registerTool("oko_claim_telegram_channel", {
|
|
2930
|
+
title: "Claim Telegram channel",
|
|
2931
|
+
description: toolDescription({
|
|
2932
|
+
summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
|
|
2933
|
+
args: [
|
|
2934
|
+
"claimCode: code posted by the bot after it was added to a channel.",
|
|
2935
|
+
"workspaceIds: optional workspaces to expose this channel to after claim.",
|
|
2936
|
+
],
|
|
2937
|
+
returns: "claimed channel and workspace ids updated.",
|
|
2938
|
+
examples: ["Add @OKOStreamBot to a Telegram channel, copy the claim code, then call this tool."],
|
|
2939
|
+
notes: ["Workspace sync requires the authenticated organizer to own each workspace."],
|
|
2940
|
+
}),
|
|
2941
|
+
inputSchema: {
|
|
2942
|
+
claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
|
|
2943
|
+
workspaceIds: z.array(z.string().min(1)).max(20).default([]).describe("Optional workspace ids to attach the channel to."),
|
|
2944
|
+
},
|
|
2945
|
+
outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
|
|
2946
|
+
annotations: writeTool,
|
|
2947
|
+
}, async ({ claimCode, workspaceIds }) => {
|
|
2948
|
+
const { uid } = await authed(["telegram:write"]);
|
|
2949
|
+
const claimChannel = httpsCallable(functions, "claimTelegramChannel");
|
|
2950
|
+
const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
|
|
2951
|
+
const data = serialize(result.data);
|
|
2952
|
+
const channelRaw = data.channel && typeof data.channel === "object" ? data.channel : {};
|
|
2953
|
+
const parsed = telegramChannelSchema.safeParse(channelRaw);
|
|
2954
|
+
if (!parsed.success)
|
|
2955
|
+
throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
|
|
2956
|
+
const workspaceIdsUpdated = await syncTelegramChannelToWorkspaces(uid, parsed.data, workspaceIds);
|
|
2957
|
+
return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated });
|
|
2958
|
+
});
|
|
2959
|
+
server.registerTool("oko_list_telegram_posts", {
|
|
2960
|
+
title: "List Telegram posts",
|
|
2961
|
+
description: toolDescription({
|
|
2962
|
+
summary: "List Telegram notification posts for an event.",
|
|
2963
|
+
args: [
|
|
2964
|
+
"eventId: event whose Telegram posts to list.",
|
|
2965
|
+
"status: optional client-side status filter.",
|
|
2966
|
+
"limit: max posts, default 30, max 100.",
|
|
2967
|
+
"cursor: createdAt cursor returned by previous page.",
|
|
2968
|
+
],
|
|
2969
|
+
returns: "posts plus cursor pagination metadata.",
|
|
2970
|
+
examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."],
|
|
2971
|
+
}),
|
|
2972
|
+
inputSchema: {
|
|
2973
|
+
eventId: eventIdField,
|
|
2974
|
+
status: z.enum(TELEGRAM_POST_STATUSES).optional().describe("Optional post status filter."),
|
|
2975
|
+
limit: limitField(),
|
|
2976
|
+
cursor: cursorField,
|
|
2977
|
+
},
|
|
2978
|
+
outputSchema: telegramPostsOutputSchema,
|
|
2979
|
+
annotations: readOnlyTool,
|
|
2980
|
+
}, async ({ eventId, status, limit: max, cursor }) => {
|
|
2981
|
+
const { uid } = await authed(["telegram:write"]);
|
|
2982
|
+
await readEvent(eventId, uid, "read");
|
|
2983
|
+
const constraints = [
|
|
2984
|
+
where("eventId", "==", eventId),
|
|
2985
|
+
orderBy("createdAt", "desc"),
|
|
2986
|
+
limit(max + 1),
|
|
2987
|
+
];
|
|
2988
|
+
if (cursor)
|
|
2989
|
+
constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
|
|
2990
|
+
const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
|
|
2991
|
+
const { page, hasMore } = pickPage(snap.docs, max);
|
|
2992
|
+
let posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
2993
|
+
if (status)
|
|
2994
|
+
posts = posts.filter((post) => post.status === status);
|
|
2995
|
+
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
|
|
2996
|
+
return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
|
|
2997
|
+
});
|
|
2998
|
+
server.registerTool("oko_cancel_telegram_post", {
|
|
2999
|
+
title: "Cancel Telegram post",
|
|
3000
|
+
description: toolDescription({
|
|
3001
|
+
summary: "Delete a draft/scheduled/failed Telegram post record before it is sent.",
|
|
3002
|
+
args: ["postId: telegramPosts document id.", "confirm: must be true."],
|
|
3003
|
+
returns: "postId, eventId, status, and canceled=true.",
|
|
3004
|
+
examples: ["Use for scheduled posts that should not be sent."],
|
|
3005
|
+
notes: ["Already sent Telegram messages cannot be unsent through OKO; this tool refuses status=sent."],
|
|
3006
|
+
}),
|
|
3007
|
+
inputSchema: { postId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for cancellation.") },
|
|
3008
|
+
outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
|
|
3009
|
+
annotations: destructiveTool,
|
|
3010
|
+
}, async ({ postId, confirm }) => {
|
|
3011
|
+
await authed(["telegram:write"]);
|
|
3012
|
+
const postRef = doc(firestore, "telegramPosts", postId);
|
|
3013
|
+
const postSnap = await getDoc(postRef);
|
|
3014
|
+
if (!postSnap.exists())
|
|
3015
|
+
throw new Error("Telegram post not found.");
|
|
3016
|
+
const post = postSnap.data();
|
|
3017
|
+
if (post.status === "sent")
|
|
3018
|
+
throw new Error("Telegram post was already sent and cannot be canceled through OKO.");
|
|
3019
|
+
requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
|
|
3020
|
+
await deleteDoc(postRef);
|
|
3021
|
+
return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
|
|
3022
|
+
});
|
|
1933
3023
|
server.registerTool("oko_publish_telegram_post", {
|
|
1934
3024
|
title: "Publish Telegram post",
|
|
1935
3025
|
description: toolDescription({
|
|
@@ -1992,45 +3082,41 @@ export function registerTools(server) {
|
|
|
1992
3082
|
summary: "Create a workspace invite for a known OKO workspace.",
|
|
1993
3083
|
args: [
|
|
1994
3084
|
"workspaceId: workspace owned by the authenticated organizer.",
|
|
1995
|
-
"code: invite code, 4-64 chars;
|
|
3085
|
+
"code: optional invite code, 4-64 chars; omitted means generated.",
|
|
1996
3086
|
"email: optional intended recipient email.",
|
|
1997
3087
|
"permissions: optional whitelist of workspace permission booleans.",
|
|
1998
3088
|
"confirm: must be true.",
|
|
1999
3089
|
],
|
|
2000
|
-
returns: "workspaceId, invite code, and created=true.",
|
|
3090
|
+
returns: "workspaceId, invite code, inviteUrl, and created=true.",
|
|
2001
3091
|
examples: ["Use only after confirming the recipient and permission set with the workspace owner."],
|
|
2002
3092
|
}),
|
|
2003
3093
|
inputSchema: {
|
|
2004
3094
|
workspaceId: z.string().min(1),
|
|
2005
|
-
code: z.string().min(4).max(
|
|
3095
|
+
code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
|
|
2006
3096
|
email: z.string().email().optional(),
|
|
2007
3097
|
comment: z.string().max(500).default(""),
|
|
2008
3098
|
permissions: workspacePermissionsSchema,
|
|
2009
3099
|
confirm: z.boolean().optional(),
|
|
2010
3100
|
},
|
|
2011
|
-
outputSchema: z.object({ workspaceId: z.string(), code: z.string(), created: z.boolean() }).passthrough(),
|
|
3101
|
+
outputSchema: z.object({ workspaceId: z.string(), code: z.string(), inviteUrl: z.string().url(), created: z.boolean() }).passthrough(),
|
|
2012
3102
|
annotations: writeTool,
|
|
2013
3103
|
}, async (args) => {
|
|
2014
3104
|
const { uid } = await authed(["workspace:write"]);
|
|
2015
|
-
const
|
|
2016
|
-
if (!workspaceSnap.exists())
|
|
2017
|
-
throw new Error("Workspace not found.");
|
|
2018
|
-
const workspace = workspaceSnap.data();
|
|
2019
|
-
if (workspace.ownerId !== uid)
|
|
2020
|
-
throw new Error("Only the workspace owner can create invites.");
|
|
3105
|
+
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
2021
3106
|
requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
|
|
2022
|
-
const code = args.code
|
|
3107
|
+
const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
|
|
3108
|
+
const inviteUrl = `https://okostream.app/invite/${code}`;
|
|
2023
3109
|
await setDoc(doc(firestore, "workspaceInvites", code), {
|
|
2024
3110
|
workspaceId: args.workspaceId,
|
|
2025
3111
|
workspaceName: safeString(workspace.name, "Workspace"),
|
|
2026
3112
|
email: args.email ?? "",
|
|
2027
3113
|
comment: args.comment,
|
|
2028
|
-
permissions: args.permissions,
|
|
3114
|
+
permissions: normalizeWorkspacePermissions(args.permissions),
|
|
2029
3115
|
createdBy: uid,
|
|
2030
3116
|
createdAt: serverTimestamp(),
|
|
2031
3117
|
claimed: false,
|
|
2032
3118
|
claimedBy: null,
|
|
2033
3119
|
});
|
|
2034
|
-
return textResult({ workspaceId: args.workspaceId, code, created: true });
|
|
3120
|
+
return textResult({ workspaceId: args.workspaceId, code, inviteUrl, created: true });
|
|
2035
3121
|
});
|
|
2036
3122
|
}
|