@okostream/mcp 0.1.2 → 0.3.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/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";
@@ -39,6 +39,10 @@ const MAX_LIST_LIMIT = 100;
39
39
  const MAX_EXPORT_LIMIT = 200;
40
40
  const ANALYTICS_DEFAULT_LIMIT = 10;
41
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"];
42
46
  const readOnlyTool = {
43
47
  readOnlyHint: true,
44
48
  destructiveHint: false,
@@ -140,6 +144,51 @@ const deleteImageOutputSchema = z.object({
140
144
  storageDeletesAttempted: z.number().int().nonnegative(),
141
145
  storageDeleteWarnings: z.array(z.string()),
142
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();
166
+ const sessionReportOutputSchema = z.object({
167
+ eventId: z.string(),
168
+ sessionId: z.string(),
169
+ reportUrl: z.string().url(),
170
+ markdown: z.string(),
171
+ session: unknownRecordSchema,
172
+ }).passthrough();
173
+ const profileOutputSchema = z.object({
174
+ uid: z.string(),
175
+ profile: unknownRecordSchema,
176
+ updated: z.array(z.string()),
177
+ }).passthrough();
178
+ const deleteLeadOutputSchema = z.object({
179
+ eventId: z.string(),
180
+ leadId: z.string(),
181
+ deleted: z.boolean(),
182
+ }).passthrough();
183
+ const notificationsOutputSchema = z.object({
184
+ notifications: z.array(unknownRecordSchema),
185
+ unreadCountOnPage: z.number().int().nonnegative(),
186
+ ...cursorMetaSchema,
187
+ }).passthrough();
188
+ const notificationsMarkOutputSchema = z.object({
189
+ markedRead: z.number().int().nonnegative(),
190
+ notificationIds: z.array(z.string()),
191
+ }).passthrough();
143
192
  const formFieldSchema = z.object({
144
193
  id: z.string().min(1).optional(),
145
194
  label: z.string().max(120),
@@ -174,6 +223,23 @@ const cardItemInputSchema = z.object({
174
223
  buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
175
224
  formId: cardIdSchema.nullable().optional(),
176
225
  }).strict();
226
+ const emailSchema = z.string().email().max(320);
227
+ const nmoParticipantInputSchema = z.object({
228
+ email: emailSchema.describe("Participant email."),
229
+ fullName: z.string().min(2).max(200).describe("Participant full name."),
230
+ position: z.string().max(200).nullable().optional().describe("Optional medical position."),
231
+ org: z.string().max(200).nullable().optional().describe("Optional organization/clinic."),
232
+ }).strict();
233
+ const telegramChannelSchema = z.object({
234
+ chatId: z.number().int(),
235
+ title: z.string().max(200).optional(),
236
+ username: z.string().max(120).nullable().optional(),
237
+ }).strict();
238
+ const profileDisplayNameSchema = z.string().max(40).nullable();
239
+ const timezoneSchema = z.string()
240
+ .min(1)
241
+ .max(100)
242
+ .regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.");
177
243
  const cardsConfigOutputSchema = z.object({
178
244
  eventId: z.string(),
179
245
  updated: z.array(z.string()),
@@ -200,6 +266,17 @@ const workspacePermissionsSchema = z.object({
200
266
  downloadReports: z.boolean().optional(),
201
267
  sendNotifications: z.boolean().optional(),
202
268
  }).strict().default({});
269
+ const WORKSPACE_PERMISSION_KEYS = [
270
+ "createEvents",
271
+ "editSetup",
272
+ "deleteEvents",
273
+ "controlLive",
274
+ "moderateChat",
275
+ "startSession",
276
+ "viewAnalytics",
277
+ "downloadReports",
278
+ "sendNotifications",
279
+ ];
203
280
  const eventIdField = z.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
204
281
  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}.`);
205
282
  const cursorField = z.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
@@ -633,6 +710,35 @@ function normalizeVideo(videoUrl) {
633
710
  function eventUrl(eventId) {
634
711
  return `https://okostream.app/event/${eventId}`;
635
712
  }
713
+ function setupUrl(eventId) {
714
+ return `https://okostream.app/setup/${eventId}`;
715
+ }
716
+ function analyticsUrl(eventId) {
717
+ return `https://okostream.app/analytics/${eventId}`;
718
+ }
719
+ function sessionReportUrl(eventId, sessionId) {
720
+ return `${analyticsUrl(eventId)}/report/${sessionId}`;
721
+ }
722
+ function nmoRegisterUrl(eventId) {
723
+ return `${eventUrl(eventId)}/register`;
724
+ }
725
+ function nmoLoginUrl(eventId) {
726
+ return `${eventUrl(eventId)}/login`;
727
+ }
728
+ function eventLinks(eventId, eventData) {
729
+ const nmoMode = eventData?.nmoMode === true;
730
+ return {
731
+ url: eventUrl(eventId),
732
+ setupUrl: setupUrl(eventId),
733
+ analyticsUrl: analyticsUrl(eventId),
734
+ registrationUrl: nmoRegisterUrl(eventId),
735
+ loginUrl: nmoLoginUrl(eventId),
736
+ ...(nmoMode ? {
737
+ nmoRegistrationUrl: nmoRegisterUrl(eventId),
738
+ nmoLoginUrl: nmoLoginUrl(eventId),
739
+ } : {}),
740
+ };
741
+ }
636
742
  function assertEventReadable(data, uid) {
637
743
  const access = Array.isArray(data.accessibleBy) ? data.accessibleBy : [];
638
744
  const editors = Array.isArray(data.editors) ? data.editors : [];
@@ -695,6 +801,193 @@ async function buildWorkspaceAccess(uid, workspaceId) {
695
801
  }
696
802
  return { accessibleBy: [...accessibleBy], editors: [...editors] };
697
803
  }
804
+ async function buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId) {
805
+ const access = await buildWorkspaceAccess(uid, targetWorkspaceId);
806
+ const accessibleBy = new Set(access.accessibleBy);
807
+ const editors = new Set(access.editors);
808
+ if (originalOwnerId)
809
+ accessibleBy.add(originalOwnerId);
810
+ return { accessibleBy: [...accessibleBy], editors: [...editors] };
811
+ }
812
+ async function readWorkspaceForOwner(workspaceId, uid) {
813
+ const snap = await getDoc(doc(firestore, "workspaces", workspaceId));
814
+ if (!snap.exists())
815
+ throw new Error("Workspace not found.");
816
+ const data = snap.data();
817
+ if (data.ownerId !== uid)
818
+ throw new Error("Only the workspace owner can manage this workspace.");
819
+ return data;
820
+ }
821
+ function normalizeWorkspacePermissions(value) {
822
+ const out = {};
823
+ for (const key of WORKSPACE_PERMISSION_KEYS) {
824
+ if (value[key] === true)
825
+ out[key] = true;
826
+ else if (value[key] === false)
827
+ out[key] = false;
828
+ }
829
+ return out;
830
+ }
831
+ function randomInviteCode() {
832
+ return Math.random().toString(36).slice(2, 8).toUpperCase();
833
+ }
834
+ function normalizeEmail(value) {
835
+ return value.trim().toLowerCase();
836
+ }
837
+ function normalizeOptionalText(value, max) {
838
+ const text = value?.trim() ?? "";
839
+ return text ? text.slice(0, max) : null;
840
+ }
841
+ function parseCsvLine(line) {
842
+ const cells = [];
843
+ let current = "";
844
+ let quoted = false;
845
+ for (let i = 0; i < line.length; i++) {
846
+ const char = line[i];
847
+ const next = line[i + 1];
848
+ if (quoted && char === "\"" && next === "\"") {
849
+ current += "\"";
850
+ i++;
851
+ continue;
852
+ }
853
+ if (char === "\"") {
854
+ quoted = !quoted;
855
+ continue;
856
+ }
857
+ if (!quoted && char === ",") {
858
+ cells.push(current.trim());
859
+ current = "";
860
+ continue;
861
+ }
862
+ current += char;
863
+ }
864
+ cells.push(current.trim());
865
+ return cells;
866
+ }
867
+ function parseNmoParticipantsCsv(csv) {
868
+ const lines = csv
869
+ .split(/\r?\n/)
870
+ .map((line) => line.trim())
871
+ .filter(Boolean);
872
+ if (lines.length < 2)
873
+ throw new Error("CSV must include a header row and at least one participant row.");
874
+ const headers = parseCsvLine(lines[0] ?? "").map((header) => header.trim().toLowerCase());
875
+ const alias = {
876
+ email: "email",
877
+ mail: "email",
878
+ "e-mail": "email",
879
+ fullname: "fullName",
880
+ full_name: "fullName",
881
+ name: "fullName",
882
+ fio: "fullName",
883
+ "фио": "fullName",
884
+ "имя": "fullName",
885
+ position: "position",
886
+ "должность": "position",
887
+ org: "org",
888
+ organization: "org",
889
+ organisation: "org",
890
+ clinic: "org",
891
+ "организация": "org",
892
+ };
893
+ return lines.slice(1).map((line, index) => {
894
+ const cells = parseCsvLine(line);
895
+ const row = {};
896
+ headers.forEach((header, cellIndex) => {
897
+ const key = alias[header];
898
+ if (key)
899
+ row[key] = cells[cellIndex] ?? "";
900
+ });
901
+ const parsed = nmoParticipantInputSchema.safeParse(row);
902
+ if (!parsed.success) {
903
+ throw new Error(`Invalid NMO CSV row ${index + 2}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
904
+ }
905
+ return parsed.data;
906
+ });
907
+ }
908
+ async function readTelegramChannels(uid, includeWorkspaceChannels) {
909
+ const channelsById = new Map();
910
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
911
+ const profileChannels = Array.isArray(profileSnap.data()?.telegramChannels) ? profileSnap.data()?.telegramChannels : [];
912
+ for (const raw of profileChannels) {
913
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
914
+ continue;
915
+ const ch = raw;
916
+ if (typeof ch.chatId !== "number")
917
+ continue;
918
+ channelsById.set(ch.chatId, { ...serialize(ch), source: "profile" });
919
+ }
920
+ if (includeWorkspaceChannels) {
921
+ const workspaces = await listVisibleWorkspaces(uid, true);
922
+ for (const workspace of workspaces) {
923
+ const workspaceId = safeString(workspace.id);
924
+ const workspaceName = safeString(workspace.name);
925
+ const channels = Array.isArray(workspace.telegramChannels) ? workspace.telegramChannels : [];
926
+ for (const raw of channels) {
927
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
928
+ continue;
929
+ const ch = raw;
930
+ if (typeof ch.chatId !== "number")
931
+ continue;
932
+ const prev = channelsById.get(ch.chatId) ?? {};
933
+ const visibleInWorkspaces = Array.isArray(prev.visibleInWorkspaces) ? prev.visibleInWorkspaces : [];
934
+ channelsById.set(ch.chatId, {
935
+ ...prev,
936
+ ...serialize(ch),
937
+ source: prev.source ? "profile+workspace" : "workspace",
938
+ visibleInWorkspaces: [...visibleInWorkspaces, { id: workspaceId, name: workspaceName }],
939
+ });
940
+ }
941
+ }
942
+ }
943
+ return [...channelsById.values()].sort((a, b) => safeString(a.title).localeCompare(safeString(b.title)));
944
+ }
945
+ async function listVisibleWorkspaces(uid, includeShared) {
946
+ const out = [];
947
+ const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
948
+ ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
949
+ if (includeShared) {
950
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
951
+ const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
952
+ for (const workspaceId of sharedIds) {
953
+ if (out.some((row) => row.id === workspaceId))
954
+ continue;
955
+ const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
956
+ const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
957
+ if (wsSnap.exists())
958
+ out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
959
+ }
960
+ }
961
+ return out;
962
+ }
963
+ async function syncTelegramChannelToWorkspaces(uid, channel, workspaceIds) {
964
+ const updated = [];
965
+ if (workspaceIds.length === 0)
966
+ return updated;
967
+ const profileRef = doc(firestore, "userProfiles", uid);
968
+ const profileSnap = await getDoc(profileRef);
969
+ const channels = Array.isArray(profileSnap.data()?.telegramChannels) ? [...profileSnap.data().telegramChannels] : [];
970
+ const nextProfileChannels = channels.map((item) => {
971
+ if (item.chatId !== channel.chatId)
972
+ return item;
973
+ const current = Array.isArray(item.workspaceIds) ? item.workspaceIds.filter((id) => typeof id === "string") : [];
974
+ return { ...item, workspaceIds: [...new Set([...current, ...workspaceIds])] };
975
+ });
976
+ await setDoc(profileRef, { telegramChannels: nextProfileChannels }, { merge: true });
977
+ for (const workspaceId of workspaceIds) {
978
+ await readWorkspaceForOwner(workspaceId, uid);
979
+ const wsRef = doc(firestore, "workspaces", workspaceId);
980
+ const wsSnap = await getDoc(wsRef);
981
+ const wsChannels = Array.isArray(wsSnap.data()?.telegramChannels) ? wsSnap.data().telegramChannels : [];
982
+ if (!wsChannels.some((item) => item.chatId === channel.chatId)) {
983
+ await updateDoc(wsRef, {
984
+ telegramChannels: [...wsChannels, { chatId: channel.chatId, title: channel.title ?? "", username: channel.username ?? null }],
985
+ });
986
+ updated.push(workspaceId);
987
+ }
988
+ }
989
+ return updated;
990
+ }
698
991
  async function readAgg(eventId) {
699
992
  const snap = await get(ref(rtdb, `events/${eventId}/agg`));
700
993
  const agg = (snap.val() ?? {});
@@ -941,6 +1234,70 @@ function compactAnalyticsSession(raw, options) {
941
1234
  }
942
1235
  return session;
943
1236
  }
1237
+ function numberMetric(value) {
1238
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1239
+ }
1240
+ function formatSeconds(value) {
1241
+ const seconds = numberMetric(value);
1242
+ if (seconds == null)
1243
+ return "n/a";
1244
+ const total = Math.max(0, Math.round(seconds));
1245
+ const h = Math.floor(total / 3600);
1246
+ const m = Math.floor((total % 3600) / 60);
1247
+ const s = total % 60;
1248
+ if (h > 0)
1249
+ return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
1250
+ return `${m}m ${String(s).padStart(2, "0")}s`;
1251
+ }
1252
+ function formatDateTime(value) {
1253
+ const millis = toMillis(value);
1254
+ if (millis == null)
1255
+ return "n/a";
1256
+ return new Date(millis).toISOString();
1257
+ }
1258
+ function markdownMetric(label, value, suffix = "") {
1259
+ const metric = value === null || value === undefined || value === "" ? "n/a" : String(value);
1260
+ return `- ${label}: ${metric}${suffix}`;
1261
+ }
1262
+ function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
1263
+ const lines = [
1264
+ `# OKO session report`,
1265
+ "",
1266
+ `Event: ${safeString(event.title, eventId)}`,
1267
+ `Event URL: ${eventUrl(eventId)}`,
1268
+ `Report URL: ${sessionReportUrl(eventId, sessionId)}`,
1269
+ `Session: ${sessionId}`,
1270
+ "",
1271
+ "## Timing",
1272
+ markdownMetric("Started", formatDateTime(session.startedAt)),
1273
+ markdownMetric("Ended", formatDateTime(session.endedAt)),
1274
+ markdownMetric("Duration", formatSeconds(session.durationSec)),
1275
+ "",
1276
+ "## Audience",
1277
+ markdownMetric("Peak viewers", session.peakViewers),
1278
+ markdownMetric("Unique visitors", session.uniqueVisitors),
1279
+ markdownMetric("Views", session.viewCount),
1280
+ markdownMetric("Average watch time", formatSeconds(session.avgTimeSec)),
1281
+ markdownMetric("Median watch time", formatSeconds(session.medianTimeSec)),
1282
+ "",
1283
+ "## Engagement",
1284
+ markdownMetric("Chat messages", session.chatMessages),
1285
+ markdownMetric("Banner impressions", session.bannerImpressions),
1286
+ markdownMetric("Banner clicks", session.bannerClicks),
1287
+ markdownMetric("Banner CTR", session.bannerCtr, session.bannerCtr == null ? "" : "%"),
1288
+ markdownMetric("CTA clicks", session.ctaClicks),
1289
+ markdownMetric("Cards clicks", session.cardsClicks),
1290
+ ];
1291
+ const nmo = session.nmoEvaluation;
1292
+ if (nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
1293
+ const summary = nmo.summary;
1294
+ if (summary && typeof summary === "object" && !Array.isArray(summary)) {
1295
+ const s = summary;
1296
+ lines.push("", "## NMO", markdownMetric("Passed", s.passed), markdownMetric("Failed", s.failed), markdownMetric("No-show", s.noShow), markdownMetric("Total", s.total));
1297
+ }
1298
+ }
1299
+ return lines.join("\n");
1300
+ }
944
1301
  export function registerTools(server) {
945
1302
  server.registerTool("oko_whoami", {
946
1303
  title: "Who am I",
@@ -958,6 +1315,39 @@ export function registerTools(server) {
958
1315
  const auth = await authed([]);
959
1316
  return textResult({ uid: auth.uid, scopes: currentScopes() });
960
1317
  });
1318
+ server.registerTool("oko_update_profile", {
1319
+ title: "Update profile",
1320
+ description: toolDescription({
1321
+ summary: "Update the authenticated organizer profile fields used by OKO UI.",
1322
+ args: [
1323
+ "displayName: optional display name, max 40 chars; null clears it.",
1324
+ "timezone: optional IANA timezone such as Europe/Moscow or UTC.",
1325
+ ],
1326
+ returns: "uid, updated field names, and the resulting profile snapshot.",
1327
+ examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
1328
+ notes: ["This edits only the current organizer's own userProfiles document and does not require extra workspace/event scopes."],
1329
+ }),
1330
+ inputSchema: {
1331
+ displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
1332
+ timezone: timezoneSchema.optional().describe("IANA timezone, for example Europe/Moscow or UTC."),
1333
+ },
1334
+ outputSchema: profileOutputSchema,
1335
+ annotations: idempotentWriteTool,
1336
+ }, async ({ displayName, timezone }) => {
1337
+ const { uid } = await authed([]);
1338
+ const patch = { updatedAt: serverTimestamp() };
1339
+ if (displayName !== undefined)
1340
+ patch.displayName = displayName?.trim() || null;
1341
+ if (timezone !== undefined)
1342
+ patch.timezone = timezone.trim();
1343
+ const updated = Object.keys(patch).filter((key) => key !== "updatedAt");
1344
+ if (updated.length === 0)
1345
+ throw new Error("Provide displayName and/or timezone.");
1346
+ const profileRef = doc(firestore, "userProfiles", uid);
1347
+ await setDoc(profileRef, patch, { merge: true });
1348
+ const profileSnap = await getDoc(profileRef);
1349
+ return textResult({ uid, updated, profile: profileSnap.exists() ? serialize(profileSnap.data()) : {} });
1350
+ });
961
1351
  server.registerTool("oko_list_workspaces", {
962
1352
  title: "List workspaces",
963
1353
  description: toolDescription({
@@ -971,23 +1361,211 @@ export function registerTools(server) {
971
1361
  annotations: readOnlyTool,
972
1362
  }, async ({ includeShared }) => {
973
1363
  const { uid } = await authed(["workspace:read"]);
974
- const out = [];
975
- const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
976
- ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
977
- if (includeShared) {
978
- const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
979
- const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
980
- for (const workspaceId of sharedIds) {
981
- if (out.some((row) => typeof row === "object" && row !== null && row.id === workspaceId))
982
- continue;
983
- const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
984
- const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
985
- if (wsSnap.exists())
986
- out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
987
- }
988
- }
1364
+ const out = await listVisibleWorkspaces(uid, includeShared);
989
1365
  return textResult({ workspaces: out, count: out.length });
990
1366
  });
1367
+ server.registerTool("oko_create_workspace", {
1368
+ title: "Create workspace",
1369
+ description: toolDescription({
1370
+ summary: "Create a shared OKO workspace owned by the authenticated organizer.",
1371
+ args: ["name: workspace display name."],
1372
+ returns: "workspaceId, name, and created=true.",
1373
+ examples: ["Use when the organizer wants to create a new team/workspace for events."],
1374
+ }),
1375
+ inputSchema: { name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("Workspace name.") },
1376
+ outputSchema: z.object({ workspaceId: z.string(), created: z.boolean() }).passthrough(),
1377
+ annotations: writeTool,
1378
+ }, async ({ name }) => {
1379
+ const { uid } = await authed(["workspace:write"]);
1380
+ const workspaceRef = await addDoc(collection(firestore, "workspaces"), {
1381
+ name: name.trim(),
1382
+ ownerId: uid,
1383
+ personal: false,
1384
+ createdAt: serverTimestamp(),
1385
+ });
1386
+ return textResult({ workspaceId: workspaceRef.id, name: name.trim(), created: true });
1387
+ });
1388
+ server.registerTool("oko_update_workspace", {
1389
+ title: "Update workspace",
1390
+ description: toolDescription({
1391
+ summary: "Rename a workspace owned by the authenticated organizer.",
1392
+ args: ["workspaceId: workspace to update.", "name: new workspace display name."],
1393
+ returns: "workspaceId and updated fields.",
1394
+ examples: ["Use for workspace rename requests."],
1395
+ }),
1396
+ inputSchema: { workspaceId: z.string().min(1), name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("New workspace name.") },
1397
+ outputSchema: updateOutputSchema,
1398
+ annotations: idempotentWriteTool,
1399
+ }, async ({ workspaceId, name }) => {
1400
+ const { uid } = await authed(["workspace:write"]);
1401
+ await readWorkspaceForOwner(workspaceId, uid);
1402
+ await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name.trim() });
1403
+ return textResult({ workspaceId, updated: ["name"] });
1404
+ });
1405
+ server.registerTool("oko_delete_workspace", {
1406
+ title: "Delete workspace",
1407
+ description: toolDescription({
1408
+ summary: "Delete an empty non-personal workspace owned by the authenticated organizer.",
1409
+ args: ["workspaceId: workspace to delete.", "confirm: must be true."],
1410
+ returns: "workspaceId and deleted=true.",
1411
+ examples: ["Use after listing workspace access and confirming the workspace is empty."],
1412
+ notes: ["Personal workspaces cannot be deleted. This tool refuses to delete workspaces that still contain events; move/delete events first."],
1413
+ }),
1414
+ inputSchema: { workspaceId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for workspace deletion.") },
1415
+ outputSchema: z.object({ workspaceId: z.string(), deleted: z.boolean() }).passthrough(),
1416
+ annotations: destructiveTool,
1417
+ }, async ({ workspaceId, confirm }) => {
1418
+ const { uid } = await authed(["workspace:write"]);
1419
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
1420
+ requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
1421
+ if (workspace.personal === true)
1422
+ throw new Error("Personal workspace cannot be deleted.");
1423
+ const eventsSnap = await getDocs(query(collection(firestore, "events"), where("workspaceId", "==", workspaceId), limit(1)));
1424
+ if (!eventsSnap.empty)
1425
+ throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
1426
+ await deleteDoc(doc(firestore, "workspaces", workspaceId));
1427
+ return textResult({ workspaceId, deleted: true });
1428
+ });
1429
+ server.registerTool("oko_list_workspace_access", {
1430
+ title: "List workspace access",
1431
+ description: toolDescription({
1432
+ summary: "List workspace members/shares and pending invites.",
1433
+ args: ["workspaceId: workspace owned by the authenticated organizer."],
1434
+ returns: "workspace document, members array, invites array.",
1435
+ examples: ["Use before changing permissions, removing a member, or revoking an invite."],
1436
+ }),
1437
+ inputSchema: { workspaceId: z.string().min(1) },
1438
+ outputSchema: workspaceAccessOutputSchema,
1439
+ annotations: readOnlyTool,
1440
+ }, async ({ workspaceId }) => {
1441
+ const { uid } = await authed(["workspace:read"]);
1442
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
1443
+ const [sharesSnap, invitesSnap] = await Promise.all([
1444
+ getDocs(collection(firestore, "workspaces", workspaceId, "shares")),
1445
+ getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId))),
1446
+ ]);
1447
+ const members = [];
1448
+ for (const share of sharesSnap.docs) {
1449
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", share.id));
1450
+ members.push({
1451
+ uid: share.id,
1452
+ ...serialize(share.data()),
1453
+ profile: profileSnap.exists() ? serialize(profileSnap.data()) : null,
1454
+ });
1455
+ }
1456
+ const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
1457
+ return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites });
1458
+ });
1459
+ server.registerTool("oko_update_workspace_member", {
1460
+ title: "Update workspace member",
1461
+ description: toolDescription({
1462
+ summary: "Create or update a workspace share/member permissions document.",
1463
+ args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
1464
+ returns: "workspaceId, uid, and updated=true.",
1465
+ examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
1466
+ notes: ["Changing a share triggers OKO CF18 fan-out to event access, RTDB workspaceMembers, and chat roles."],
1467
+ }),
1468
+ inputSchema: {
1469
+ workspaceId: z.string().min(1),
1470
+ uid: z.string().min(1).describe("Member Firebase uid."),
1471
+ email: z.string().email().optional().describe("Optional member email cache."),
1472
+ comment: z.string().max(500).default(""),
1473
+ permissions: workspacePermissionsSchema,
1474
+ confirm: z.boolean().optional().describe("Required true for share updates."),
1475
+ },
1476
+ outputSchema: updateOutputSchema,
1477
+ annotations: writeTool,
1478
+ }, async (args) => {
1479
+ const { uid } = await authed(["workspace:write"]);
1480
+ const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
1481
+ requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
1482
+ await setDoc(doc(firestore, "workspaces", args.workspaceId, "shares", args.uid), {
1483
+ email: args.email ?? "",
1484
+ comment: args.comment.trim(),
1485
+ permissions: normalizeWorkspacePermissions(args.permissions),
1486
+ sharedAt: serverTimestamp(),
1487
+ sharedBy: uid,
1488
+ }, { merge: true });
1489
+ return textResult({ workspaceId: args.workspaceId, uid: args.uid, updated: true });
1490
+ });
1491
+ server.registerTool("oko_remove_workspace_member", {
1492
+ title: "Remove workspace member",
1493
+ description: toolDescription({
1494
+ summary: "Remove a member/share from a workspace.",
1495
+ args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
1496
+ returns: "workspaceId, uid, removed=true.",
1497
+ examples: ["Use after listing workspace access and confirming the exact uid."],
1498
+ notes: ["Removing a share triggers OKO CF18 reverse fan-out."],
1499
+ }),
1500
+ inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
1501
+ outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
1502
+ annotations: destructiveTool,
1503
+ }, async ({ workspaceId, uid: targetUid, confirm }) => {
1504
+ const { uid } = await authed(["workspace:write"]);
1505
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
1506
+ requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
1507
+ await deleteDoc(doc(firestore, "workspaces", workspaceId, "shares", targetUid));
1508
+ return textResult({ workspaceId, uid: targetUid, removed: true });
1509
+ });
1510
+ server.registerTool("oko_list_workspace_invites", {
1511
+ title: "List workspace invites",
1512
+ description: toolDescription({
1513
+ summary: "List workspace invite links for a workspace owned by the organizer.",
1514
+ args: ["workspaceId: workspace owned by the organizer."],
1515
+ returns: "invites array with code, claim status, permissions, and inviteUrl.",
1516
+ examples: ["Use before revoking or sharing an invite code."],
1517
+ }),
1518
+ inputSchema: { workspaceId: z.string().min(1) },
1519
+ outputSchema: z.object({ workspaceId: z.string(), invites: z.array(unknownRecordSchema), count: z.number().int().nonnegative() }).passthrough(),
1520
+ annotations: readOnlyTool,
1521
+ }, async ({ workspaceId }) => {
1522
+ const { uid } = await authed(["workspace:read"]);
1523
+ await readWorkspaceForOwner(workspaceId, uid);
1524
+ const snap = await getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)));
1525
+ const invites = snap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()), inviteUrl: `https://okostream.app/invite/${invite.id}` }));
1526
+ return textResult({ workspaceId, invites, count: invites.length });
1527
+ });
1528
+ server.registerTool("oko_revoke_workspace_invite", {
1529
+ title: "Revoke workspace invite",
1530
+ description: toolDescription({
1531
+ summary: "Delete a pending workspace invite code.",
1532
+ args: ["code: invite code.", "confirm: must be true."],
1533
+ returns: "code and revoked=true.",
1534
+ examples: ["Use after confirming the exact invite code."],
1535
+ }),
1536
+ inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH), confirm: z.boolean().optional().describe("Required true for invite revocation.") },
1537
+ outputSchema: z.object({ code: z.string(), revoked: z.boolean() }).passthrough(),
1538
+ annotations: destructiveTool,
1539
+ }, async ({ code, confirm }) => {
1540
+ const { uid } = await authed(["workspace:write"]);
1541
+ const normalizedCode = code.trim().toUpperCase();
1542
+ const inviteSnap = await getDoc(doc(firestore, "workspaceInvites", normalizedCode));
1543
+ if (!inviteSnap.exists())
1544
+ throw new Error("Workspace invite not found.");
1545
+ const invite = inviteSnap.data();
1546
+ if (invite.createdBy !== uid)
1547
+ throw new Error("Only the invite creator/workspace owner can revoke this invite.");
1548
+ requireConfirm(confirm, "oko_revoke_workspace_invite", { code: normalizedCode, workspaceId: invite.workspaceId, claimed: invite.claimed });
1549
+ await deleteDoc(doc(firestore, "workspaceInvites", normalizedCode));
1550
+ return textResult({ code: normalizedCode, revoked: true });
1551
+ });
1552
+ server.registerTool("oko_claim_workspace_invite", {
1553
+ title: "Claim workspace invite",
1554
+ description: toolDescription({
1555
+ summary: "Accept a workspace invite code for the authenticated organizer.",
1556
+ args: ["code: invite code from /invite/{code}."],
1557
+ returns: "success plus workspace id/name returned by OKO CF19.",
1558
+ examples: ["Use when the organizer wants to join a workspace from an invite code."],
1559
+ }),
1560
+ inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH) },
1561
+ outputSchema: unknownRecordSchema,
1562
+ annotations: writeTool,
1563
+ }, async ({ code }) => {
1564
+ await authed(["workspace:write"]);
1565
+ const claimInvite = httpsCallable(functions, "claimWorkspaceInvite");
1566
+ const result = await claimInvite({ code: code.trim().toUpperCase() });
1567
+ return textResult({ ...serialize(result.data) });
1568
+ });
991
1569
  server.registerTool("oko_list_my_events", {
992
1570
  title: "List my events",
993
1571
  description: toolDescription({
@@ -1038,7 +1616,7 @@ export function registerTools(server) {
1038
1616
  }, async ({ eventId }) => {
1039
1617
  const { uid } = await authed(["events:read"]);
1040
1618
  const data = await readEvent(eventId, uid, "read");
1041
- return textResult({ id: eventId, ...serialize(data), url: eventUrl(eventId) });
1619
+ return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId, data) });
1042
1620
  });
1043
1621
  server.registerTool("oko_create_webinar", {
1044
1622
  title: "Create webinar",
@@ -1094,7 +1672,7 @@ export function registerTools(server) {
1094
1672
  ownerId: uid,
1095
1673
  workspaceId,
1096
1674
  });
1097
- return textResult({ id: docRef.id, url: eventUrl(docRef.id), workspaceId });
1675
+ return textResult({ id: docRef.id, ...eventLinks(docRef.id), workspaceId });
1098
1676
  });
1099
1677
  server.registerTool("oko_update_event", {
1100
1678
  title: "Update event metadata",
@@ -1511,6 +2089,71 @@ export function registerTools(server) {
1511
2089
  await deleteDoc(doc(firestore, "events", eventId));
1512
2090
  return textResult({ deleted: eventId });
1513
2091
  });
2092
+ server.registerTool("oko_duplicate_event", {
2093
+ title: "Duplicate event",
2094
+ description: toolDescription({
2095
+ summary: "Duplicate an event setup into the same or another workspace without analytics/session history.",
2096
+ args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
2097
+ returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
2098
+ examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
2099
+ notes: ["Copies title, video, meta, blocks, forms, telegram config, NMO settings; does not copy analytics sessions/leads/participants."],
2100
+ }),
2101
+ inputSchema: {
2102
+ eventId: eventIdField,
2103
+ targetWorkspaceId: z.string().min(1).optional().describe("Target workspace id; omit to use the source workspace or personal workspace."),
2104
+ },
2105
+ outputSchema: createWebinarOutputSchema,
2106
+ annotations: writeTool,
2107
+ }, async ({ eventId, targetWorkspaceId }) => {
2108
+ const { uid } = await authed(["events:write"]);
2109
+ const source = await readEvent(eventId, uid, "read");
2110
+ const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
2111
+ const access = await buildWorkspaceAccess(uid, workspaceId);
2112
+ const newRef = await addDoc(collection(firestore, "events"), {
2113
+ ownerId: uid,
2114
+ workspaceId,
2115
+ accessibleBy: access.accessibleBy,
2116
+ editors: access.editors,
2117
+ createdAt: serverTimestamp(),
2118
+ title: safeString(source.title, "Untitled event"),
2119
+ pinned: false,
2120
+ video: source.video ?? { originalUrl: null, embedUrl: null },
2121
+ meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
2122
+ blocks: source.blocks ?? {},
2123
+ ...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
2124
+ ...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
2125
+ ...(source.nmoMode === true ? { nmoMode: true } : {}),
2126
+ ...(source.nmoCriteria && typeof source.nmoCriteria === "object" ? { nmoCriteria: source.nmoCriteria } : {}),
2127
+ ...(source.nmoExtraFields && typeof source.nmoExtraFields === "object" ? { nmoExtraFields: source.nmoExtraFields } : {}),
2128
+ });
2129
+ await update(ref(rtdb, `events/${newRef.id}`), { ownerId: uid, workspaceId });
2130
+ return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
2131
+ });
2132
+ server.registerTool("oko_move_event", {
2133
+ title: "Move event",
2134
+ description: toolDescription({
2135
+ summary: "Move an event to another workspace and recompute accessibleBy/editors.",
2136
+ args: ["eventId: event to move.", "targetWorkspaceId: target workspace id."],
2137
+ returns: "eventId, targetWorkspaceId, updated Firestore fields, and RTDB mirror status.",
2138
+ examples: ["Use when an event belongs in another workspace. Call oko_list_workspaces first to choose the target."],
2139
+ notes: ["Reads target workspace shares from Firestore with permissions; does not use RTDB workspaceMembers."],
2140
+ }),
2141
+ inputSchema: { eventId: eventIdField, targetWorkspaceId: z.string().min(1).describe("Target workspace id.") },
2142
+ outputSchema: updateOutputSchema,
2143
+ annotations: writeTool,
2144
+ }, async ({ eventId, targetWorkspaceId }) => {
2145
+ const { uid } = await authed(["events:write", "workspace:write"]);
2146
+ const event = await readEvent(eventId, uid, "edit");
2147
+ const originalOwnerId = safeString(event.ownerId) || null;
2148
+ const access = await buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId);
2149
+ await updateDoc(doc(firestore, "events", eventId), {
2150
+ workspaceId: targetWorkspaceId,
2151
+ accessibleBy: access.accessibleBy,
2152
+ editors: access.editors,
2153
+ });
2154
+ await set(ref(rtdb, `events/${eventId}/workspaceId`), targetWorkspaceId);
2155
+ return textResult({ eventId, targetWorkspaceId, updated: ["workspaceId", "accessibleBy", "editors"], rtdbMirrorUpdated: true });
2156
+ });
1514
2157
  server.registerTool("oko_get_live_state", {
1515
2158
  title: "Get live state",
1516
2159
  description: toolDescription({
@@ -1854,6 +2497,103 @@ export function registerTools(server) {
1854
2497
  ]);
1855
2498
  return textResult({ eventId, sessionId, ended: true });
1856
2499
  });
2500
+ server.registerTool("oko_fix_orphan_session", {
2501
+ title: "Fix orphan analytics session",
2502
+ description: toolDescription({
2503
+ summary: "Clear stuck active-session pointers when an analytics session cannot be ended normally.",
2504
+ args: [
2505
+ "eventId: event with stuck session pointers.",
2506
+ "deleteRtdbSessionData: optional, also delete events/{eventId}/sessions/{sessionId}; default false.",
2507
+ "confirm: must be true.",
2508
+ ],
2509
+ returns: "eventId, cleared sessionId if found, and cleaned paths.",
2510
+ examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
2511
+ notes: ["This does not stop the video stream; OKO Start/End controls analytics sessions only."],
2512
+ }),
2513
+ inputSchema: {
2514
+ eventId: eventIdField,
2515
+ deleteRtdbSessionData: z.boolean().default(false).describe("Also delete RTDB session data for the stuck session."),
2516
+ confirm: z.boolean().optional().describe("Required true for clearing session pointers."),
2517
+ },
2518
+ outputSchema: basicEventOutputSchema,
2519
+ annotations: destructiveTool,
2520
+ }, async ({ eventId, deleteRtdbSessionData, confirm }) => {
2521
+ const { uid } = await authed(["analytics:write", "live:control"]);
2522
+ const event = await readEvent(eventId, uid, "edit");
2523
+ const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
2524
+ const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
2525
+ requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
2526
+ const cleanup = [
2527
+ updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
2528
+ remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2529
+ ];
2530
+ if (sessionId)
2531
+ cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
2532
+ if (sessionId && deleteRtdbSessionData)
2533
+ cleanup.push(remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)));
2534
+ const results = await Promise.allSettled(cleanup);
2535
+ const warnings = results
2536
+ .filter((result) => result.status === "rejected")
2537
+ .map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
2538
+ return textResult({
2539
+ eventId,
2540
+ sessionId: sessionId || null,
2541
+ fixed: warnings.length === 0,
2542
+ cleared: ["events.activeAnalyticsSessionId", "ctrl/activeSessionId", ...(sessionId ? [`activeSessions/${sessionId}`] : []), ...(sessionId && deleteRtdbSessionData ? [`events/${eventId}/sessions/${sessionId}`] : [])],
2543
+ warnings,
2544
+ });
2545
+ });
2546
+ server.registerTool("oko_delete_session", {
2547
+ title: "Delete analytics session",
2548
+ description: toolDescription({
2549
+ summary: "Delete an analytics session document and optionally its RTDB session data.",
2550
+ args: [
2551
+ "eventId: event id.",
2552
+ "sessionId: analytics session id.",
2553
+ "deleteRtdbData: best-effort delete of events/{eventId}/sessions/{sessionId}; default false.",
2554
+ "confirm: must be true.",
2555
+ ],
2556
+ returns: "eventId, sessionId, deleted=true, and cleaned paths.",
2557
+ examples: ["Use only after confirming the exact session id with the organizer."],
2558
+ 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."],
2559
+ }),
2560
+ inputSchema: {
2561
+ eventId: eventIdField,
2562
+ sessionId: z.string().min(1).describe("analyticsSessions document id."),
2563
+ deleteRtdbData: z.boolean().default(false).describe("Best-effort delete RTDB session data for this session."),
2564
+ confirm: z.boolean().optional().describe("Required true for session deletion."),
2565
+ },
2566
+ outputSchema: basicEventOutputSchema,
2567
+ annotations: destructiveTool,
2568
+ }, async ({ eventId, sessionId, deleteRtdbData, confirm }) => {
2569
+ const { uid } = await authed(["analytics:write", "live:control"]);
2570
+ const event = await readEvent(eventId, uid, "edit");
2571
+ requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
2572
+ const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
2573
+ const cleanup = [];
2574
+ if (isActive) {
2575
+ cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
2576
+ cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
2577
+ cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
2578
+ }
2579
+ cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
2580
+ const results = await Promise.allSettled(cleanup);
2581
+ const warnings = results
2582
+ .filter((result) => result.status === "rejected")
2583
+ .map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
2584
+ if (warnings.length > 0)
2585
+ throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
2586
+ const rtdbCleanupWarnings = [];
2587
+ if (deleteRtdbData) {
2588
+ try {
2589
+ await remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`));
2590
+ }
2591
+ catch (err) {
2592
+ rtdbCleanupWarnings.push(err instanceof Error ? err.message : String(err));
2593
+ }
2594
+ }
2595
+ return textResult({ eventId, sessionId, deleted: true, wasActive: isActive, deleteRtdbData, rtdbCleanupWarnings });
2596
+ });
1857
2597
  server.registerTool("oko_get_analytics", {
1858
2598
  title: "Get analytics sessions",
1859
2599
  description: toolDescription({
@@ -1896,6 +2636,65 @@ export function registerTools(server) {
1896
2636
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
1897
2637
  return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots/includeNmoRows disabled." });
1898
2638
  });
2639
+ server.registerTool("oko_get_session_report", {
2640
+ title: "Get session report",
2641
+ description: toolDescription({
2642
+ summary: "Build a ready-to-share report for one analytics session, with compact structured data and markdown.",
2643
+ args: [
2644
+ "eventId: event to inspect.",
2645
+ "sessionId: optional analytics session id; omitted means latest session.",
2646
+ "includeTimeline: include recent timeline buckets; default false.",
2647
+ "timelineLimit: max timeline buckets when includeTimeline=true.",
2648
+ "includeSnapshots: include baseline/final snapshots; default false.",
2649
+ "includeNmoRows: include full NMO participant rows; default false.",
2650
+ ],
2651
+ returns: "event/session ids, report URL, markdown summary, event summary, and compact session object.",
2652
+ examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
2653
+ }),
2654
+ inputSchema: {
2655
+ eventId: eventIdField,
2656
+ sessionId: z.string().min(1).optional().describe("analyticsSessions document id. Omit to use the latest session."),
2657
+ includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets."),
2658
+ timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets when includeTimeline=true."),
2659
+ includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
2660
+ includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
2661
+ },
2662
+ outputSchema: sessionReportOutputSchema,
2663
+ annotations: readOnlyTool,
2664
+ }, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }) => {
2665
+ const { uid } = await authed(["analytics:read"]);
2666
+ const event = await readEvent(eventId, uid, "read");
2667
+ let resolvedSessionId = sessionId;
2668
+ let sessionData = null;
2669
+ if (resolvedSessionId) {
2670
+ const sessionSnap = await getDoc(doc(firestore, "events", eventId, "analyticsSessions", resolvedSessionId));
2671
+ if (!sessionSnap.exists())
2672
+ throw new Error("Analytics session not found.");
2673
+ sessionData = sessionSnap.data();
2674
+ }
2675
+ else {
2676
+ const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), orderBy("startedAt", "desc"), limit(1)));
2677
+ const first = snap.docs[0];
2678
+ if (!first)
2679
+ throw new Error("No analytics sessions found for this event.");
2680
+ resolvedSessionId = first.id;
2681
+ sessionData = first.data();
2682
+ }
2683
+ const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots, includeNmoRows });
2684
+ const markdown = sessionReportMarkdown({ eventId, event, sessionId: resolvedSessionId, session });
2685
+ return textResult({
2686
+ eventId,
2687
+ sessionId: resolvedSessionId,
2688
+ event: {
2689
+ id: eventId,
2690
+ title: safeString(event.title, eventId),
2691
+ ...eventLinks(eventId, event),
2692
+ },
2693
+ reportUrl: sessionReportUrl(eventId, resolvedSessionId),
2694
+ markdown,
2695
+ session,
2696
+ }, { truncationHint: "Keep includeTimeline/includeSnapshots/includeNmoRows disabled or lower timelineLimit." });
2697
+ });
1899
2698
  server.registerTool("oko_list_recent_chat", {
1900
2699
  title: "List recent chat",
1901
2700
  description: toolDescription({
@@ -2008,6 +2807,42 @@ export function registerTools(server) {
2008
2807
  await update(ref(chatRtdb), deletes);
2009
2808
  return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
2010
2809
  });
2810
+ server.registerTool("oko_unmute_user", {
2811
+ title: "Unmute chat user",
2812
+ description: toolDescription({
2813
+ summary: "Remove a chat mute for a user.",
2814
+ args: ["eventId: target event.", "uid: Firebase uid to unmute.", "confirm: must be true."],
2815
+ returns: "eventId, targetUid, and unmuted=true.",
2816
+ examples: ["Use when a user was muted by mistake or the organizer wants to restore chat access."],
2817
+ }),
2818
+ inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unmute."), confirm: z.boolean().optional().describe("Required true for unmute.") },
2819
+ outputSchema: basicEventOutputSchema,
2820
+ annotations: idempotentWriteTool,
2821
+ }, async ({ eventId, uid: targetUid, confirm }) => {
2822
+ const actor = await authed(["chat:moderate"]);
2823
+ await readEvent(eventId, actor.uid, "edit");
2824
+ requireConfirm(confirm, "oko_unmute_user", { eventId, targetUid });
2825
+ await remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`));
2826
+ return textResult({ eventId, targetUid, unmuted: true });
2827
+ });
2828
+ server.registerTool("oko_unban_user", {
2829
+ title: "Unban chat user",
2830
+ description: toolDescription({
2831
+ summary: "Remove a chat ban for a user.",
2832
+ args: ["eventId: target event.", "uid: Firebase uid to unban.", "confirm: must be true."],
2833
+ returns: "eventId, targetUid, and unbanned=true.",
2834
+ examples: ["Use when a user was banned by mistake or the organizer wants to restore event chat access."],
2835
+ }),
2836
+ inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unban."), confirm: z.boolean().optional().describe("Required true for unban.") },
2837
+ outputSchema: basicEventOutputSchema,
2838
+ annotations: idempotentWriteTool,
2839
+ }, async ({ eventId, uid: targetUid, confirm }) => {
2840
+ const actor = await authed(["chat:moderate"]);
2841
+ await readEvent(eventId, actor.uid, "edit");
2842
+ requireConfirm(confirm, "oko_unban_user", { eventId, targetUid });
2843
+ await remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`));
2844
+ return textResult({ eventId, targetUid, unbanned: true });
2845
+ });
2011
2846
  server.registerTool("oko_set_chat_settings", {
2012
2847
  title: "Set chat settings",
2013
2848
  description: toolDescription({
@@ -2113,6 +2948,116 @@ export function registerTools(server) {
2113
2948
  }
2114
2949
  return textResult({ participants: rows, ...meta });
2115
2950
  });
2951
+ server.registerTool("oko_add_nmo_participant", {
2952
+ title: "Add NMO participant",
2953
+ description: toolDescription({
2954
+ summary: "Add or update one NMO participant for an event through OKO organizer import.",
2955
+ args: ["eventId: NMO event.", "email/fullName/position/org: participant fields."],
2956
+ returns: "eventId, summary, and per-row import result.",
2957
+ examples: ["Use when the organizer dictates one doctor/participant to add before the webinar."],
2958
+ notes: ["This does not return a participant login token; use registration/login URLs from oko_get_event for attendee access."],
2959
+ }),
2960
+ inputSchema: { eventId: eventIdField, participant: nmoParticipantInputSchema },
2961
+ outputSchema: nmoImportOutputSchema,
2962
+ annotations: writeTool,
2963
+ }, async ({ eventId, participant }) => {
2964
+ const { uid } = await authed(["nmo:write"]);
2965
+ await readEvent(eventId, uid, "edit");
2966
+ const importFn = httpsCallable(functions, "importNmoParticipants");
2967
+ const result = await importFn({
2968
+ eventId,
2969
+ participants: [{
2970
+ email: normalizeEmail(participant.email),
2971
+ fullName: participant.fullName.trim(),
2972
+ position: normalizeOptionalText(participant.position, 200),
2973
+ org: normalizeOptionalText(participant.org, 200),
2974
+ }],
2975
+ });
2976
+ const data = serialize(result.data);
2977
+ const results = Array.isArray(data.results) ? data.results : [];
2978
+ const first = results[0];
2979
+ if (!first || first.ok !== true) {
2980
+ throw new Error(`NMO participant import failed: ${safeString(first?.error, "unknown error")}`);
2981
+ }
2982
+ return textResult({ eventId, ...data });
2983
+ });
2984
+ server.registerTool("oko_import_nmo_participants", {
2985
+ title: "Import NMO participants",
2986
+ description: toolDescription({
2987
+ summary: "Bulk import NMO participants from structured rows or CSV text.",
2988
+ args: [
2989
+ "eventId: NMO event.",
2990
+ "participants: optional array of rows { email, fullName, position?, org? }.",
2991
+ "csv: optional CSV text with headers email, fullName, position, org.",
2992
+ ],
2993
+ returns: "eventId, summary, and per-row results with ok/error.",
2994
+ examples: ["Use csv when the organizer pastes a participant list from a spreadsheet."],
2995
+ notes: ["Provide exactly one of participants or csv. Max 200 rows. The callable returns partial success for bulk imports."],
2996
+ }),
2997
+ inputSchema: {
2998
+ eventId: eventIdField,
2999
+ participants: z.array(nmoParticipantInputSchema).max(MAX_NMO_IMPORT_PARTICIPANTS).optional(),
3000
+ csv: z.string().max(80_000).optional().describe("CSV with headers email, fullName, position, org."),
3001
+ },
3002
+ outputSchema: nmoImportOutputSchema,
3003
+ annotations: writeTool,
3004
+ }, async ({ eventId, participants, csv }) => {
3005
+ const { uid } = await authed(["nmo:write"]);
3006
+ await readEvent(eventId, uid, "edit");
3007
+ if (Boolean(participants) === Boolean(csv))
3008
+ throw new Error("Provide exactly one of participants or csv.");
3009
+ const rows = participants ?? parseNmoParticipantsCsv(csv ?? "");
3010
+ if (rows.length === 0 || rows.length > MAX_NMO_IMPORT_PARTICIPANTS)
3011
+ throw new Error(`Import supports 1-${MAX_NMO_IMPORT_PARTICIPANTS} participants.`);
3012
+ const importFn = httpsCallable(functions, "importNmoParticipants");
3013
+ const result = await importFn({
3014
+ eventId,
3015
+ participants: rows.map((row) => ({
3016
+ email: normalizeEmail(row.email),
3017
+ fullName: row.fullName.trim(),
3018
+ position: normalizeOptionalText(row.position, 200),
3019
+ org: normalizeOptionalText(row.org, 200),
3020
+ })),
3021
+ });
3022
+ return textResult({ eventId, ...serialize(result.data) });
3023
+ });
3024
+ server.registerTool("oko_remove_nmo_participant", {
3025
+ title: "Remove NMO participant",
3026
+ description: toolDescription({
3027
+ summary: "Remove an NMO participant and immediately revoke event access.",
3028
+ args: ["eventId: NMO event.", "uid or email: participant to remove.", "confirm: must be true."],
3029
+ returns: "eventId, participant uid/email, and removed=true.",
3030
+ examples: ["Use after listing participants and confirming the exact participant."],
3031
+ notes: ["Order is intentional: arrayRemove from events.participantUids first, then delete participant doc."],
3032
+ }),
3033
+ inputSchema: {
3034
+ eventId: eventIdField,
3035
+ uid: z.string().min(1).optional().describe("Participant Firebase uid."),
3036
+ email: emailSchema.optional().describe("Participant email; used to resolve uid if uid is omitted."),
3037
+ confirm: z.boolean().optional().describe("Required true for participant removal."),
3038
+ },
3039
+ outputSchema: z.object({ eventId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
3040
+ annotations: destructiveTool,
3041
+ }, async ({ eventId, uid: participantUid, email, confirm }) => {
3042
+ const { uid } = await authed(["nmo:write"]);
3043
+ await readEvent(eventId, uid, "edit");
3044
+ let targetUid = participantUid;
3045
+ const normalizedEmail = email ? normalizeEmail(email) : null;
3046
+ if (!targetUid && normalizedEmail) {
3047
+ const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), where("email", "==", normalizedEmail), limit(2)));
3048
+ if (snap.empty)
3049
+ throw new Error(`NMO participant with email ${normalizedEmail} not found.`);
3050
+ if (snap.docs.length > 1)
3051
+ throw new Error(`Multiple NMO participants found for email ${normalizedEmail}; use uid.`);
3052
+ targetUid = snap.docs[0].id;
3053
+ }
3054
+ if (!targetUid)
3055
+ throw new Error("Provide uid or email.");
3056
+ requireConfirm(confirm, "oko_remove_nmo_participant", { eventId, uid: targetUid, email: normalizedEmail });
3057
+ await updateDoc(doc(firestore, "events", eventId), { participantUids: arrayRemove(targetUid) });
3058
+ await deleteDoc(doc(firestore, "eventParticipants", eventId, "items", targetUid));
3059
+ return textResult({ eventId, uid: targetUid, email: normalizedEmail, removed: true });
3060
+ });
2116
3061
  server.registerTool("oko_list_leads", {
2117
3062
  title: "List form leads",
2118
3063
  description: toolDescription({
@@ -2155,6 +3100,218 @@ export function registerTools(server) {
2155
3100
  }
2156
3101
  return textResult({ leads, ...meta });
2157
3102
  });
3103
+ server.registerTool("oko_delete_lead", {
3104
+ title: "Delete form lead",
3105
+ description: toolDescription({
3106
+ summary: "Delete one lead/form submission from an event.",
3107
+ args: ["eventId: event id.", "leadId: events/{eventId}/leads document id.", "confirm: must be true."],
3108
+ returns: "eventId, leadId, deleted=true, and a small preview of the deleted lead.",
3109
+ examples: ["Use after listing leads and confirming the exact submission to remove."],
3110
+ notes: ["Destructive. Requires setup:write in addition to leads:read so read-only keys cannot delete leads."],
3111
+ }),
3112
+ inputSchema: {
3113
+ eventId: eventIdField,
3114
+ leadId: z.string().min(1).max(160).describe("Lead document id under events/{eventId}/leads."),
3115
+ confirm: z.boolean().optional().describe("Required true for lead deletion."),
3116
+ },
3117
+ outputSchema: deleteLeadOutputSchema,
3118
+ annotations: destructiveTool,
3119
+ }, async ({ eventId, leadId, confirm }) => {
3120
+ const { uid } = await authed(["leads:read", "setup:write"]);
3121
+ await readEvent(eventId, uid, "edit");
3122
+ const leadRef = doc(firestore, "events", eventId, "leads", leadId);
3123
+ const leadSnap = await getDoc(leadRef);
3124
+ if (!leadSnap.exists())
3125
+ throw new Error("Lead not found.");
3126
+ const lead = serialize(leadSnap.data());
3127
+ requireConfirm(confirm, "oko_delete_lead", { eventId, leadId, formId: lead.formId, source: lead.source, submittedAt: lead.submittedAt });
3128
+ await deleteDoc(leadRef);
3129
+ return textResult({ eventId, leadId, deleted: true, leadPreview: lead });
3130
+ });
3131
+ server.registerTool("oko_list_notifications", {
3132
+ title: "List notifications",
3133
+ description: toolDescription({
3134
+ summary: "List in-app notifications for the authenticated organizer.",
3135
+ args: [
3136
+ "unreadOnly: only unread notifications, default false.",
3137
+ "limit: max notifications, default 20, max 100.",
3138
+ "cursor: createdAt cursor returned as nextCursor by the previous page.",
3139
+ ],
3140
+ returns: "notifications plus unreadCountOnPage and cursor pagination metadata.",
3141
+ examples: ["Use when the organizer asks what changed in OKO or what notifications are waiting."],
3142
+ }),
3143
+ inputSchema: {
3144
+ unreadOnly: z.boolean().default(false).describe("Return only unread notifications."),
3145
+ limit: limitField(100, 20),
3146
+ cursor: cursorField,
3147
+ },
3148
+ outputSchema: notificationsOutputSchema,
3149
+ annotations: readOnlyTool,
3150
+ }, async ({ unreadOnly, limit: max, cursor }) => {
3151
+ const { uid } = await authed(["workspace:read"]);
3152
+ const fetchLimit = unreadOnly ? Math.min(max * 5 + 1, 501) : max + 1;
3153
+ const constraints = [
3154
+ orderBy("createdAt", "desc"),
3155
+ limit(fetchLimit),
3156
+ ];
3157
+ if (cursor)
3158
+ constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
3159
+ const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), ...constraints));
3160
+ const matchingDocs = unreadOnly ? snap.docs.filter((item) => item.data().read !== true) : snap.docs;
3161
+ const { page, hasMore: hasMoreInMatches } = pickPage(matchingDocs, max);
3162
+ const hasMore = hasMoreInMatches || snap.docs.length >= fetchLimit;
3163
+ const cursorSource = hasMoreInMatches ? page.at(-1) : snap.docs.at(-1);
3164
+ const notifications = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
3165
+ const nextCursor = hasMore ? timestampCursorFrom(cursorSource?.data().createdAt) : null;
3166
+ return textResult({
3167
+ notifications,
3168
+ unreadCountOnPage: notifications.filter((item) => item.read !== true).length,
3169
+ ...cursorMeta(notifications.length, max, hasMore, nextCursor),
3170
+ });
3171
+ });
3172
+ server.registerTool("oko_mark_notifications_read", {
3173
+ title: "Mark notifications read",
3174
+ description: toolDescription({
3175
+ summary: "Mark own OKO in-app notifications as read.",
3176
+ args: [
3177
+ "notificationIds: optional explicit notification ids, max 100.",
3178
+ "allUnread: true to mark unread notifications on the first page, max controlled by limit.",
3179
+ "limit: when allUnread=true, max unread notifications to mark, default 100, max 200.",
3180
+ ],
3181
+ returns: "markedRead count and notification ids updated.",
3182
+ examples: ["Use after oko_list_notifications when the user asks to clear the bell/unread state."],
3183
+ notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications."],
3184
+ }),
3185
+ inputSchema: {
3186
+ notificationIds: z.array(z.string().min(1).max(160)).max(100).optional().describe("Specific notification document ids."),
3187
+ allUnread: z.boolean().default(false).describe("Mark unread notifications as read."),
3188
+ limit: limitField(200, 100),
3189
+ },
3190
+ outputSchema: notificationsMarkOutputSchema,
3191
+ annotations: idempotentWriteTool,
3192
+ }, async ({ notificationIds, allUnread, limit: max }) => {
3193
+ const { uid } = await authed(["workspace:read"]);
3194
+ if (Boolean(notificationIds?.length) === allUnread) {
3195
+ throw new Error("Provide either notificationIds or allUnread=true.");
3196
+ }
3197
+ let ids = [...new Set(notificationIds ?? [])];
3198
+ if (allUnread) {
3199
+ const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), where("read", "==", false), limit(max)));
3200
+ ids = snap.docs.map((item) => item.id);
3201
+ }
3202
+ await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
3203
+ return textResult({ markedRead: ids.length, notificationIds: ids });
3204
+ });
3205
+ server.registerTool("oko_list_telegram_channels", {
3206
+ title: "List Telegram channels",
3207
+ description: toolDescription({
3208
+ summary: "List Telegram channels available to the authenticated organizer.",
3209
+ args: ["includeWorkspaceChannels: include channels exposed through visible workspaces, default true."],
3210
+ returns: "channels with chatId, title, username, source, and visible workspace metadata.",
3211
+ examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
3212
+ }),
3213
+ inputSchema: { includeWorkspaceChannels: z.boolean().default(true).describe("Include workspace-level channels visible to this organizer.") },
3214
+ outputSchema: telegramChannelsOutputSchema,
3215
+ annotations: readOnlyTool,
3216
+ }, async ({ includeWorkspaceChannels }) => {
3217
+ const { uid } = await authed(["telegram:write"]);
3218
+ const channels = await readTelegramChannels(uid, includeWorkspaceChannels);
3219
+ return textResult({ channels, count: channels.length });
3220
+ });
3221
+ server.registerTool("oko_claim_telegram_channel", {
3222
+ title: "Claim Telegram channel",
3223
+ description: toolDescription({
3224
+ summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
3225
+ args: [
3226
+ "claimCode: code posted by the bot after it was added to a channel.",
3227
+ "workspaceIds: optional workspaces to expose this channel to after claim.",
3228
+ ],
3229
+ returns: "claimed channel and workspace ids updated.",
3230
+ examples: ["Add @OKOStreamBot to a Telegram channel, copy the claim code, then call this tool."],
3231
+ notes: ["Workspace sync requires the authenticated organizer to own each workspace."],
3232
+ }),
3233
+ inputSchema: {
3234
+ claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
3235
+ workspaceIds: z.array(z.string().min(1)).max(20).default([]).describe("Optional workspace ids to attach the channel to."),
3236
+ },
3237
+ outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
3238
+ annotations: writeTool,
3239
+ }, async ({ claimCode, workspaceIds }) => {
3240
+ const { uid } = await authed(["telegram:write"]);
3241
+ const claimChannel = httpsCallable(functions, "claimTelegramChannel");
3242
+ const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
3243
+ const data = serialize(result.data);
3244
+ const channelRaw = data.channel && typeof data.channel === "object" ? data.channel : {};
3245
+ const parsed = telegramChannelSchema.safeParse(channelRaw);
3246
+ if (!parsed.success)
3247
+ throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
3248
+ const workspaceIdsUpdated = await syncTelegramChannelToWorkspaces(uid, parsed.data, workspaceIds);
3249
+ return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated });
3250
+ });
3251
+ server.registerTool("oko_list_telegram_posts", {
3252
+ title: "List Telegram posts",
3253
+ description: toolDescription({
3254
+ summary: "List Telegram notification posts for an event.",
3255
+ args: [
3256
+ "eventId: event whose Telegram posts to list.",
3257
+ "status: optional client-side status filter.",
3258
+ "limit: max posts, default 30, max 100.",
3259
+ "cursor: createdAt cursor returned by previous page.",
3260
+ ],
3261
+ returns: "posts plus cursor pagination metadata.",
3262
+ examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."],
3263
+ }),
3264
+ inputSchema: {
3265
+ eventId: eventIdField,
3266
+ status: z.enum(TELEGRAM_POST_STATUSES).optional().describe("Optional post status filter."),
3267
+ limit: limitField(),
3268
+ cursor: cursorField,
3269
+ },
3270
+ outputSchema: telegramPostsOutputSchema,
3271
+ annotations: readOnlyTool,
3272
+ }, async ({ eventId, status, limit: max, cursor }) => {
3273
+ const { uid } = await authed(["telegram:write"]);
3274
+ await readEvent(eventId, uid, "read");
3275
+ const constraints = [
3276
+ where("eventId", "==", eventId),
3277
+ orderBy("createdAt", "desc"),
3278
+ limit(max + 1),
3279
+ ];
3280
+ if (cursor)
3281
+ constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
3282
+ const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
3283
+ const { page, hasMore } = pickPage(snap.docs, max);
3284
+ let posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
3285
+ if (status)
3286
+ posts = posts.filter((post) => post.status === status);
3287
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
3288
+ return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
3289
+ });
3290
+ server.registerTool("oko_cancel_telegram_post", {
3291
+ title: "Cancel Telegram post",
3292
+ description: toolDescription({
3293
+ summary: "Delete a draft/scheduled/failed Telegram post record before it is sent.",
3294
+ args: ["postId: telegramPosts document id.", "confirm: must be true."],
3295
+ returns: "postId, eventId, status, and canceled=true.",
3296
+ examples: ["Use for scheduled posts that should not be sent."],
3297
+ notes: ["Already sent Telegram messages cannot be unsent through OKO; this tool refuses status=sent."],
3298
+ }),
3299
+ inputSchema: { postId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for cancellation.") },
3300
+ outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
3301
+ annotations: destructiveTool,
3302
+ }, async ({ postId, confirm }) => {
3303
+ await authed(["telegram:write"]);
3304
+ const postRef = doc(firestore, "telegramPosts", postId);
3305
+ const postSnap = await getDoc(postRef);
3306
+ if (!postSnap.exists())
3307
+ throw new Error("Telegram post not found.");
3308
+ const post = postSnap.data();
3309
+ if (post.status === "sent")
3310
+ throw new Error("Telegram post was already sent and cannot be canceled through OKO.");
3311
+ requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
3312
+ await deleteDoc(postRef);
3313
+ return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
3314
+ });
2158
3315
  server.registerTool("oko_publish_telegram_post", {
2159
3316
  title: "Publish Telegram post",
2160
3317
  description: toolDescription({
@@ -2217,45 +3374,41 @@ export function registerTools(server) {
2217
3374
  summary: "Create a workspace invite for a known OKO workspace.",
2218
3375
  args: [
2219
3376
  "workspaceId: workspace owned by the authenticated organizer.",
2220
- "code: invite code, 4-64 chars; stored uppercase.",
3377
+ "code: optional invite code, 4-64 chars; omitted means generated.",
2221
3378
  "email: optional intended recipient email.",
2222
3379
  "permissions: optional whitelist of workspace permission booleans.",
2223
3380
  "confirm: must be true.",
2224
3381
  ],
2225
- returns: "workspaceId, invite code, and created=true.",
3382
+ returns: "workspaceId, invite code, inviteUrl, and created=true.",
2226
3383
  examples: ["Use only after confirming the recipient and permission set with the workspace owner."],
2227
3384
  }),
2228
3385
  inputSchema: {
2229
3386
  workspaceId: z.string().min(1),
2230
- code: z.string().min(4).max(64),
3387
+ code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
2231
3388
  email: z.string().email().optional(),
2232
3389
  comment: z.string().max(500).default(""),
2233
3390
  permissions: workspacePermissionsSchema,
2234
3391
  confirm: z.boolean().optional(),
2235
3392
  },
2236
- outputSchema: z.object({ workspaceId: z.string(), code: z.string(), created: z.boolean() }).passthrough(),
3393
+ outputSchema: z.object({ workspaceId: z.string(), code: z.string(), inviteUrl: z.string().url(), created: z.boolean() }).passthrough(),
2237
3394
  annotations: writeTool,
2238
3395
  }, async (args) => {
2239
3396
  const { uid } = await authed(["workspace:write"]);
2240
- const workspaceSnap = await getDoc(doc(firestore, "workspaces", args.workspaceId));
2241
- if (!workspaceSnap.exists())
2242
- throw new Error("Workspace not found.");
2243
- const workspace = workspaceSnap.data();
2244
- if (workspace.ownerId !== uid)
2245
- throw new Error("Only the workspace owner can create invites.");
3397
+ const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
2246
3398
  requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
2247
- const code = args.code.trim().toUpperCase();
3399
+ const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
3400
+ const inviteUrl = `https://okostream.app/invite/${code}`;
2248
3401
  await setDoc(doc(firestore, "workspaceInvites", code), {
2249
3402
  workspaceId: args.workspaceId,
2250
3403
  workspaceName: safeString(workspace.name, "Workspace"),
2251
3404
  email: args.email ?? "",
2252
3405
  comment: args.comment,
2253
- permissions: args.permissions,
3406
+ permissions: normalizeWorkspacePermissions(args.permissions),
2254
3407
  createdBy: uid,
2255
3408
  createdAt: serverTimestamp(),
2256
3409
  claimed: false,
2257
3410
  claimedBy: null,
2258
3411
  });
2259
- return textResult({ workspaceId: args.workspaceId, code, created: true });
3412
+ return textResult({ workspaceId: args.workspaceId, code, inviteUrl, created: true });
2260
3413
  });
2261
3414
  }