@okostream/mcp 0.1.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 ADDED
@@ -0,0 +1,1556 @@
1
+ import { z } from "zod";
2
+ import { addDoc, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
3
+ import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
4
+ import { httpsCallable } from "firebase/functions";
5
+ import { ensureAuth, currentScopes } from "./auth.js";
6
+ import { chatRtdb, firestore, functions, rtdb } from "./firebase.js";
7
+ import { csvCell, requireConfirm, safeString, serialize, textResult } from "./utils.js";
8
+ import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
9
+ const boolOrNull = z.boolean().nullable();
10
+ const MAX_FIRESTORE_TIMELINE_BUCKETS = 1500;
11
+ const DEFAULT_LIST_LIMIT = 30;
12
+ const MAX_LIST_LIMIT = 100;
13
+ const MAX_EXPORT_LIMIT = 200;
14
+ const ANALYTICS_DEFAULT_LIMIT = 10;
15
+ const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
16
+ const readOnlyTool = {
17
+ readOnlyHint: true,
18
+ destructiveHint: false,
19
+ idempotentHint: true,
20
+ openWorldHint: true,
21
+ };
22
+ const writeTool = {
23
+ readOnlyHint: false,
24
+ destructiveHint: false,
25
+ idempotentHint: false,
26
+ openWorldHint: true,
27
+ };
28
+ const idempotentWriteTool = {
29
+ readOnlyHint: false,
30
+ destructiveHint: false,
31
+ idempotentHint: true,
32
+ openWorldHint: true,
33
+ };
34
+ const destructiveTool = {
35
+ readOnlyHint: false,
36
+ destructiveHint: true,
37
+ idempotentHint: false,
38
+ openWorldHint: true,
39
+ };
40
+ const unknownRecordSchema = z.record(z.unknown());
41
+ const basicEventOutputSchema = z.object({
42
+ eventId: z.string(),
43
+ }).passthrough();
44
+ const updateOutputSchema = z.object({
45
+ id: z.string().optional(),
46
+ eventId: z.string().optional(),
47
+ updated: z.array(z.string()).optional(),
48
+ }).passthrough();
49
+ const createWebinarOutputSchema = {
50
+ id: z.string(),
51
+ url: z.string().url(),
52
+ workspaceId: z.string(),
53
+ };
54
+ const cursorMetaSchema = {
55
+ count: z.number().int().nonnegative(),
56
+ limit: z.number().int().positive(),
57
+ hasMore: z.boolean(),
58
+ nextCursor: z.string().nullable(),
59
+ has_more: z.boolean(),
60
+ next_cursor: z.string().nullable(),
61
+ };
62
+ const workspacesOutputSchema = z.object({
63
+ workspaces: z.array(unknownRecordSchema),
64
+ count: z.number().int().nonnegative(),
65
+ }).passthrough();
66
+ const eventsOutputSchema = z.object({
67
+ events: z.array(unknownRecordSchema),
68
+ ...cursorMetaSchema,
69
+ }).passthrough();
70
+ const eventOutputSchema = z.object({
71
+ id: z.string(),
72
+ url: z.string().url(),
73
+ }).passthrough();
74
+ const liveStateOutputSchema = z.object({
75
+ eventId: z.string(),
76
+ agg: unknownRecordSchema,
77
+ ctrl: unknownRecordSchema,
78
+ chat: unknownRecordSchema,
79
+ }).passthrough();
80
+ const analyticsOutputSchema = z.object({
81
+ sessions: z.array(unknownRecordSchema),
82
+ ...cursorMetaSchema,
83
+ }).passthrough();
84
+ const chatOutputSchema = z.object({
85
+ messages: z.array(unknownRecordSchema),
86
+ ...cursorMetaSchema,
87
+ }).passthrough();
88
+ const participantsOutputSchema = z.object({
89
+ participants: z.array(unknownRecordSchema).optional(),
90
+ csv: z.string().optional(),
91
+ ...cursorMetaSchema,
92
+ }).passthrough();
93
+ const leadsOutputSchema = z.object({
94
+ leads: z.array(unknownRecordSchema).optional(),
95
+ csv: z.string().optional(),
96
+ ...cursorMetaSchema,
97
+ }).passthrough();
98
+ const formFieldSchema = z.object({
99
+ id: z.string().min(1).optional(),
100
+ label: z.string().max(120),
101
+ type: z.enum(["text", "tel", "email", "textarea"]).default("text"),
102
+ required: z.boolean().default(false),
103
+ placeholder: z.string().max(160).nullable().optional(),
104
+ }).strict();
105
+ const formSchema = z.object({
106
+ id: z.string().min(1).optional(),
107
+ title: z.string().max(160).nullable().optional(),
108
+ description: z.string().max(1000).nullable().optional(),
109
+ submitLabel: z.string().max(80).nullable().optional(),
110
+ successText: z.string().max(500).nullable().optional(),
111
+ consentText: z.string().max(500).nullable().optional(),
112
+ consentRequired: z.boolean().default(true),
113
+ fields: z.array(formFieldSchema).max(30),
114
+ }).strict();
115
+ const workspacePermissionsSchema = z.object({
116
+ createEvents: z.boolean().optional(),
117
+ editSetup: z.boolean().optional(),
118
+ deleteEvents: z.boolean().optional(),
119
+ controlLive: z.boolean().optional(),
120
+ moderateChat: z.boolean().optional(),
121
+ startSession: z.boolean().optional(),
122
+ viewAnalytics: z.boolean().optional(),
123
+ downloadReports: z.boolean().optional(),
124
+ sendNotifications: z.boolean().optional(),
125
+ }).strict().default({});
126
+ const eventIdField = z.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
127
+ 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}.`);
128
+ const cursorField = z.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
129
+ function toolDescription({ summary, args = [], returns, examples = [], notes = [], }) {
130
+ return [
131
+ summary,
132
+ args.length ? `Args:\n${args.map((item) => `- ${item}`).join("\n")}` : "",
133
+ `Returns:\n- ${returns}`,
134
+ examples.length ? `Examples:\n${examples.map((item) => `- ${item}`).join("\n")}` : "",
135
+ notes.length ? `Notes:\n${notes.map((item) => `- ${item}`).join("\n")}` : "",
136
+ ].filter(Boolean).join("\n\n");
137
+ }
138
+ function cursorMeta(count, max, hasMore, nextCursor) {
139
+ return {
140
+ count,
141
+ limit: max,
142
+ hasMore,
143
+ nextCursor,
144
+ has_more: hasMore,
145
+ next_cursor: nextCursor,
146
+ };
147
+ }
148
+ function pickPage(items, max) {
149
+ return {
150
+ page: items.slice(0, max),
151
+ hasMore: items.length > max,
152
+ };
153
+ }
154
+ function randomId() {
155
+ return Math.random().toString(36).slice(2) + Date.now().toString(36);
156
+ }
157
+ async function authed(scopes) {
158
+ return ensureAuth(scopes);
159
+ }
160
+ function normalizeVideo(videoUrl) {
161
+ const raw = videoUrl?.trim();
162
+ if (!raw)
163
+ return { originalUrl: null, embedUrl: null };
164
+ const effective = extractIframeSrc(raw) ?? raw;
165
+ const detected = detectAndNormalize(effective);
166
+ if (detected.provider !== "unknown" && !detected.normalizedUrl) {
167
+ throw new Error(`Could not extract a valid ${detected.provider} video id from videoUrl.`);
168
+ }
169
+ return { originalUrl: raw, embedUrl: detected.normalizedUrl ?? effective };
170
+ }
171
+ function eventUrl(eventId) {
172
+ return `https://okostream.app/event/${eventId}`;
173
+ }
174
+ function assertEventReadable(data, uid) {
175
+ const access = Array.isArray(data.accessibleBy) ? data.accessibleBy : [];
176
+ const editors = Array.isArray(data.editors) ? data.editors : [];
177
+ if (data.ownerId !== uid && !access.includes(uid) && !editors.includes(uid)) {
178
+ throw new Error("Event is not accessible to this organizer.");
179
+ }
180
+ }
181
+ function assertEventEditable(data, uid) {
182
+ const editors = Array.isArray(data.editors) ? data.editors : [];
183
+ if (data.ownerId !== uid && !editors.includes(uid)) {
184
+ throw new Error("Event is not editable by this organizer.");
185
+ }
186
+ }
187
+ async function readEvent(eventId, uid, mode = "read") {
188
+ const snap = await getDoc(doc(firestore, "events", eventId));
189
+ if (!snap.exists())
190
+ throw new Error("Event not found.");
191
+ const data = snap.data();
192
+ if (mode === "edit")
193
+ assertEventEditable(data, uid);
194
+ else
195
+ assertEventReadable(data, uid);
196
+ return data;
197
+ }
198
+ function canEditEventWithPermissions(perms) {
199
+ return perms.editSetup === true || perms.controlLive === true || perms.startSession === true;
200
+ }
201
+ async function buildWorkspaceAccess(uid, workspaceId) {
202
+ const accessibleBy = new Set([uid]);
203
+ const editors = new Set([uid]);
204
+ if (workspaceId === uid) {
205
+ return { accessibleBy: [...accessibleBy], editors: [...editors] };
206
+ }
207
+ const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
208
+ if (!wsSnap.exists())
209
+ throw new Error("Workspace not found or not readable.");
210
+ const wsOwnerId = safeString(wsSnap.data().ownerId);
211
+ if (wsOwnerId) {
212
+ accessibleBy.add(wsOwnerId);
213
+ editors.add(wsOwnerId);
214
+ }
215
+ if (wsOwnerId !== uid) {
216
+ const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
217
+ const perms = (shareSnap.data()?.permissions ?? {});
218
+ if (!shareSnap.exists() || perms.createEvents !== true) {
219
+ throw new Error("This organizer lacks createEvents permission in the workspace.");
220
+ }
221
+ }
222
+ try {
223
+ const sharesSnap = await getDocs(collection(firestore, "workspaces", workspaceId, "shares"));
224
+ sharesSnap.forEach((share) => {
225
+ accessibleBy.add(share.id);
226
+ const perms = (share.data().permissions ?? {});
227
+ if (canEditEventWithPermissions(perms))
228
+ editors.add(share.id);
229
+ });
230
+ }
231
+ catch {
232
+ // Non-owner members cannot read all shares. CF20 will backfill accessibleBy/editors.
233
+ }
234
+ return { accessibleBy: [...accessibleBy], editors: [...editors] };
235
+ }
236
+ async function readAgg(eventId) {
237
+ const snap = await get(ref(rtdb, `events/${eventId}/agg`));
238
+ const agg = (snap.val() ?? {});
239
+ const cardsById = readNestedClicks(agg.cards);
240
+ const ctaById = readNestedClicks(agg.cta);
241
+ return {
242
+ chatMessages: Number(agg.chatMessages ?? 0),
243
+ bannerClicks: Number(agg.bannerClicks ?? 0),
244
+ bannerImpressions: Number(agg.bannerImpressions ?? 0),
245
+ ctaClicks: Number(agg.ctaClicks ?? 0),
246
+ cardsClicks: Number(agg.cardsClicks ?? 0),
247
+ viewCount: Number(agg.viewCount ?? 0),
248
+ viewSumSec: Number(agg.viewSumSec ?? 0),
249
+ online: Number(agg.online ?? 0),
250
+ uniqueVisitors: Number(agg.uniqueVisitors ?? 0),
251
+ viewHist: numberRecord(agg.viewHist),
252
+ geo: numberRecord(agg.geo),
253
+ cardsById,
254
+ ctaById,
255
+ };
256
+ }
257
+ async function getChatRole(eventId, uid) {
258
+ const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
259
+ return typeof snap.val() === "string" ? snap.val() : "viewer";
260
+ }
261
+ function csvRows(headers, rows) {
262
+ return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
263
+ }
264
+ function numberRecord(value) {
265
+ if (!value || typeof value !== "object" || Array.isArray(value))
266
+ return {};
267
+ const out = {};
268
+ for (const [key, raw] of Object.entries(value)) {
269
+ if (typeof raw === "number" && Number.isFinite(raw))
270
+ out[key] = raw;
271
+ }
272
+ return out;
273
+ }
274
+ function readNestedClicks(value) {
275
+ if (!value || typeof value !== "object" || Array.isArray(value))
276
+ return {};
277
+ const out = {};
278
+ for (const [id, raw] of Object.entries(value)) {
279
+ if (typeof raw === "number" && Number.isFinite(raw)) {
280
+ out[id] = raw;
281
+ continue;
282
+ }
283
+ if (raw && typeof raw === "object" && typeof raw.clicks === "number") {
284
+ out[id] = raw.clicks;
285
+ }
286
+ }
287
+ return out;
288
+ }
289
+ function cleanForFirestore(value) {
290
+ if (value === undefined)
291
+ return undefined;
292
+ if (value === null || typeof value !== "object")
293
+ return value;
294
+ if (Array.isArray(value))
295
+ return value.map(cleanForFirestore).filter((item) => item !== undefined);
296
+ const out = {};
297
+ for (const [key, item] of Object.entries(value)) {
298
+ const cleaned = cleanForFirestore(item);
299
+ if (cleaned !== undefined)
300
+ out[key] = cleaned;
301
+ }
302
+ return out;
303
+ }
304
+ function downsampleTimeline(buckets, maxBuckets = MAX_FIRESTORE_TIMELINE_BUCKETS) {
305
+ if (buckets.length <= maxBuckets)
306
+ return buckets;
307
+ if (maxBuckets < 2)
308
+ return buckets.slice(-maxBuckets);
309
+ const result = [];
310
+ const lastIndex = buckets.length - 1;
311
+ for (let i = 0; i < maxBuckets; i++) {
312
+ const idx = Math.round((i * lastIndex) / (maxBuckets - 1));
313
+ const bucket = buckets[idx];
314
+ if (bucket && result[result.length - 1] !== bucket)
315
+ result.push(bucket);
316
+ }
317
+ return result;
318
+ }
319
+ function isTimelineBucket(value) {
320
+ if (!value || typeof value !== "object")
321
+ return false;
322
+ const data = value;
323
+ return typeof data.t === "number"
324
+ && typeof data.v === "number"
325
+ && typeof data.c === "number"
326
+ && typeof data.bi === "number"
327
+ && typeof data.bc === "number"
328
+ && typeof data.cta === "number";
329
+ }
330
+ function toMillis(value) {
331
+ if (typeof value === "number" && Number.isFinite(value))
332
+ return value;
333
+ if (value && typeof value === "object" && typeof value.toMillis === "function") {
334
+ return value.toMillis();
335
+ }
336
+ return null;
337
+ }
338
+ function parseTimestampCursor(cursor) {
339
+ const millis = Number(cursor);
340
+ if (Number.isFinite(millis) && millis > 0)
341
+ return Timestamp.fromMillis(millis);
342
+ const parsed = Date.parse(cursor);
343
+ if (Number.isFinite(parsed))
344
+ return Timestamp.fromMillis(parsed);
345
+ throw new Error("Invalid cursor. Use the nextCursor value returned by the previous page.");
346
+ }
347
+ function timestampCursorFrom(value) {
348
+ const millis = toMillis(value);
349
+ return millis == null ? null : String(millis);
350
+ }
351
+ function readNmoCriteria(raw) {
352
+ const data = (raw && typeof raw === "object" ? raw : {});
353
+ return {
354
+ minWatchTimeRatio: typeof data.minWatchTimeRatio === "number" ? data.minWatchTimeRatio : 0.8,
355
+ minCheckpointRatio: typeof data.minCheckpointRatio === "number" ? data.minCheckpointRatio : 0.8,
356
+ };
357
+ }
358
+ function checkpointResponseInWindow(responses, startMs, endMs) {
359
+ return Object.values(responses).some((response) => {
360
+ const ts = typeof response.respondedAt === "number" ? response.respondedAt : response.shownAt;
361
+ return typeof ts === "number" && ts >= startMs - 5_000 && ts <= endMs + 60_000;
362
+ });
363
+ }
364
+ async function buildNmoEvaluation({ eventId, sessionId, startMs, endMs, durationSec, baselineEngagement, finalEngagement, }) {
365
+ const eventSnap = await getDoc(doc(firestore, "events", eventId));
366
+ const event = eventSnap.data() ?? {};
367
+ if (event.nmoMode !== true)
368
+ return null;
369
+ const criteria = readNmoCriteria(event.nmoCriteria);
370
+ const [participantsSnap, checkpointsSnap, windowsSnap] = await Promise.all([
371
+ getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), orderBy("fullName"))),
372
+ get(ref(rtdb, `events/${eventId}/checkpoints`)),
373
+ get(ref(rtdb, `events/${eventId}/checkpointWindows`)),
374
+ ]);
375
+ const participants = participantsSnap.docs.map((item) => ({ uid: item.id, ...item.data() }));
376
+ const checkpoints = (checkpointsSnap.val() ?? {});
377
+ const windows = (windowsSnap.val() ?? {});
378
+ let checkpointIds = Object.entries(windows)
379
+ .filter(([, window]) => {
380
+ if (window.sessionId)
381
+ return window.sessionId === sessionId;
382
+ return typeof window.startedAt === "number" && startMs != null && window.startedAt >= startMs && window.startedAt <= endMs;
383
+ })
384
+ .map(([id]) => id);
385
+ if (checkpointIds.length === 0 && startMs != null) {
386
+ checkpointIds = Object.entries(checkpoints)
387
+ .filter(([, responses]) => checkpointResponseInWindow(responses, startMs, endMs))
388
+ .map(([id]) => id);
389
+ }
390
+ checkpointIds = [...new Set(checkpointIds)].sort();
391
+ const totalCheckpoints = checkpointIds.length;
392
+ const rows = participants.map((participant) => {
393
+ const uid = safeString(participant.uid);
394
+ const watchSec = Math.max(0, (finalEngagement[uid] ?? 0) - (baselineEngagement[uid] ?? 0));
395
+ const watchRatio = durationSec > 0 ? watchSec / durationSec : 0;
396
+ let confirmedCount = 0;
397
+ let reactionSum = 0;
398
+ let reactionCount = 0;
399
+ for (const checkpointId of checkpointIds) {
400
+ const response = checkpoints[checkpointId]?.[uid];
401
+ if (response?.status === "confirmed" || response?.status === "auto_confirmed")
402
+ confirmedCount++;
403
+ if (typeof response?.reactionMs === "number" && response.reactionMs > 0) {
404
+ reactionSum += response.reactionMs;
405
+ reactionCount++;
406
+ }
407
+ }
408
+ const checkpointRatio = totalCheckpoints > 0 ? confirmedCount / totalCheckpoints : 1;
409
+ const avgReactionSec = reactionCount > 0 ? Math.round((reactionSum / reactionCount) / 100) / 10 : null;
410
+ const status = watchSec === 0
411
+ ? "noShow"
412
+ : watchRatio >= criteria.minWatchTimeRatio && checkpointRatio >= criteria.minCheckpointRatio
413
+ ? "passed"
414
+ : "failed";
415
+ return {
416
+ uid,
417
+ email: safeString(participant.email),
418
+ fullName: safeString(participant.fullName),
419
+ ...(safeString(participant.position) && { position: safeString(participant.position) }),
420
+ ...(safeString(participant.org) && { org: safeString(participant.org) }),
421
+ watchSec,
422
+ watchRatio,
423
+ confirmedCount,
424
+ totalCheckpoints,
425
+ checkpointRatio,
426
+ avgReactionSec,
427
+ status,
428
+ };
429
+ });
430
+ return {
431
+ generatedAtMs: endMs,
432
+ source: "sessionSnapshotV1",
433
+ criteria,
434
+ sessionDurationSec: durationSec,
435
+ checkpointIds,
436
+ summary: {
437
+ passed: rows.filter((row) => row.status === "passed").length,
438
+ failed: rows.filter((row) => row.status === "failed").length,
439
+ noShow: rows.filter((row) => row.status === "noShow").length,
440
+ total: rows.length,
441
+ },
442
+ rows,
443
+ };
444
+ }
445
+ function compactAnalyticsSession(raw, options) {
446
+ const session = serialize(raw);
447
+ const timeline = Array.isArray(session.timeline) ? session.timeline : null;
448
+ if (timeline) {
449
+ session.timelineBucketCount = timeline.length;
450
+ if (options.includeTimeline) {
451
+ const page = timeline.slice(-options.timelineLimit);
452
+ session.timeline = page;
453
+ session.timelineTruncated = timeline.length > page.length;
454
+ session.timelineReturned = page.length;
455
+ }
456
+ else {
457
+ delete session.timeline;
458
+ session.timelineOmitted = true;
459
+ }
460
+ }
461
+ if (!options.includeSnapshots) {
462
+ if (session.baselineSnapshot !== undefined) {
463
+ delete session.baselineSnapshot;
464
+ session.baselineSnapshotOmitted = true;
465
+ }
466
+ if (session.finalSnapshot !== undefined) {
467
+ delete session.finalSnapshot;
468
+ session.finalSnapshotOmitted = true;
469
+ }
470
+ }
471
+ const nmo = session.nmoEvaluation;
472
+ if (!options.includeNmoRows && nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
473
+ const nmoRecord = nmo;
474
+ if (Array.isArray(nmoRecord.rows)) {
475
+ nmoRecord.rowsCount = nmoRecord.rows.length;
476
+ delete nmoRecord.rows;
477
+ nmoRecord.rowsOmitted = true;
478
+ }
479
+ }
480
+ return session;
481
+ }
482
+ export function registerTools(server) {
483
+ server.registerTool("oko_whoami", {
484
+ title: "Who am I",
485
+ description: toolDescription({
486
+ summary: "Return the authenticated OKO organizer uid and granted MCP scopes.",
487
+ returns: "uid plus the exact scopes granted by OKO API key exchange. Use this before write workflows to check permissions.",
488
+ examples: ['Use when starting a session: call first, then choose tools allowed by "scopes".'],
489
+ }),
490
+ outputSchema: {
491
+ uid: z.string(),
492
+ scopes: z.array(z.string()),
493
+ },
494
+ annotations: readOnlyTool,
495
+ }, async () => {
496
+ const auth = await authed([]);
497
+ return textResult({ uid: auth.uid, scopes: currentScopes() });
498
+ });
499
+ server.registerTool("oko_list_workspaces", {
500
+ title: "List workspaces",
501
+ description: toolDescription({
502
+ summary: "List OKO workspaces visible to the authenticated organizer.",
503
+ args: ["includeShared: include workspaces shared with this organizer, default true."],
504
+ returns: "workspaces array with id, ownership flag, and permissions for shared workspaces, plus count.",
505
+ examples: ["Use before oko_create_webinar when the user wants to create a webinar in a shared workspace."],
506
+ }),
507
+ inputSchema: { includeShared: z.boolean().default(true).describe("Include workspaces shared with this organizer.") },
508
+ outputSchema: workspacesOutputSchema,
509
+ annotations: readOnlyTool,
510
+ }, async ({ includeShared }) => {
511
+ const { uid } = await authed(["workspace:read"]);
512
+ const out = [];
513
+ const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
514
+ ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
515
+ if (includeShared) {
516
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
517
+ const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
518
+ for (const workspaceId of sharedIds) {
519
+ if (out.some((row) => typeof row === "object" && row !== null && row.id === workspaceId))
520
+ continue;
521
+ const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
522
+ const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
523
+ if (wsSnap.exists())
524
+ out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
525
+ }
526
+ }
527
+ return textResult({ workspaces: out, count: out.length });
528
+ });
529
+ server.registerTool("oko_list_my_events", {
530
+ title: "List my events",
531
+ description: toolDescription({
532
+ summary: "List OKO events visible to the authenticated organizer.",
533
+ args: [
534
+ "limit: maximum events to return, default 30, max 100.",
535
+ "cursor: timestamp cursor returned as nextCursor by the previous page.",
536
+ "workspaceId: optional client-side workspace filter.",
537
+ ],
538
+ returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
539
+ examples: ["Use limit=10 first; if hasMore is true, call again with cursor=nextCursor."],
540
+ }),
541
+ inputSchema: {
542
+ limit: limitField(),
543
+ cursor: cursorField,
544
+ workspaceId: z.string().optional().describe("Optional workspace id to filter returned events."),
545
+ },
546
+ outputSchema: eventsOutputSchema,
547
+ annotations: readOnlyTool,
548
+ }, async (args) => {
549
+ const { uid } = await authed(["events:read"]);
550
+ const constraints = [
551
+ where("accessibleBy", "array-contains", uid),
552
+ orderBy("createdAt", "desc"),
553
+ limit(args.limit + 1),
554
+ ];
555
+ if (args.cursor)
556
+ constraints.splice(2, 0, startAfter(parseTimestampCursor(args.cursor)));
557
+ const snap = await getDocs(query(collection(firestore, "events"), ...constraints));
558
+ const { page, hasMore } = pickPage(snap.docs, args.limit);
559
+ let events = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
560
+ if (args.workspaceId)
561
+ events = events.filter((event) => event.workspaceId === args.workspaceId);
562
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
563
+ return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
564
+ });
565
+ server.registerTool("oko_get_event", {
566
+ title: "Get event",
567
+ description: toolDescription({
568
+ summary: "Read one OKO event visible to the organizer.",
569
+ args: ["eventId: OKO event document id."],
570
+ returns: "full event document fields plus id and public event URL.",
571
+ examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
572
+ }),
573
+ inputSchema: { eventId: eventIdField },
574
+ outputSchema: eventOutputSchema,
575
+ annotations: readOnlyTool,
576
+ }, async ({ eventId }) => {
577
+ const { uid } = await authed(["events:read"]);
578
+ const data = await readEvent(eventId, uid, "read");
579
+ return textResult({ id: eventId, ...serialize(data), url: eventUrl(eventId) });
580
+ });
581
+ server.registerTool("oko_create_webinar", {
582
+ title: "Create webinar",
583
+ description: toolDescription({
584
+ summary: "Create an OKO webinar and its required RTDB owner/workspace mirror.",
585
+ args: [
586
+ "title: webinar title.",
587
+ "workspaceId: optional workspace id; omitted means personal workspace.",
588
+ "videoUrl: optional iframe/source/player URL; normalized by OKO.",
589
+ "eventDate: optional ISO datetime.",
590
+ "chatEnabled: whether the chat block is enabled at creation.",
591
+ ],
592
+ returns: "new event id, public event URL, and workspaceId.",
593
+ examples: ["Use for a complete create flow, then call oko_configure_blocks and oko_get_event."],
594
+ notes: ["This creates an event page; OKO has no draft/live/ended event status."],
595
+ }),
596
+ inputSchema: {
597
+ title: z.string().min(1).max(160).describe("Webinar title shown on the public event page."),
598
+ workspaceId: z.string().optional().describe("Workspace id; omit for the organizer's personal workspace."),
599
+ videoUrl: z.string().nullable().optional().describe("Optional source/player/iframe URL to normalize."),
600
+ eventDate: z.string().datetime().nullable().optional().describe("Optional webinar datetime in ISO format."),
601
+ speakerName: z.string().max(160).nullable().optional().describe("Optional speaker display name."),
602
+ description: z.string().max(3000).nullable().optional().describe("Optional event description."),
603
+ chatEnabled: z.boolean().default(false).describe("Whether the chat block starts enabled."),
604
+ },
605
+ outputSchema: createWebinarOutputSchema,
606
+ annotations: writeTool,
607
+ }, async (args) => {
608
+ const { uid } = await authed(["events:write"]);
609
+ const workspaceId = args.workspaceId ?? uid;
610
+ const access = await buildWorkspaceAccess(uid, workspaceId);
611
+ const video = normalizeVideo(args.videoUrl);
612
+ const docRef = await addDoc(collection(firestore, "events"), {
613
+ ownerId: uid,
614
+ workspaceId,
615
+ accessibleBy: access.accessibleBy,
616
+ editors: access.editors,
617
+ createdAt: serverTimestamp(),
618
+ title: args.title.trim(),
619
+ pinned: false,
620
+ video,
621
+ meta: {
622
+ eventDate: args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null,
623
+ speakerName: args.speakerName?.trim() || null,
624
+ description: args.description?.trim() || null,
625
+ },
626
+ blocks: {
627
+ chat: { enabled: args.chatEnabled, visibility: "always" },
628
+ promoBanner: { enabled: false, text: "", link: "", visibility: "always" },
629
+ },
630
+ });
631
+ await update(ref(rtdb, `events/${docRef.id}`), {
632
+ ownerId: uid,
633
+ workspaceId,
634
+ });
635
+ return textResult({ id: docRef.id, url: eventUrl(docRef.id), workspaceId });
636
+ });
637
+ server.registerTool("oko_update_event", {
638
+ title: "Update event metadata",
639
+ description: toolDescription({
640
+ summary: "Update safe top-level event/setup metadata fields.",
641
+ args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned."],
642
+ returns: "event id and the list of Firestore fields updated.",
643
+ examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for block/form/NMO setup."],
644
+ }),
645
+ inputSchema: {
646
+ eventId: eventIdField,
647
+ title: z.string().min(1).max(160).optional().describe("New webinar title."),
648
+ videoUrl: z.string().nullable().optional().describe("New source/player/iframe URL, or null to clear."),
649
+ eventDate: z.string().datetime().nullable().optional().describe("New ISO datetime, or null to clear."),
650
+ speakerName: z.string().max(160).nullable().optional().describe("New speaker display name, or null to clear."),
651
+ description: z.string().max(3000).nullable().optional().describe("New description, or null to clear."),
652
+ pinned: z.boolean().optional().describe("Whether this event is pinned in the dashboard."),
653
+ },
654
+ outputSchema: updateOutputSchema,
655
+ annotations: writeTool,
656
+ }, async (args) => {
657
+ const { uid } = await authed(["events:write", "setup:write"]);
658
+ const data = await readEvent(args.eventId, uid, "edit");
659
+ const patch = {};
660
+ if (args.title !== undefined)
661
+ patch.title = args.title.trim();
662
+ if (args.videoUrl !== undefined) {
663
+ const video = normalizeVideo(args.videoUrl);
664
+ patch["video.originalUrl"] = video.originalUrl;
665
+ patch["video.embedUrl"] = video.embedUrl;
666
+ }
667
+ if (args.eventDate !== undefined)
668
+ patch["meta.eventDate"] = args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null;
669
+ if (args.speakerName !== undefined)
670
+ patch["meta.speakerName"] = args.speakerName?.trim() || null;
671
+ if (args.description !== undefined)
672
+ patch["meta.description"] = args.description?.trim() || null;
673
+ if (args.pinned !== undefined)
674
+ patch.pinned = args.pinned;
675
+ await updateDoc(doc(firestore, "events", args.eventId), patch);
676
+ await update(ref(rtdb, `events/${args.eventId}`), {
677
+ ownerId: safeString(data.ownerId, uid),
678
+ workspaceId: safeString(data.workspaceId, uid),
679
+ });
680
+ return textResult({ id: args.eventId, updated: Object.keys(patch) });
681
+ });
682
+ server.registerTool("oko_configure_blocks", {
683
+ title: "Configure event blocks",
684
+ description: toolDescription({
685
+ summary: "Configure supported event setup blocks, forms, Telegram channel ids, and NMO mode.",
686
+ args: [
687
+ "eventId: event to edit.",
688
+ "chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
689
+ "forms: replaces configured lead forms when provided.",
690
+ "telegramChannelIds: replaces configured Telegram channels when provided.",
691
+ "nmo: optional NMO mode and criteria patch.",
692
+ ],
693
+ returns: "event id and the list of Firestore fields updated.",
694
+ examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, or NMO settings."],
695
+ }),
696
+ inputSchema: {
697
+ eventId: eventIdField,
698
+ chat: z.object({ enabled: z.boolean().optional(), visibility: z.enum(["always", "hidden"]).optional() }).optional(),
699
+ promoBanner: z.object({ enabled: z.boolean().optional(), link: z.string().max(1000).optional(), visibility: z.enum(["always", "hidden"]).optional(), formId: z.string().nullable().optional() }).optional(),
700
+ primaryCta: z.object({ enabled: z.boolean().optional(), visibility: z.enum(["always", "hidden"]).optional(), title: z.string().nullable().optional(), text: z.string().nullable().optional(), buttonLabel: z.string().nullable().optional(), buttonUrl: z.string().nullable().optional(), formId: z.string().nullable().optional() }).optional(),
701
+ videoPlaceholder: z.object({ enabled: z.boolean().optional(), mode: z.enum(["glass", "image"]).optional(), title: z.string().nullable().optional(), subtitle: z.string().nullable().optional(), showTimer: z.boolean().optional() }).optional(),
702
+ forms: z.array(formSchema).max(20).optional(),
703
+ telegramChannelIds: z.array(z.number().int()).max(20).optional(),
704
+ nmo: z.object({
705
+ enabled: z.boolean().optional(),
706
+ minWatchTimeRatio: z.number().min(0).max(1).optional(),
707
+ minCheckpointRatio: z.number().min(0).max(1).optional(),
708
+ extraPosition: z.boolean().optional(),
709
+ extraOrg: z.boolean().optional(),
710
+ }).optional(),
711
+ },
712
+ outputSchema: updateOutputSchema,
713
+ annotations: writeTool,
714
+ }, async (args) => {
715
+ const { uid } = await authed(["setup:write"]);
716
+ await readEvent(args.eventId, uid, "edit");
717
+ const patch = {};
718
+ if (args.chat?.enabled !== undefined)
719
+ patch["blocks.chat.enabled"] = args.chat.enabled;
720
+ if (args.chat?.visibility !== undefined)
721
+ patch["blocks.chat.visibility"] = args.chat.visibility;
722
+ if (args.promoBanner?.enabled !== undefined)
723
+ patch["blocks.promoBanner.enabled"] = args.promoBanner.enabled;
724
+ if (args.promoBanner?.link !== undefined)
725
+ patch["blocks.promoBanner.link"] = args.promoBanner.link;
726
+ if (args.promoBanner?.visibility !== undefined)
727
+ patch["blocks.promoBanner.visibility"] = args.promoBanner.visibility;
728
+ if (args.promoBanner?.formId !== undefined)
729
+ patch["blocks.promoBanner.formId"] = args.promoBanner.formId;
730
+ if (args.primaryCta?.enabled !== undefined)
731
+ patch["blocks.primaryCta.enabled"] = args.primaryCta.enabled;
732
+ if (args.primaryCta?.visibility !== undefined)
733
+ patch["blocks.primaryCta.visibility"] = args.primaryCta.visibility;
734
+ if (args.primaryCta?.title !== undefined)
735
+ patch["blocks.primaryCta.title"] = args.primaryCta.title?.trim() || null;
736
+ if (args.primaryCta?.text !== undefined)
737
+ patch["blocks.primaryCta.text"] = args.primaryCta.text?.trim() || null;
738
+ if (args.primaryCta?.buttonLabel !== undefined)
739
+ patch["blocks.primaryCta.buttonLabel"] = args.primaryCta.buttonLabel?.trim() || null;
740
+ if (args.primaryCta?.buttonUrl !== undefined)
741
+ patch["blocks.primaryCta.buttonUrl"] = args.primaryCta.buttonUrl?.trim() || null;
742
+ if (args.primaryCta?.formId !== undefined)
743
+ patch["blocks.primaryCta.formId"] = args.primaryCta.formId;
744
+ if (args.videoPlaceholder?.enabled !== undefined)
745
+ patch["blocks.video.placeholder.enabled"] = args.videoPlaceholder.enabled;
746
+ if (args.videoPlaceholder?.mode !== undefined)
747
+ patch["blocks.video.placeholder.mode"] = args.videoPlaceholder.mode;
748
+ if (args.videoPlaceholder?.title !== undefined)
749
+ patch["blocks.video.placeholder.title"] = args.videoPlaceholder.title?.trim() || null;
750
+ if (args.videoPlaceholder?.subtitle !== undefined)
751
+ patch["blocks.video.placeholder.subtitle"] = args.videoPlaceholder.subtitle?.trim() || null;
752
+ if (args.videoPlaceholder?.showTimer !== undefined)
753
+ patch["blocks.video.placeholder.showTimer"] = args.videoPlaceholder.showTimer;
754
+ if (args.forms !== undefined)
755
+ patch.forms = args.forms.map((form) => ({
756
+ id: form.id ?? randomId(),
757
+ type: "lead",
758
+ title: form.title?.trim() || null,
759
+ description: form.description?.trim() || null,
760
+ submitLabel: form.submitLabel?.trim() || null,
761
+ successText: form.successText?.trim() || null,
762
+ consentText: form.consentText?.trim() || null,
763
+ consentRequired: form.consentRequired !== false,
764
+ fields: form.fields.map((field) => ({
765
+ id: field.id ?? randomId(),
766
+ label: field.label.trim(),
767
+ type: field.type,
768
+ required: field.required === true,
769
+ placeholder: field.placeholder?.trim() || null,
770
+ })),
771
+ }));
772
+ if (args.telegramChannelIds !== undefined) {
773
+ patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
774
+ patch["telegram.channelIds"] = args.telegramChannelIds;
775
+ patch["telegram.channelId"] = args.telegramChannelIds[0] ?? null;
776
+ }
777
+ if (args.nmo?.enabled !== undefined)
778
+ patch.nmoMode = args.nmo.enabled;
779
+ if (args.nmo?.minWatchTimeRatio !== undefined)
780
+ patch["nmoCriteria.minWatchTimeRatio"] = args.nmo.minWatchTimeRatio;
781
+ if (args.nmo?.minCheckpointRatio !== undefined)
782
+ patch["nmoCriteria.minCheckpointRatio"] = args.nmo.minCheckpointRatio;
783
+ if (args.nmo?.extraPosition !== undefined)
784
+ patch["nmoExtraFields.position"] = args.nmo.extraPosition;
785
+ if (args.nmo?.extraOrg !== undefined)
786
+ patch["nmoExtraFields.org"] = args.nmo.extraOrg;
787
+ await updateDoc(doc(firestore, "events", args.eventId), patch);
788
+ return textResult({ id: args.eventId, updated: Object.keys(patch) });
789
+ });
790
+ server.registerTool("oko_delete_event", {
791
+ title: "Delete event",
792
+ description: toolDescription({
793
+ summary: "Delete an OKO event document.",
794
+ args: ["eventId: event to delete.", "confirm: must be true."],
795
+ returns: "deleted event id.",
796
+ examples: ["Use only after the user explicitly confirms the exact event title/id."],
797
+ notes: ["Destructive. Does not control stream state; it deletes the event record."],
798
+ }),
799
+ inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true for deletion.") },
800
+ outputSchema: z.object({ deleted: z.string() }).passthrough(),
801
+ annotations: destructiveTool,
802
+ }, async ({ eventId, confirm }) => {
803
+ const { uid } = await authed(["events:delete"]);
804
+ const data = await readEvent(eventId, uid, "edit");
805
+ requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
806
+ await deleteDoc(doc(firestore, "events", eventId));
807
+ return textResult({ deleted: eventId });
808
+ });
809
+ server.registerTool("oko_get_live_state", {
810
+ title: "Get live state",
811
+ description: toolDescription({
812
+ summary: "Read main RTDB live controls, aggregate analytics, and chat settings for an event.",
813
+ args: ["eventId: event to inspect."],
814
+ returns: "eventId, agg metrics, ctrl live overrides, and chat settings.",
815
+ examples: ["Use during a webinar before changing live block visibility or starting/ending sessions."],
816
+ }),
817
+ inputSchema: { eventId: eventIdField },
818
+ outputSchema: liveStateOutputSchema,
819
+ annotations: readOnlyTool,
820
+ }, async ({ eventId }) => {
821
+ const { uid } = await authed(["analytics:read"]);
822
+ await readEvent(eventId, uid, "read");
823
+ const [aggSnap, ctrlSnap, chatSettingsSnap] = await Promise.all([
824
+ get(ref(rtdb, `events/${eventId}/agg`)),
825
+ get(ref(rtdb, `events/${eventId}/ctrl`)),
826
+ get(ref(chatRtdb, `settings/${eventId}`)),
827
+ ]);
828
+ return textResult({ eventId, agg: aggSnap.val() ?? {}, ctrl: ctrlSnap.val() ?? {}, chat: chatSettingsSnap.val() ?? {} });
829
+ });
830
+ server.registerTool("oko_set_block_visibility", {
831
+ title: "Set live block visibility",
832
+ description: toolDescription({
833
+ summary: "Set or reset a live RTDB visibility override for a public event block.",
834
+ args: ["eventId: event to control.", "block: chat, banner, cta, cards, or placeholder.", "visible: true/false, or null to reset override."],
835
+ returns: "eventId, block, and effective override value.",
836
+ examples: ["Use visible=false to hide the CTA live; use visible=null to return to setup defaults."],
837
+ }),
838
+ inputSchema: {
839
+ eventId: eventIdField,
840
+ block: z.enum(["chat", "banner", "cta", "cards", "placeholder"]).describe("Public block whose live override should change."),
841
+ visible: boolOrNull.describe("true/false to force visibility, null to reset override."),
842
+ },
843
+ outputSchema: basicEventOutputSchema,
844
+ annotations: idempotentWriteTool,
845
+ }, async ({ eventId, block, visible }) => {
846
+ const { uid } = await authed(["live:control"]);
847
+ await readEvent(eventId, uid, "edit");
848
+ const paths = {
849
+ chat: "chatVisible",
850
+ banner: "bannerVisible",
851
+ cta: "ctaVisible",
852
+ cards: "cardsVisible",
853
+ placeholder: "placeholderVisible",
854
+ };
855
+ const target = ref(rtdb, `events/${eventId}/ctrl/${paths[block]}`);
856
+ if (visible === null)
857
+ await remove(target);
858
+ else
859
+ await set(target, visible);
860
+ return textResult({ eventId, block, visible });
861
+ });
862
+ server.registerTool("oko_refresh_viewers", {
863
+ title: "Refresh viewers",
864
+ description: toolDescription({
865
+ summary: "Trigger a live viewer refresh tick for an event.",
866
+ args: ["eventId: event whose viewers should refresh."],
867
+ returns: "eventId and refreshed=true.",
868
+ examples: ["Use after a setup/live change if viewers should reload state immediately."],
869
+ }),
870
+ inputSchema: { eventId: eventIdField },
871
+ outputSchema: basicEventOutputSchema,
872
+ annotations: writeTool,
873
+ }, async ({ eventId }) => {
874
+ const { uid } = await authed(["live:control"]);
875
+ await readEvent(eventId, uid, "edit");
876
+ await set(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), Date.now());
877
+ return textResult({ eventId, refreshed: true });
878
+ });
879
+ server.registerTool("oko_start_session", {
880
+ title: "Start analytics session",
881
+ description: toolDescription({
882
+ summary: "Start an analytics session using the same Firestore/RTDB pointers as the dashboard.",
883
+ args: ["eventId: event whose analytics session should start."],
884
+ returns: "eventId, new sessionId, and started=true.",
885
+ examples: ["Use when the organizer wants analytics tracking to begin; it does not start the video stream."],
886
+ notes: ["OKO Start/End controls analytics sessions only, not stream publishing."],
887
+ }),
888
+ inputSchema: { eventId: eventIdField },
889
+ outputSchema: basicEventOutputSchema,
890
+ annotations: writeTool,
891
+ }, async ({ eventId }) => {
892
+ const { uid } = await authed(["analytics:write", "live:control"]);
893
+ const event = await readEvent(eventId, uid, "edit");
894
+ if (event.activeAnalyticsSessionId)
895
+ throw new Error("An analytics session is already active.");
896
+ let sessionId = null;
897
+ try {
898
+ const agg = await readAgg(eventId);
899
+ const sessionRef = await addDoc(collection(firestore, "events", eventId, "analyticsSessions"), {
900
+ startedAt: serverTimestamp(),
901
+ endedAt: null,
902
+ });
903
+ sessionId = sessionRef.id;
904
+ const updates = {
905
+ "ctrl/activeSessionId": sessionId,
906
+ [`sessions/${sessionId}/startedAt`]: Date.now(),
907
+ [`sessions/${sessionId}/baseline/chatMessages`]: agg.chatMessages,
908
+ [`sessions/${sessionId}/baseline/bannerClicks`]: agg.bannerClicks,
909
+ [`sessions/${sessionId}/baseline/bannerImpressions`]: agg.bannerImpressions,
910
+ [`sessions/${sessionId}/baseline/ctaClicks`]: agg.ctaClicks,
911
+ [`sessions/${sessionId}/baseline/cardsClicks`]: agg.cardsClicks,
912
+ [`sessions/${sessionId}/baseline/viewCount`]: agg.viewCount,
913
+ [`sessions/${sessionId}/baseline/viewSumSec`]: agg.viewSumSec,
914
+ [`sessions/${sessionId}/baseline/online`]: agg.online,
915
+ [`sessions/${sessionId}/baseline/uniqueVisitors`]: agg.uniqueVisitors,
916
+ [`sessions/${sessionId}/peak`]: agg.online,
917
+ };
918
+ for (const [bucket, count] of Object.entries(agg.viewHist)) {
919
+ updates[`sessions/${sessionId}/baseline/viewHist/${bucket}`] = count;
920
+ }
921
+ for (const [cityKey, count] of Object.entries(agg.geo ?? {})) {
922
+ updates[`sessions/${sessionId}/baseline/geo/${cityKey}`] = count;
923
+ }
924
+ for (const [cardId, clicks] of Object.entries(agg.cardsById ?? {})) {
925
+ updates[`sessions/${sessionId}/baseline/cardsById/${cardId}`] = clicks;
926
+ }
927
+ for (const [ctaId, clicks] of Object.entries(agg.ctaById ?? {})) {
928
+ updates[`sessions/${sessionId}/baseline/ctaById/${ctaId}`] = clicks;
929
+ }
930
+ await update(ref(rtdb, `events/${eventId}`), updates);
931
+ try {
932
+ const engagementSnap = await get(ref(rtdb, `events/${eventId}/engagement`));
933
+ const engagementNow = numberRecord(engagementSnap.val());
934
+ const engagementUpdates = {};
935
+ for (const [viewerId, seconds] of Object.entries(engagementNow)) {
936
+ engagementUpdates[`sessions/${sessionId}/baseline/engagement/${viewerId}`] = seconds;
937
+ }
938
+ if (Object.keys(engagementUpdates).length > 0) {
939
+ await update(ref(rtdb, `events/${eventId}`), engagementUpdates);
940
+ }
941
+ }
942
+ catch {
943
+ // Non-fatal: dashboard behaves the same; avg/median are skipped if baseline is unavailable.
944
+ }
945
+ await updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: sessionId });
946
+ try {
947
+ await set(ref(rtdb, `activeSessions/${sessionId}`), { eventId, startedAt: Date.now() });
948
+ }
949
+ catch {
950
+ // Non-fatal: client-side sampler can still write timeline, and CF stale cleanup is best-effort.
951
+ }
952
+ return textResult({ eventId, sessionId, started: true });
953
+ }
954
+ catch (err) {
955
+ if (sessionId) {
956
+ await Promise.allSettled([
957
+ remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
958
+ remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
959
+ remove(ref(rtdb, `activeSessions/${sessionId}`)),
960
+ updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
961
+ deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)),
962
+ ]);
963
+ }
964
+ const msg = err instanceof Error ? err.message : String(err);
965
+ throw new Error(`Start failed: ${msg}`);
966
+ }
967
+ });
968
+ server.registerTool("oko_end_session", {
969
+ title: "End analytics session",
970
+ description: toolDescription({
971
+ summary: "End the active analytics session and persist the session snapshot.",
972
+ args: ["eventId: event with an active analytics session.", "confirm: must be true."],
973
+ returns: "eventId, sessionId, and ended=true.",
974
+ examples: ["Use after the webinar analytics window is over and the user confirms the exact event."],
975
+ notes: ["Destructive for the active session pointer, but it does not stop the video stream."],
976
+ }),
977
+ inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true for ending the active session.") },
978
+ outputSchema: basicEventOutputSchema,
979
+ annotations: destructiveTool,
980
+ }, async ({ eventId, confirm }) => {
981
+ const { uid } = await authed(["analytics:write", "live:control"]);
982
+ const event = await readEvent(eventId, uid, "edit");
983
+ const sessionId = safeString(event.activeAnalyticsSessionId);
984
+ if (!sessionId)
985
+ throw new Error("No active analytics session.");
986
+ requireConfirm(confirm, "oko_end_session", { eventId, sessionId });
987
+ const sessionDocRef = doc(firestore, "events", eventId, "analyticsSessions", sessionId);
988
+ const [finalAgg, baselineSnap, sessionSnap, firestoreSessionSnap] = await Promise.all([
989
+ readAgg(eventId),
990
+ get(ref(rtdb, `events/${eventId}/sessions/${sessionId}/baseline`)),
991
+ get(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
992
+ getDoc(sessionDocRef),
993
+ ]);
994
+ if (!baselineSnap.exists()) {
995
+ throw new Error("Session baseline is missing; clear the stuck session and start a fresh session.");
996
+ }
997
+ const baseRaw = (baselineSnap.val() ?? {});
998
+ const baseline = {
999
+ chatMessages: Number(baseRaw.chatMessages ?? 0),
1000
+ bannerClicks: Number(baseRaw.bannerClicks ?? 0),
1001
+ bannerImpressions: Number(baseRaw.bannerImpressions ?? 0),
1002
+ ctaClicks: Number(baseRaw.ctaClicks ?? 0),
1003
+ cardsClicks: Number(baseRaw.cardsClicks ?? 0),
1004
+ viewCount: Number(baseRaw.viewCount ?? 0),
1005
+ viewSumSec: Number(baseRaw.viewSumSec ?? 0),
1006
+ viewHist: numberRecord(baseRaw.viewHist),
1007
+ online: Number(baseRaw.online ?? 0),
1008
+ uniqueVisitors: Number(baseRaw.uniqueVisitors ?? 0),
1009
+ geo: numberRecord(baseRaw.geo),
1010
+ cardsById: numberRecord(baseRaw.cardsById),
1011
+ ctaById: numberRecord(baseRaw.ctaById),
1012
+ };
1013
+ const session = (sessionSnap.val() ?? {});
1014
+ const timelineRaw = (session.timeline ?? {});
1015
+ const rawSessionTimeline = Object.values(timelineRaw).filter(isTimelineBucket).sort((a, b) => a.t - b.t);
1016
+ const sessionTimeline = downsampleTimeline(rawSessionTimeline);
1017
+ const startMs = toMillis(firestoreSessionSnap.data()?.startedAt) ?? toMillis(session.startedAt);
1018
+ const endMs = Date.now();
1019
+ const durationSec = startMs != null ? Math.max(0, Math.round((endMs - startMs) / 1000)) : 0;
1020
+ const viewCount = Math.max(0, finalAgg.viewCount - baseline.viewCount);
1021
+ const viewSumSec = Math.max(0, finalAgg.viewSumSec - baseline.viewSumSec);
1022
+ const bannerUniqueImpressions = Number(session.bannerUniqueImpressions ?? 0);
1023
+ const bannerUniqueClicks = Number(session.bannerUniqueClicks ?? 0);
1024
+ const bannerCtrDenominator = Math.max(bannerUniqueImpressions, bannerUniqueClicks);
1025
+ const bannerCtr = bannerCtrDenominator > 0
1026
+ ? Math.round((bannerUniqueClicks / bannerCtrDenominator) * 1000) / 10
1027
+ : null;
1028
+ let avgTimeSec = null;
1029
+ let medianTimeSec = null;
1030
+ let finalEngagement = {};
1031
+ let baselineEngagement = {};
1032
+ let engagementLoaded = false;
1033
+ try {
1034
+ const finalEngagementSnap = await get(ref(rtdb, `events/${eventId}/engagement`));
1035
+ finalEngagement = numberRecord(finalEngagementSnap.val());
1036
+ baselineEngagement = numberRecord(baseRaw.engagement);
1037
+ engagementLoaded = true;
1038
+ const sessionDurations = [];
1039
+ for (const [viewerId, finalSeconds] of Object.entries(finalEngagement)) {
1040
+ const delta = Math.max(0, finalSeconds - (baselineEngagement[viewerId] ?? 0));
1041
+ if (delta >= 30)
1042
+ sessionDurations.push(delta);
1043
+ }
1044
+ if (sessionDurations.length > 0) {
1045
+ avgTimeSec = Math.round(sessionDurations.reduce((sum, item) => sum + item, 0) / sessionDurations.length);
1046
+ const sorted = [...sessionDurations].sort((a, b) => a - b);
1047
+ const mid = Math.floor(sorted.length / 2);
1048
+ medianTimeSec = sorted.length % 2 === 0
1049
+ ? Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2)
1050
+ : sorted[mid] ?? null;
1051
+ }
1052
+ }
1053
+ catch {
1054
+ // Non-fatal: session can still be saved without avg/median.
1055
+ }
1056
+ const cardsBreakdown = {};
1057
+ for (const cardId of new Set([...Object.keys(finalAgg.cardsById ?? {}), ...Object.keys(baseline.cardsById ?? {})])) {
1058
+ const delta = Math.max(0, (finalAgg.cardsById?.[cardId] ?? 0) - (baseline.cardsById?.[cardId] ?? 0));
1059
+ if (delta > 0)
1060
+ cardsBreakdown[cardId] = delta;
1061
+ }
1062
+ const ctaBreakdown = {};
1063
+ for (const ctaId of new Set([...Object.keys(finalAgg.ctaById ?? {}), ...Object.keys(baseline.ctaById ?? {})])) {
1064
+ const delta = Math.max(0, (finalAgg.ctaById?.[ctaId] ?? 0) - (baseline.ctaById?.[ctaId] ?? 0));
1065
+ if (delta > 0)
1066
+ ctaBreakdown[ctaId] = delta;
1067
+ }
1068
+ const geoBreakdown = {};
1069
+ for (const cityKey of new Set([...Object.keys(finalAgg.geo ?? {}), ...Object.keys(baseline.geo ?? {})])) {
1070
+ const delta = Math.max(0, (finalAgg.geo?.[cityKey] ?? 0) - (baseline.geo?.[cityKey] ?? 0));
1071
+ if (delta > 0)
1072
+ geoBreakdown[cityKey] = delta;
1073
+ }
1074
+ let nmoEvaluation = null;
1075
+ if (engagementLoaded) {
1076
+ try {
1077
+ nmoEvaluation = await buildNmoEvaluation({
1078
+ eventId,
1079
+ sessionId,
1080
+ startMs,
1081
+ endMs,
1082
+ durationSec,
1083
+ baselineEngagement,
1084
+ finalEngagement,
1085
+ });
1086
+ }
1087
+ catch {
1088
+ // Non-fatal: dashboard treats NMO snapshot failure as warning-only.
1089
+ }
1090
+ }
1091
+ const finalSnapshot = {
1092
+ chatMessages: finalAgg.chatMessages,
1093
+ bannerClicks: finalAgg.bannerClicks,
1094
+ bannerImpressions: finalAgg.bannerImpressions,
1095
+ ctaClicks: finalAgg.ctaClicks,
1096
+ cardsClicks: finalAgg.cardsClicks,
1097
+ viewCount: finalAgg.viewCount,
1098
+ viewSumSec: finalAgg.viewSumSec,
1099
+ viewHist: finalAgg.viewHist,
1100
+ uniqueVisitors: finalAgg.uniqueVisitors,
1101
+ geo: finalAgg.geo ?? {},
1102
+ cardsById: finalAgg.cardsById ?? {},
1103
+ ctaById: finalAgg.ctaById ?? {},
1104
+ };
1105
+ const sessionUniqueVisitors = baseline.online + Number(session.uniqueVisitors ?? 0);
1106
+ await updateDoc(sessionDocRef, {
1107
+ endedAt: serverTimestamp(),
1108
+ durationSec,
1109
+ peakViewers: Number(session.peak ?? 0),
1110
+ uniqueVisitors: sessionUniqueVisitors,
1111
+ viewCount,
1112
+ viewSumSec,
1113
+ avgTimeSec,
1114
+ medianTimeSec,
1115
+ chatMessages: Math.max(0, finalAgg.chatMessages - baseline.chatMessages),
1116
+ bannerImpressions: Math.max(0, finalAgg.bannerImpressions - baseline.bannerImpressions),
1117
+ bannerUniqueImpressions,
1118
+ bannerClicks: Math.max(0, finalAgg.bannerClicks - baseline.bannerClicks),
1119
+ bannerUniqueClicks,
1120
+ bannerCtr,
1121
+ ctaClicks: Math.max(0, finalAgg.ctaClicks - baseline.ctaClicks),
1122
+ ctaUniqueClicks: Number(session.ctaUniqueClicks ?? 0),
1123
+ ctaBreakdown: Object.keys(ctaBreakdown).length > 0 ? ctaBreakdown : null,
1124
+ cardsClicks: Math.max(0, finalAgg.cardsClicks - baseline.cardsClicks),
1125
+ cardsUniqueClicks: Number(session.cardsUniqueClicks ?? 0),
1126
+ cardsBreakdown: Object.keys(cardsBreakdown).length > 0 ? cardsBreakdown : null,
1127
+ geo: Object.keys(geoBreakdown).length > 0 ? geoBreakdown : null,
1128
+ baselineSnapshot: cleanForFirestore(baseline),
1129
+ finalSnapshot: cleanForFirestore(finalSnapshot),
1130
+ timeline: sessionTimeline,
1131
+ timelineOriginalBucketCount: rawSessionTimeline.length,
1132
+ ...(nmoEvaluation ? { nmoEvaluation: cleanForFirestore(nmoEvaluation) } : {}),
1133
+ });
1134
+ await updateDoc(doc(firestore, "events", eventId), {
1135
+ activeAnalyticsSessionId: deleteField(),
1136
+ "analytics.lastSessionSummary": {
1137
+ sessionId,
1138
+ startedAt: firestoreSessionSnap.data()?.startedAt ?? null,
1139
+ endedAt: Timestamp.now(),
1140
+ peakViewers: session.peak ?? null,
1141
+ uniqueVisitors: sessionUniqueVisitors,
1142
+ viewCount,
1143
+ viewSumSec,
1144
+ },
1145
+ });
1146
+ await Promise.allSettled([
1147
+ remove(ref(rtdb, `activeSessions/${sessionId}`)),
1148
+ remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
1149
+ ]);
1150
+ return textResult({ eventId, sessionId, ended: true });
1151
+ });
1152
+ server.registerTool("oko_get_analytics", {
1153
+ title: "Get analytics sessions",
1154
+ description: toolDescription({
1155
+ summary: "Read paginated analytics session summaries for an event.",
1156
+ args: [
1157
+ "eventId: event to inspect.",
1158
+ "limit: max sessions to return, default 10, max 50.",
1159
+ "cursor: timestamp cursor returned as nextCursor by the previous page.",
1160
+ "includeTimeline: include recent timeline buckets; default false to keep context small.",
1161
+ "timelineLimit: max timeline buckets per session when includeTimeline=true.",
1162
+ "includeSnapshots: include baseline/final snapshots; default false.",
1163
+ "includeNmoRows: include NMO participant rows; default false.",
1164
+ ],
1165
+ returns: "sessions plus count, limit, hasMore/has_more, nextCursor/next_cursor. Heavy timeline/snapshot fields are omitted unless requested.",
1166
+ examples: ["Use default options for dashboard-like summaries; request includeTimeline only for chart/debug tasks."],
1167
+ }),
1168
+ inputSchema: {
1169
+ eventId: eventIdField,
1170
+ limit: limitField(50, ANALYTICS_DEFAULT_LIMIT),
1171
+ cursor: cursorField,
1172
+ includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets for each session."),
1173
+ timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets per session when includeTimeline=true."),
1174
+ includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
1175
+ includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
1176
+ },
1177
+ outputSchema: analyticsOutputSchema,
1178
+ annotations: readOnlyTool,
1179
+ }, async ({ eventId, limit: max, cursor, includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }) => {
1180
+ const { uid } = await authed(["analytics:read"]);
1181
+ await readEvent(eventId, uid, "read");
1182
+ const constraints = [
1183
+ orderBy("startedAt", "desc"),
1184
+ limit(max + 1),
1185
+ ];
1186
+ if (cursor)
1187
+ constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
1188
+ const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), ...constraints));
1189
+ const { page, hasMore } = pickPage(snap.docs, max);
1190
+ const sessions = page.map((item) => compactAnalyticsSession({ id: item.id, ...item.data() }, { includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }));
1191
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
1192
+ return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots/includeNmoRows disabled." });
1193
+ });
1194
+ server.registerTool("oko_list_recent_chat", {
1195
+ title: "List recent chat",
1196
+ description: toolDescription({
1197
+ summary: "Read recent chat messages from chat RTDB with cursor pagination.",
1198
+ args: [
1199
+ "eventId: event chat to inspect.",
1200
+ "limit: max messages to return, default 50, max 100.",
1201
+ "cursor: message id returned as nextCursor by the previous page to read older messages.",
1202
+ ],
1203
+ returns: "messages array sorted newest-first plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
1204
+ examples: ["Use limit=20 first; if hasMore is true, call again with cursor=nextCursor for older chat."],
1205
+ }),
1206
+ inputSchema: { eventId: eventIdField, limit: limitField(100, 50), cursor: cursorField },
1207
+ outputSchema: chatOutputSchema,
1208
+ annotations: readOnlyTool,
1209
+ }, async ({ eventId, limit: max, cursor }) => {
1210
+ const { uid } = await authed(["analytics:read"]);
1211
+ await readEvent(eventId, uid, "read");
1212
+ const constraints = cursor
1213
+ ? [orderByKey(), endBefore(cursor), limitToLast(max + 1)]
1214
+ : [orderByKey(), limitToLast(max + 1)];
1215
+ const snap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), ...constraints));
1216
+ const entries = Object.entries((snap.val() ?? {}))
1217
+ .sort(([a], [b]) => b.localeCompare(a));
1218
+ const { page, hasMore } = pickPage(entries, max);
1219
+ const messages = page.map(([id, message]) => ({ id, ...serialize(message) }));
1220
+ const nextCursor = hasMore ? messages.at(-1)?.id ?? null : null;
1221
+ return textResult({ messages, ...cursorMeta(messages.length, max, hasMore, nextCursor) });
1222
+ });
1223
+ server.registerTool("oko_send_chat_message", {
1224
+ title: "Send chat message",
1225
+ description: toolDescription({
1226
+ summary: "Send a chat message as the authenticated organizer/moderator.",
1227
+ args: ["eventId: target event chat.", "text: message text, max 500 chars.", "name: display name, default OKO MCP."],
1228
+ returns: "eventId, new messageId, and sent=true.",
1229
+ examples: ["Use for organizer announcements in chat after user confirms the exact text."],
1230
+ }),
1231
+ inputSchema: { eventId: eventIdField, text: z.string().min(1).max(500).describe("Message text."), name: z.string().min(1).max(24).default("OKO MCP").describe("Display name.") },
1232
+ outputSchema: basicEventOutputSchema,
1233
+ annotations: writeTool,
1234
+ }, async ({ eventId, text, name }) => {
1235
+ const { uid } = await authed(["chat:write"]);
1236
+ await readEvent(eventId, uid, "read");
1237
+ const role = await getChatRole(eventId, uid);
1238
+ const msgRef = push(ref(chatRtdb, `messages/${eventId}`));
1239
+ await set(msgRef, { text, uid, name, role, ts: rtdbServerTimestamp() });
1240
+ await runTransaction(ref(rtdb, `events/${eventId}/agg/chatMessages`), (n) => (n ?? 0) + 1);
1241
+ return textResult({ eventId, messageId: msgRef.key, sent: true });
1242
+ });
1243
+ server.registerTool("oko_delete_chat_message", {
1244
+ title: "Delete chat message",
1245
+ description: toolDescription({
1246
+ summary: "Soft-delete a chat message through moderation metadata.",
1247
+ args: ["eventId: target event.", "messageId: chat RTDB message id.", "confirm: must be true."],
1248
+ returns: "eventId, messageId, and deleted=true.",
1249
+ examples: ["Use only after showing the message text/id and receiving explicit confirmation."],
1250
+ }),
1251
+ inputSchema: { eventId: eventIdField, messageId: z.string().min(1).describe("Chat RTDB message id."), confirm: z.boolean().optional().describe("Required true for deletion.") },
1252
+ outputSchema: basicEventOutputSchema,
1253
+ annotations: destructiveTool,
1254
+ }, async ({ eventId, messageId, confirm }) => {
1255
+ const { uid } = await authed(["chat:moderate"]);
1256
+ await readEvent(eventId, uid, "edit");
1257
+ requireConfirm(confirm, "oko_delete_chat_message", { eventId, messageId });
1258
+ await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: rtdbServerTimestamp(), ts: Date.now(), by: uid });
1259
+ return textResult({ eventId, messageId, deleted: true });
1260
+ });
1261
+ server.registerTool("oko_mute_user", {
1262
+ title: "Mute chat user",
1263
+ description: toolDescription({
1264
+ summary: "Mute a chat user for a bounded time window.",
1265
+ args: ["eventId: target event.", "uid: Firebase uid to mute.", "minutes: mute duration, 1-1440, default 5.", "confirm: must be true."],
1266
+ returns: "eventId, targetUid, and mutedMinutes.",
1267
+ examples: ["Use after reading chat messages and confirming the exact uid with the user."],
1268
+ }),
1269
+ inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to mute."), minutes: z.number().int().min(1).max(1440).default(5).describe("Mute duration in minutes."), confirm: z.boolean().optional().describe("Required true for mute.") },
1270
+ outputSchema: basicEventOutputSchema,
1271
+ annotations: destructiveTool,
1272
+ }, async ({ eventId, uid: targetUid, minutes, confirm }) => {
1273
+ const actor = await authed(["chat:moderate"]);
1274
+ await readEvent(eventId, actor.uid, "edit");
1275
+ requireConfirm(confirm, "oko_mute_user", { eventId, targetUid, minutes });
1276
+ await set(ref(chatRtdb, `mutes/${eventId}/${targetUid}`), { until: Date.now() + minutes * 60_000, by: actor.uid, reason: "manual" });
1277
+ return textResult({ eventId, targetUid, mutedMinutes: minutes });
1278
+ });
1279
+ server.registerTool("oko_ban_user", {
1280
+ title: "Ban chat user",
1281
+ description: toolDescription({
1282
+ summary: "Ban a chat user and soft-delete up to 500 recent messages from that uid.",
1283
+ args: ["eventId: target event.", "uid: Firebase uid to ban.", "confirm: must be true."],
1284
+ returns: "eventId, targetUid, banned=true, and deletedMessages count.",
1285
+ examples: ["Use only after explicit user confirmation; prefer mute for temporary moderation."],
1286
+ }),
1287
+ inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to ban."), confirm: z.boolean().optional().describe("Required true for ban.") },
1288
+ outputSchema: basicEventOutputSchema,
1289
+ annotations: destructiveTool,
1290
+ }, async ({ eventId, uid: targetUid, confirm }) => {
1291
+ const actor = await authed(["chat:moderate"]);
1292
+ await readEvent(eventId, actor.uid, "edit");
1293
+ requireConfirm(confirm, "oko_ban_user", { eventId, targetUid });
1294
+ await set(ref(chatRtdb, `bans/${eventId}/${targetUid}`), true);
1295
+ const messagesSnap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), limitToLast(500)));
1296
+ const messages = (messagesSnap.val() ?? {});
1297
+ const deletes = {};
1298
+ for (const [messageId, message] of Object.entries(messages)) {
1299
+ if (message.uid === targetUid)
1300
+ deletes[`mod/deleted/${eventId}/${messageId}`] = { deletedAt: rtdbServerTimestamp(), ts: Date.now(), by: actor.uid, reason: "ban" };
1301
+ }
1302
+ if (Object.keys(deletes).length > 0)
1303
+ await update(ref(chatRtdb), deletes);
1304
+ return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
1305
+ });
1306
+ server.registerTool("oko_set_chat_settings", {
1307
+ title: "Set chat settings",
1308
+ description: toolDescription({
1309
+ summary: "Set slow mode and automod settings for an event chat.",
1310
+ args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle."],
1311
+ returns: "eventId and updated RTDB setting paths.",
1312
+ examples: ["Use slowModeMs=5000 to slow busy chats; use automodEnabled=false as an emergency kill switch."],
1313
+ }),
1314
+ inputSchema: {
1315
+ eventId: eventIdField,
1316
+ slowModeMs: z.number().int().min(0).max(60000).optional().describe("Slow mode delay in milliseconds."),
1317
+ automodEnabled: z.boolean().optional().describe("Enable or disable chat automoderation."),
1318
+ },
1319
+ outputSchema: basicEventOutputSchema,
1320
+ annotations: idempotentWriteTool,
1321
+ }, async ({ eventId, slowModeMs, automodEnabled }) => {
1322
+ const { uid } = await authed(["chat:moderate"]);
1323
+ await readEvent(eventId, uid, "edit");
1324
+ const patch = {};
1325
+ if (slowModeMs !== undefined)
1326
+ patch[`settings/${eventId}/slowModeMs`] = slowModeMs;
1327
+ if (automodEnabled !== undefined)
1328
+ patch[`settings/${eventId}/automodEnabled`] = automodEnabled;
1329
+ await update(ref(chatRtdb), patch);
1330
+ return textResult({ eventId, updated: Object.keys(patch) });
1331
+ });
1332
+ server.registerTool("oko_launch_checkpoint", {
1333
+ title: "Launch NMO checkpoint",
1334
+ description: toolDescription({
1335
+ summary: "Launch a participant attendance checkpoint for NMO mode.",
1336
+ args: ["eventId: target event.", "timeoutSec: checkpoint response window, 5-3600 seconds.", "confirm: must be true."],
1337
+ returns: "eventId and checkpoint object with id, startedAt, timeoutSec, and optional sessionId.",
1338
+ examples: ["Use during a live NMO webinar only after user confirmation."],
1339
+ }),
1340
+ inputSchema: { eventId: eventIdField, timeoutSec: z.number().int().min(5).max(3600).default(30).describe("Checkpoint response window in seconds."), confirm: z.boolean().optional().describe("Required true for launch.") },
1341
+ outputSchema: basicEventOutputSchema,
1342
+ annotations: writeTool,
1343
+ }, async ({ eventId, timeoutSec, confirm }) => {
1344
+ const { uid } = await authed(["nmo:write", "live:control"]);
1345
+ await readEvent(eventId, uid, "edit");
1346
+ requireConfirm(confirm, "oko_launch_checkpoint", { eventId, timeoutSec });
1347
+ const activeSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
1348
+ const checkpoint = { id: randomId(), startedAt: Date.now(), timeoutSec, ...(activeSnap.val() ? { sessionId: activeSnap.val() } : {}) };
1349
+ await update(ref(rtdb, `events/${eventId}`), {
1350
+ "ctrl/checkpoint": checkpoint,
1351
+ [`checkpointWindows/${checkpoint.id}`]: checkpoint,
1352
+ });
1353
+ return textResult({ eventId, checkpoint });
1354
+ });
1355
+ server.registerTool("oko_close_checkpoint", {
1356
+ title: "Close NMO checkpoint",
1357
+ description: toolDescription({
1358
+ summary: "Close the active NMO checkpoint for an event.",
1359
+ args: ["eventId: target event."],
1360
+ returns: "eventId and checkpointClosed=true.",
1361
+ examples: ["Use when the organizer wants to stop showing the active checkpoint."],
1362
+ }),
1363
+ inputSchema: { eventId: eventIdField },
1364
+ outputSchema: basicEventOutputSchema,
1365
+ annotations: idempotentWriteTool,
1366
+ }, async ({ eventId }) => {
1367
+ const { uid } = await authed(["nmo:write", "live:control"]);
1368
+ await readEvent(eventId, uid, "edit");
1369
+ await remove(ref(rtdb, `events/${eventId}/ctrl/checkpoint`));
1370
+ return textResult({ eventId, checkpointClosed: true });
1371
+ });
1372
+ server.registerTool("oko_list_participants", {
1373
+ title: "List NMO participants",
1374
+ description: toolDescription({
1375
+ summary: "Read paginated NMO participants for an event.",
1376
+ args: [
1377
+ "eventId: target event.",
1378
+ "csv: return CSV string instead of participants array.",
1379
+ "limit: max rows, default 50, max 200.",
1380
+ "cursor: participant uid returned as nextCursor by the previous page.",
1381
+ ],
1382
+ returns: "participants or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
1383
+ examples: ["Use limit=50 first; if hasMore is true, call again with cursor=nextCursor."],
1384
+ }),
1385
+ inputSchema: {
1386
+ eventId: eventIdField,
1387
+ csv: z.boolean().default(false).describe("Return CSV string instead of JSON rows."),
1388
+ limit: limitField(MAX_EXPORT_LIMIT, 50),
1389
+ cursor: cursorField,
1390
+ },
1391
+ outputSchema: participantsOutputSchema,
1392
+ annotations: readOnlyTool,
1393
+ }, async ({ eventId, csv, limit: max, cursor }) => {
1394
+ const { uid } = await authed(["nmo:write"]);
1395
+ await readEvent(eventId, uid, "read");
1396
+ const constraints = cursor
1397
+ ? [orderBy(documentId()), startAfter(cursor), limit(max + 1)]
1398
+ : [orderBy(documentId()), limit(max + 1)];
1399
+ const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), ...constraints));
1400
+ const page = snap.docs.slice(0, max);
1401
+ const rows = page.map((item) => ({ uid: item.id, ...serialize(item.data()) }));
1402
+ const hasMore = snap.docs.length > max;
1403
+ const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
1404
+ const meta = cursorMeta(rows.length, max, hasMore, nextCursor);
1405
+ if (csv) {
1406
+ const text = csvRows(["uid", "email", "fullName", "position", "org", "registeredAt"], rows.map((row) => [row.uid, row.email, row.fullName, row.position, row.org, row.registeredAt]));
1407
+ return textResult({ csv: text, ...meta });
1408
+ }
1409
+ return textResult({ participants: rows, ...meta });
1410
+ });
1411
+ server.registerTool("oko_list_leads", {
1412
+ title: "List form leads",
1413
+ description: toolDescription({
1414
+ summary: "Read paginated lead form submissions for an event.",
1415
+ args: [
1416
+ "eventId: target event.",
1417
+ "formId: optional lead form id filter.",
1418
+ "csv: return CSV string instead of leads array.",
1419
+ "limit: max rows, default 50, max 200.",
1420
+ "cursor: lead document id returned as nextCursor by the previous page.",
1421
+ ],
1422
+ returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
1423
+ examples: ["Use formId to export one form; if Firestore requests an index, create it from the error link and rerun."],
1424
+ }),
1425
+ inputSchema: {
1426
+ eventId: eventIdField,
1427
+ formId: z.string().optional().describe("Optional lead form id to filter submissions."),
1428
+ csv: z.boolean().default(false).describe("Return CSV string instead of JSON rows."),
1429
+ limit: limitField(MAX_EXPORT_LIMIT, 50),
1430
+ cursor: cursorField,
1431
+ },
1432
+ outputSchema: leadsOutputSchema,
1433
+ annotations: readOnlyTool,
1434
+ }, async ({ eventId, formId, csv, limit: max, cursor }) => {
1435
+ const { uid } = await authed(["leads:read"]);
1436
+ await readEvent(eventId, uid, "read");
1437
+ const base = collection(firestore, "events", eventId, "leads");
1438
+ const constraints = formId
1439
+ ? [where("formId", "==", formId), orderBy(documentId()), ...(cursor ? [startAfter(cursor)] : []), limit(max + 1)]
1440
+ : [orderBy(documentId()), ...(cursor ? [startAfter(cursor)] : []), limit(max + 1)];
1441
+ const snap = await getDocs(query(base, ...constraints));
1442
+ const page = snap.docs.slice(0, max);
1443
+ const leads = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
1444
+ const hasMore = snap.docs.length > max;
1445
+ const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
1446
+ const meta = cursorMeta(leads.length, max, hasMore, nextCursor);
1447
+ if (csv) {
1448
+ const text = csvRows(["id", "formId", "source", "submittedAt", "valuesJson"], leads.map((lead) => [lead.id, lead.formId, lead.source, lead.submittedAt, JSON.stringify(lead.values ?? {})]));
1449
+ return textResult({ csv: text, ...meta });
1450
+ }
1451
+ return textResult({ leads, ...meta });
1452
+ });
1453
+ server.registerTool("oko_publish_telegram_post", {
1454
+ title: "Publish Telegram post",
1455
+ description: toolDescription({
1456
+ summary: "Create and optionally send or schedule a Telegram post for an OKO event.",
1457
+ args: [
1458
+ "eventId: event used for access checks and default button URL.",
1459
+ "channelId: Telegram channel id configured for the organizer.",
1460
+ "caption: post caption, max 4096 chars.",
1461
+ "scheduleAt: optional ISO datetime; omitted means immediate send and requires confirm=true.",
1462
+ "confirm: must be true for immediate send.",
1463
+ ],
1464
+ returns: "postId, scheduled flag, and sent flag.",
1465
+ examples: ["Use scheduleAt for scheduled posts; for immediate send, show the exact channel/caption and ask for confirmation."],
1466
+ notes: ["External side effect: immediate sends can publish to real Telegram channels."],
1467
+ }),
1468
+ inputSchema: {
1469
+ eventId: eventIdField,
1470
+ channelId: z.number().int().describe("Telegram channel id."),
1471
+ caption: z.string().min(1).max(4096).describe("Telegram post caption."),
1472
+ imageUrl: z.string().nullable().optional().describe("Optional image URL."),
1473
+ buttonLabel: z.string().max(64).nullable().optional().describe("Optional inline button label."),
1474
+ buttonUrl: z.string().max(1000).nullable().optional().describe("Optional inline button URL; defaults to event URL when buttonLabel is set."),
1475
+ scheduleAt: z.string().datetime().nullable().optional().describe("Optional ISO datetime for scheduled send."),
1476
+ confirm: z.boolean().optional().describe("Required true for immediate send."),
1477
+ },
1478
+ outputSchema: z.object({ postId: z.string(), scheduled: z.boolean(), sent: z.boolean() }).passthrough(),
1479
+ annotations: writeTool,
1480
+ }, async (args) => {
1481
+ const { uid } = await authed(["telegram:write"]);
1482
+ await readEvent(args.eventId, uid, "edit");
1483
+ const scheduled = !!args.scheduleAt;
1484
+ if (!scheduled)
1485
+ requireConfirm(args.confirm, "oko_publish_telegram_post", { eventId: args.eventId, channelId: args.channelId, caption: args.caption });
1486
+ const postData = {
1487
+ eventId: args.eventId,
1488
+ ownerId: uid,
1489
+ ownerEmail: "",
1490
+ channelId: args.channelId,
1491
+ caption: args.caption.trim(),
1492
+ imageUrl: args.imageUrl?.trim() || null,
1493
+ buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: args.buttonUrl?.trim() || eventUrl(args.eventId) }] : [],
1494
+ status: scheduled ? "scheduled" : "draft",
1495
+ scheduledAt: args.scheduleAt ? Timestamp.fromDate(new Date(args.scheduleAt)) : null,
1496
+ sentAt: null,
1497
+ telegramMessageId: null,
1498
+ error: null,
1499
+ createdAt: serverTimestamp(),
1500
+ updatedAt: serverTimestamp(),
1501
+ };
1502
+ const postRef = await addDoc(collection(firestore, "telegramPosts"), postData);
1503
+ if (!scheduled) {
1504
+ const sendPost = httpsCallable(functions, "sendTelegramPost");
1505
+ await sendPost({ postId: postRef.id });
1506
+ }
1507
+ return textResult({ postId: postRef.id, scheduled, sent: !scheduled });
1508
+ });
1509
+ server.registerTool("oko_create_workspace_invite", {
1510
+ title: "Create workspace invite",
1511
+ description: toolDescription({
1512
+ summary: "Create a workspace invite for a known OKO workspace.",
1513
+ args: [
1514
+ "workspaceId: workspace owned by the authenticated organizer.",
1515
+ "code: invite code, 4-64 chars; stored uppercase.",
1516
+ "email: optional intended recipient email.",
1517
+ "permissions: optional whitelist of workspace permission booleans.",
1518
+ "confirm: must be true.",
1519
+ ],
1520
+ returns: "workspaceId, invite code, and created=true.",
1521
+ examples: ["Use only after confirming the recipient and permission set with the workspace owner."],
1522
+ }),
1523
+ inputSchema: {
1524
+ workspaceId: z.string().min(1),
1525
+ code: z.string().min(4).max(64),
1526
+ email: z.string().email().optional(),
1527
+ comment: z.string().max(500).default(""),
1528
+ permissions: workspacePermissionsSchema,
1529
+ confirm: z.boolean().optional(),
1530
+ },
1531
+ outputSchema: z.object({ workspaceId: z.string(), code: z.string(), created: z.boolean() }).passthrough(),
1532
+ annotations: writeTool,
1533
+ }, async (args) => {
1534
+ const { uid } = await authed(["workspace:write"]);
1535
+ const workspaceSnap = await getDoc(doc(firestore, "workspaces", args.workspaceId));
1536
+ if (!workspaceSnap.exists())
1537
+ throw new Error("Workspace not found.");
1538
+ const workspace = workspaceSnap.data();
1539
+ if (workspace.ownerId !== uid)
1540
+ throw new Error("Only the workspace owner can create invites.");
1541
+ requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
1542
+ const code = args.code.trim().toUpperCase();
1543
+ await setDoc(doc(firestore, "workspaceInvites", code), {
1544
+ workspaceId: args.workspaceId,
1545
+ workspaceName: safeString(workspace.name, "Workspace"),
1546
+ email: args.email ?? "",
1547
+ comment: args.comment,
1548
+ permissions: args.permissions,
1549
+ createdBy: uid,
1550
+ createdAt: serverTimestamp(),
1551
+ claimed: false,
1552
+ claimedBy: null,
1553
+ });
1554
+ return textResult({ workspaceId: args.workspaceId, code, created: true });
1555
+ });
1556
+ }