@okostream/mcp 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -8
- package/dist/guides.js +215 -0
- package/dist/server.js +5 -1
- package/dist/tools.js +708 -71
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,10 +54,17 @@ Every tool returns JSON text plus MCP `structuredContent`. List-like tools use b
|
|
|
54
54
|
|
|
55
55
|
- `limit` defaults to 20-50 depending on the tool.
|
|
56
56
|
- Cursor tools return both `hasMore`/`nextCursor` and `has_more`/`next_cursor`.
|
|
57
|
-
- `oko_list_my_events`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_list_workspace_access`, `oko_list_workspace_invites`, and `oko_list_telegram_posts` support pagination.
|
|
57
|
+
- `oko_list_my_events`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_list_notifications`, `oko_list_workspace_access`, `oko_list_workspace_invites`, and `oko_list_telegram_posts` support pagination.
|
|
58
58
|
- `oko_get_analytics` omits heavy `timeline`, baseline/final snapshots, and NMO participant rows by default. Request `includeTimeline`, `includeSnapshots`, or `includeNmoRows` only when needed.
|
|
59
|
+
- `oko_get_session_report` returns a compact structured session report plus markdown suitable for a human handoff.
|
|
59
60
|
- Oversized responses are truncated with `truncated: true` and a `truncationMessage`.
|
|
60
61
|
|
|
62
|
+
The server also exposes MCP-native onboarding:
|
|
63
|
+
|
|
64
|
+
- `instructions` on initialize with the supported webinar creation flow and product invariants.
|
|
65
|
+
- Resources: `oko://guide/create-webinar`, `oko://guide/platform`, `oko://guide/security`, `oko://schema/event`, `oko://capabilities`.
|
|
66
|
+
- Prompts: `create_webinar_from_brief`, `audit_event_setup`, `prepare_session_report`.
|
|
67
|
+
|
|
61
68
|
This package is published as source-available runtime distribution for OKO customers. `license: UNLICENSED` means all rights reserved unless a separate agreement says otherwise.
|
|
62
69
|
|
|
63
70
|
## Local Development
|
|
@@ -92,14 +99,16 @@ All tools are prefixed with `oko_` for safe discovery next to other MCP servers.
|
|
|
92
99
|
Core discovery/read tools:
|
|
93
100
|
|
|
94
101
|
- `oko_whoami`, `oko_list_workspaces`, `oko_list_my_events`, `oko_get_event`
|
|
95
|
-
- `
|
|
102
|
+
- `oko_validate_event_setup`
|
|
103
|
+
- `oko_update_profile`, `oko_list_notifications`, `oko_mark_notifications_read`
|
|
104
|
+
- `oko_get_live_state`, `oko_get_analytics`, `oko_get_session_report`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_delete_lead`
|
|
96
105
|
|
|
97
106
|
Event/setup/media tools:
|
|
98
107
|
|
|
99
108
|
- `oko_create_webinar`, `oko_update_event`, `oko_delete_event`
|
|
100
109
|
- `oko_duplicate_event`, `oko_move_event`
|
|
101
110
|
- `oko_configure_blocks`, `oko_configure_cards`
|
|
102
|
-
- `oko_upload_image`, `oko_delete_image`
|
|
111
|
+
- `oko_upload_image`, `oko_upload_card_images_batch`, `oko_wait_for_image_variants`, `oko_delete_image`
|
|
103
112
|
|
|
104
113
|
Live/session/chat/NMO tools:
|
|
105
114
|
|
|
@@ -124,11 +133,15 @@ Dangerous or external side-effect tools require `confirm: true`.
|
|
|
124
133
|
2. `oko_list_my_events` should return only events visible to that UID.
|
|
125
134
|
3. `oko_create_webinar` should create a test webinar and RTDB owner/workspace mirror.
|
|
126
135
|
4. `oko_configure_cards` should create/update `blocks.cards.items` and preserve card image fields by stable card id.
|
|
127
|
-
5. `
|
|
128
|
-
6. `
|
|
129
|
-
7. `
|
|
130
|
-
8. `
|
|
131
|
-
9. `
|
|
136
|
+
5. `oko_upload_card_images_batch` should upload multiple card images, then return ready `imageVariants`.
|
|
137
|
+
6. `oko_validate_event_setup` should catch missing images/forms/buttons/video/NMO/Telegram/variants on a deliberately broken event.
|
|
138
|
+
7. `oko_start_session` -> `oko_fix_orphan_session` -> `oko_delete_session` should leave no active session pointers on a disposable test event.
|
|
139
|
+
8. `oko_duplicate_event`, workspace tools, and `oko_move_event` should preserve Firestore access rules and RTDB owner/workspace mirrors.
|
|
140
|
+
9. `oko_import_nmo_participants` should call CF24 and return per-row results; clean up test participants after import.
|
|
141
|
+
10. `oko_get_session_report` should return markdown and structured session data for a completed session.
|
|
142
|
+
11. `oko_list_notifications` -> `oko_mark_notifications_read` should read and clear an own disposable notification.
|
|
143
|
+
12. `oko_get_analytics` should read an existing event with analytics sessions.
|
|
144
|
+
13. MCP protocol smoke should report 59 tools, 5 resources, 3 prompts, and non-empty initialize instructions.
|
|
132
145
|
|
|
133
146
|
CLI help:
|
|
134
147
|
|
package/dist/guides.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const OKO_MCP_INSTRUCTIONS = [
|
|
3
|
+
"You are controlling OKO Stream as an organizer through MCP tools.",
|
|
4
|
+
"Start every new workspace session with oko_whoami, then respect the returned scopes.",
|
|
5
|
+
"OKO events have no draft/live/ended status. Start/End controls manage analytics sessions only, never video streaming.",
|
|
6
|
+
"For webinar creation, prefer this flow: oko_create_webinar, oko_configure_blocks, oko_configure_cards, image uploads, oko_validate_event_setup, then return public/setup/analytics links.",
|
|
7
|
+
"For card images, create stable card ids first, then use oko_upload_card_images_batch or oko_upload_image plus oko_wait_for_image_variants.",
|
|
8
|
+
"Before destructive actions, show the exact target and require an explicit user confirmation; then pass confirm=true.",
|
|
9
|
+
"Use oko_validate_event_setup before telling the organizer that a webinar is ready.",
|
|
10
|
+
"Do not bypass OKO APIs or Firebase rules; this server is the supported organizer surface.",
|
|
11
|
+
].join("\n");
|
|
12
|
+
const GUIDE_RESOURCES = [
|
|
13
|
+
{
|
|
14
|
+
name: "oko_mcp_create_webinar",
|
|
15
|
+
uri: "oko://guide/create-webinar",
|
|
16
|
+
title: "Create an OKO Webinar",
|
|
17
|
+
description: "Recommended call order for creating and validating a webinar.",
|
|
18
|
+
text: [
|
|
19
|
+
"# Create an OKO Webinar",
|
|
20
|
+
"",
|
|
21
|
+
"1. Call `oko_whoami` and inspect scopes.",
|
|
22
|
+
"2. If the workspace is unclear, call `oko_list_workspaces`.",
|
|
23
|
+
"3. Call `oko_create_webinar` with title/date/workspace and video URL when known.",
|
|
24
|
+
"4. Call `oko_configure_blocks` for video, forms, CTA, banner, Telegram, and NMO settings.",
|
|
25
|
+
"5. Call `oko_configure_cards` with stable card ids before uploading card images.",
|
|
26
|
+
"6. Upload images with `oko_upload_card_images_batch` or `oko_upload_image`, then wait for variants.",
|
|
27
|
+
"7. Call `oko_validate_event_setup` and fix blocking findings.",
|
|
28
|
+
"8. Return public, setup, analytics, registration, and login links that apply to the event.",
|
|
29
|
+
"",
|
|
30
|
+
"Important: OKO has no event status. Do not invent draft/live/ended lifecycle states.",
|
|
31
|
+
].join("\n"),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: "oko_mcp_platform",
|
|
35
|
+
uri: "oko://guide/platform",
|
|
36
|
+
title: "OKO Platform Invariants",
|
|
37
|
+
description: "Core OKO product rules that MCP clients must respect.",
|
|
38
|
+
text: [
|
|
39
|
+
"# OKO Platform Invariants",
|
|
40
|
+
"",
|
|
41
|
+
"- OKO events have no `draft`, `live`, or `ended` status field.",
|
|
42
|
+
"- Public event pages render whenever the event exists and normal access rules allow it.",
|
|
43
|
+
"- Start and End controls manage analytics sessions only; they never start or stop the video stream.",
|
|
44
|
+
"- Setup writes Firestore event structure. Live analytics and controls use RTDB mirrors and session nodes.",
|
|
45
|
+
"- Chat lives in the separate chat RTDB. Moderator actions require chat scopes and normal event access.",
|
|
46
|
+
"- NMO participant import is handled by the scoped Cloud Function `importNmoParticipants`.",
|
|
47
|
+
"- Image variants are asynchronous. A master upload is not fully ready until `imageVariants` are present.",
|
|
48
|
+
].join("\n"),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "oko_mcp_security",
|
|
52
|
+
uri: "oko://guide/security",
|
|
53
|
+
title: "OKO MCP Security Model",
|
|
54
|
+
description: "API-key, scopes, confirmation, and Firebase rules boundaries.",
|
|
55
|
+
text: [
|
|
56
|
+
"# OKO MCP Security Model",
|
|
57
|
+
"",
|
|
58
|
+
"The MCP server acts as the organizer user represented by `OKO_API_KEY`. It is not a platform-admin backdoor.",
|
|
59
|
+
"",
|
|
60
|
+
"Security layers:",
|
|
61
|
+
"- API keys are stored hashed in Firestore and the full secret is shown only once in the OKO UI.",
|
|
62
|
+
"- `exchangeApiKey` mints short-lived Firebase custom tokens with legacy `scopes` and rules-readable `mcpScopes`.",
|
|
63
|
+
"- MCP tools check scopes before executing.",
|
|
64
|
+
"- Firestore, RTDB, chat RTDB, and Storage rules also check `mcpScopes` for direct Firebase SDK misuse.",
|
|
65
|
+
"- Destructive or external side-effect tools require explicit `confirm:true`.",
|
|
66
|
+
"- Wrong API-key exchanges are counted per keyId; after 10 failures the keyId is locked for 15 minutes.",
|
|
67
|
+
"",
|
|
68
|
+
"- `events:read`: list and read event documents.",
|
|
69
|
+
"- `events:write`: create, duplicate, move, and update event metadata.",
|
|
70
|
+
"- `events:delete`: delete event records.",
|
|
71
|
+
"- `setup:write`: configure blocks, forms, cards, and images.",
|
|
72
|
+
"- `analytics:read`: read live state, sessions, reports, chat history.",
|
|
73
|
+
"- `analytics:write` + `live:control`: start/end/fix/delete analytics sessions.",
|
|
74
|
+
"- `live:control`: live block visibility and viewer refresh.",
|
|
75
|
+
"- `chat:write`: send organizer chat messages.",
|
|
76
|
+
"- `chat:moderate`: delete, mute, ban, unmute, unban, and chat settings.",
|
|
77
|
+
"- `telegram:write`: claim/list channels and create/cancel/publish Telegram posts.",
|
|
78
|
+
"- `workspace:read` / `workspace:write`: workspace, members, invites, notifications.",
|
|
79
|
+
"- `nmo:write`: NMO participants and checkpoints.",
|
|
80
|
+
"- `leads:read`: read form submissions; lead deletion also requires `setup:write`.",
|
|
81
|
+
].join("\n"),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "oko_event_schema",
|
|
85
|
+
uri: "oko://schema/event",
|
|
86
|
+
title: "OKO Event Schema Notes",
|
|
87
|
+
description: "Compact schema notes for fields most often edited by MCP clients.",
|
|
88
|
+
text: [
|
|
89
|
+
"# OKO Event Schema Notes",
|
|
90
|
+
"",
|
|
91
|
+
"Common event fields:",
|
|
92
|
+
"- `title`: user-visible webinar title.",
|
|
93
|
+
"- `meta.eventDate`: scheduled date.",
|
|
94
|
+
"- `video.originalUrl` / `video.embedUrl`: raw and normalized video URLs.",
|
|
95
|
+
"- `forms[]`: lead forms with `id`, `type`, `title`, `fields[]`, consent copy, and submit/success text.",
|
|
96
|
+
"- `blocks.primaryCta`: CTA copy, `buttonUrl` or `formId`, and visibility.",
|
|
97
|
+
"- `blocks.promoBanner`: banner copy, link/form binding, image refs, variants, and visibility.",
|
|
98
|
+
"- `blocks.cards.items[]`: stable `id`, copy, optional `buttonUrl` or `formId`, image refs, and `imageVariants`.",
|
|
99
|
+
"- `telegram.channelIds`: claimed Telegram channels for notifications.",
|
|
100
|
+
"- `nmoMode`, `nmoCriteria`, `participantUids`: NMO mode and participant gates.",
|
|
101
|
+
"- `workspaceId`, `accessibleBy`, `editors`: access mirrors required by rules and dashboard queries.",
|
|
102
|
+
"",
|
|
103
|
+
"Do not remove unknown fields when updating nested config. Use dedicated MCP tools instead of whole-document rewrites.",
|
|
104
|
+
].join("\n"),
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
name: "oko_mcp_capabilities",
|
|
108
|
+
uri: "oko://capabilities",
|
|
109
|
+
title: "OKO MCP Capabilities",
|
|
110
|
+
description: "High-level workflows available through OKO MCP.",
|
|
111
|
+
text: [
|
|
112
|
+
"# OKO MCP Capabilities",
|
|
113
|
+
"",
|
|
114
|
+
"## Webinar setup",
|
|
115
|
+
"- Create, duplicate, move, update, validate, and delete webinars.",
|
|
116
|
+
"- Configure forms, CTA, banner, cards, video placeholder, Telegram, and NMO settings.",
|
|
117
|
+
"- Upload images from local paths, data URLs, or HTTPS URLs and wait for variants.",
|
|
118
|
+
"",
|
|
119
|
+
"## Live analytics",
|
|
120
|
+
"- Read live state, start/end analytics sessions, refresh viewers, and change block visibility.",
|
|
121
|
+
"- Read compact analytics, session reports, leads, chat history, and NMO participation data.",
|
|
122
|
+
"- Fix or delete orphan sessions only after explicit confirmation.",
|
|
123
|
+
"",
|
|
124
|
+
"## Collaboration and comms",
|
|
125
|
+
"- Manage workspaces, members, invites, notifications, and Telegram post lifecycle.",
|
|
126
|
+
"- Send organizer chat messages and moderate chat users when scoped.",
|
|
127
|
+
"- Import/remove NMO participants through scoped Cloud Functions.",
|
|
128
|
+
].join("\n"),
|
|
129
|
+
},
|
|
130
|
+
];
|
|
131
|
+
function promptMessage(text) {
|
|
132
|
+
return {
|
|
133
|
+
messages: [{
|
|
134
|
+
role: "user",
|
|
135
|
+
content: { type: "text", text },
|
|
136
|
+
}],
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function registerGuides(server) {
|
|
140
|
+
for (const resource of GUIDE_RESOURCES) {
|
|
141
|
+
server.registerResource(resource.name, resource.uri, {
|
|
142
|
+
title: resource.title,
|
|
143
|
+
description: resource.description,
|
|
144
|
+
mimeType: "text/markdown",
|
|
145
|
+
}, async (uri) => ({
|
|
146
|
+
contents: [{
|
|
147
|
+
uri: uri.toString(),
|
|
148
|
+
mimeType: "text/markdown",
|
|
149
|
+
text: resource.text,
|
|
150
|
+
}],
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
server.registerPrompt("create_webinar_from_brief", {
|
|
154
|
+
title: "Create Webinar From Brief",
|
|
155
|
+
description: "Plan and execute a complete OKO webinar setup from an organizer brief.",
|
|
156
|
+
argsSchema: {
|
|
157
|
+
brief: z.string().min(1).describe("Organizer brief with topic, date, audience, blocks, images, and integrations."),
|
|
158
|
+
workspaceId: z.string().optional().describe("Optional OKO workspace id."),
|
|
159
|
+
},
|
|
160
|
+
}, async ({ brief, workspaceId }) => promptMessage([
|
|
161
|
+
"Create an OKO webinar from this brief using MCP tools.",
|
|
162
|
+
"",
|
|
163
|
+
"Required flow:",
|
|
164
|
+
"1. Call oko_whoami and verify scopes.",
|
|
165
|
+
"2. Create the webinar in the requested workspace if provided.",
|
|
166
|
+
"3. Configure metadata, blocks, forms, Telegram, NMO, and cards as needed.",
|
|
167
|
+
"4. Upload images only after target slots/cards exist; wait for image variants.",
|
|
168
|
+
"5. Run oko_validate_event_setup and fix actionable errors.",
|
|
169
|
+
"6. Return public, setup, analytics, registration/login links as applicable.",
|
|
170
|
+
"",
|
|
171
|
+
workspaceId ? `Workspace id: ${workspaceId}` : "Workspace id: not specified; choose the personal workspace unless the user clarifies.",
|
|
172
|
+
"",
|
|
173
|
+
"Brief:",
|
|
174
|
+
brief,
|
|
175
|
+
].join("\n")));
|
|
176
|
+
server.registerPrompt("audit_event_setup", {
|
|
177
|
+
title: "Audit Event Setup",
|
|
178
|
+
description: "Audit an OKO event for readiness and produce a fix plan.",
|
|
179
|
+
argsSchema: {
|
|
180
|
+
eventId: z.string().min(1).describe("OKO event id to audit."),
|
|
181
|
+
focus: z.string().optional().describe("Optional focus area, for example NMO, Telegram, forms, cards, live."),
|
|
182
|
+
},
|
|
183
|
+
}, async ({ eventId, focus }) => promptMessage([
|
|
184
|
+
`Audit OKO event ${eventId}.`,
|
|
185
|
+
"",
|
|
186
|
+
"Required flow:",
|
|
187
|
+
"1. Call oko_whoami and verify scopes.",
|
|
188
|
+
"2. Call oko_get_event and oko_validate_event_setup.",
|
|
189
|
+
"3. If relevant, inspect live state, Telegram posts, participants, leads, and image variant readiness.",
|
|
190
|
+
"4. Separate errors that block launch from warnings and polish suggestions.",
|
|
191
|
+
"5. Propose exact MCP tool calls needed to fix issues; execute only if the organizer asked for implementation.",
|
|
192
|
+
"",
|
|
193
|
+
focus ? `Focus: ${focus}` : "Focus: full setup readiness.",
|
|
194
|
+
].join("\n")));
|
|
195
|
+
server.registerPrompt("prepare_session_report", {
|
|
196
|
+
title: "Prepare Session Report",
|
|
197
|
+
description: "Prepare an organizer-friendly report from OKO analytics session data.",
|
|
198
|
+
argsSchema: {
|
|
199
|
+
eventId: z.string().min(1).describe("OKO event id."),
|
|
200
|
+
sessionId: z.string().optional().describe("Optional analytics session id; omit to choose from sessions list."),
|
|
201
|
+
audience: z.string().optional().describe("Optional report audience, for example internal, client, NMO."),
|
|
202
|
+
},
|
|
203
|
+
}, async ({ eventId, sessionId, audience }) => promptMessage([
|
|
204
|
+
`Prepare a session report for OKO event ${eventId}.`,
|
|
205
|
+
"",
|
|
206
|
+
"Required flow:",
|
|
207
|
+
"1. Call oko_whoami and verify analytics:read.",
|
|
208
|
+
sessionId ? `2. Use session id ${sessionId}.` : "2. Call oko_get_analytics and choose the relevant ended session id from returned sessions.",
|
|
209
|
+
"3. Call oko_get_session_report.",
|
|
210
|
+
"4. Summarize attendance, engagement, chat, CTA/cards, geo, and NMO if present.",
|
|
211
|
+
"5. Call out data caveats separately from conclusions.",
|
|
212
|
+
"",
|
|
213
|
+
audience ? `Audience: ${audience}` : "Audience: organizer/internal.",
|
|
214
|
+
].join("\n")));
|
|
215
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { OKO_MCP_INSTRUCTIONS, registerGuides } from "./guides.js";
|
|
4
5
|
import { registerTools } from "./tools.js";
|
|
5
6
|
if (process.argv.includes("--help")) {
|
|
6
7
|
process.stdout.write([
|
|
@@ -22,8 +23,11 @@ if (process.argv.includes("--help")) {
|
|
|
22
23
|
}
|
|
23
24
|
const server = new McpServer({
|
|
24
25
|
name: "oko-mcp-server",
|
|
25
|
-
version: "0.
|
|
26
|
+
version: "0.4.0",
|
|
27
|
+
}, {
|
|
28
|
+
instructions: OKO_MCP_INSTRUCTIONS,
|
|
26
29
|
});
|
|
30
|
+
registerGuides(server);
|
|
27
31
|
registerTools(server);
|
|
28
32
|
const transport = new StdioServerTransport();
|
|
29
33
|
await server.connect(transport);
|
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, arrayRemove, 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, runTransaction as runFirestoreTransaction, 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";
|
|
@@ -144,6 +144,35 @@ const deleteImageOutputSchema = z.object({
|
|
|
144
144
|
storageDeletesAttempted: z.number().int().nonnegative(),
|
|
145
145
|
storageDeleteWarnings: z.array(z.string()),
|
|
146
146
|
}).passthrough();
|
|
147
|
+
const imageVariantsOutputSchema = z.object({
|
|
148
|
+
eventId: z.string(),
|
|
149
|
+
kind: z.enum(IMAGE_KINDS),
|
|
150
|
+
cardId: z.string().nullable(),
|
|
151
|
+
imageId: z.string().nullable(),
|
|
152
|
+
imageUrl: z.string().url().nullable(),
|
|
153
|
+
imageMasterUrl: z.string().url().nullable(),
|
|
154
|
+
imageVariants: unknownRecordSchema.nullable(),
|
|
155
|
+
ready: z.boolean(),
|
|
156
|
+
reason: z.string().nullable(),
|
|
157
|
+
elapsedMs: z.number().int().nonnegative(),
|
|
158
|
+
}).passthrough();
|
|
159
|
+
const cardImagesBatchOutputSchema = z.object({
|
|
160
|
+
eventId: z.string(),
|
|
161
|
+
uploads: z.array(unknownRecordSchema),
|
|
162
|
+
variants: z.array(unknownRecordSchema),
|
|
163
|
+
ready: z.boolean(),
|
|
164
|
+
warnings: z.array(z.string()),
|
|
165
|
+
}).passthrough();
|
|
166
|
+
const setupValidationOutputSchema = z.object({
|
|
167
|
+
eventId: z.string(),
|
|
168
|
+
ok: z.boolean(),
|
|
169
|
+
score: z.number().int().min(0).max(100),
|
|
170
|
+
errors: z.array(unknownRecordSchema),
|
|
171
|
+
warnings: z.array(unknownRecordSchema),
|
|
172
|
+
suggestions: z.array(unknownRecordSchema),
|
|
173
|
+
nextActions: z.array(z.string()),
|
|
174
|
+
links: unknownRecordSchema,
|
|
175
|
+
}).passthrough();
|
|
147
176
|
const workspaceAccessOutputSchema = z.object({
|
|
148
177
|
workspaceId: z.string(),
|
|
149
178
|
workspace: unknownRecordSchema,
|
|
@@ -163,6 +192,32 @@ const nmoImportOutputSchema = z.object({
|
|
|
163
192
|
summary: unknownRecordSchema,
|
|
164
193
|
results: z.array(unknownRecordSchema),
|
|
165
194
|
}).passthrough();
|
|
195
|
+
const sessionReportOutputSchema = z.object({
|
|
196
|
+
eventId: z.string(),
|
|
197
|
+
sessionId: z.string(),
|
|
198
|
+
reportUrl: z.string().url(),
|
|
199
|
+
markdown: z.string(),
|
|
200
|
+
session: unknownRecordSchema,
|
|
201
|
+
}).passthrough();
|
|
202
|
+
const profileOutputSchema = z.object({
|
|
203
|
+
uid: z.string(),
|
|
204
|
+
profile: unknownRecordSchema,
|
|
205
|
+
updated: z.array(z.string()),
|
|
206
|
+
}).passthrough();
|
|
207
|
+
const deleteLeadOutputSchema = z.object({
|
|
208
|
+
eventId: z.string(),
|
|
209
|
+
leadId: z.string(),
|
|
210
|
+
deleted: z.boolean(),
|
|
211
|
+
}).passthrough();
|
|
212
|
+
const notificationsOutputSchema = z.object({
|
|
213
|
+
notifications: z.array(unknownRecordSchema),
|
|
214
|
+
unreadCountOnPage: z.number().int().nonnegative(),
|
|
215
|
+
...cursorMetaSchema,
|
|
216
|
+
}).passthrough();
|
|
217
|
+
const notificationsMarkOutputSchema = z.object({
|
|
218
|
+
markedRead: z.number().int().nonnegative(),
|
|
219
|
+
notificationIds: z.array(z.string()),
|
|
220
|
+
}).passthrough();
|
|
166
221
|
const formFieldSchema = z.object({
|
|
167
222
|
id: z.string().min(1).optional(),
|
|
168
223
|
label: z.string().max(120),
|
|
@@ -209,6 +264,11 @@ const telegramChannelSchema = z.object({
|
|
|
209
264
|
title: z.string().max(200).optional(),
|
|
210
265
|
username: z.string().max(120).nullable().optional(),
|
|
211
266
|
}).strict();
|
|
267
|
+
const profileDisplayNameSchema = z.string().max(40).nullable();
|
|
268
|
+
const timezoneSchema = z.string()
|
|
269
|
+
.min(1)
|
|
270
|
+
.max(100)
|
|
271
|
+
.regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.");
|
|
212
272
|
const cardsConfigOutputSchema = z.object({
|
|
213
273
|
eventId: z.string(),
|
|
214
274
|
updated: z.array(z.string()),
|
|
@@ -480,6 +540,7 @@ function readImageRef(eventData, kind, cardId) {
|
|
|
480
540
|
imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
481
541
|
imageUrl: safeString(banner.imageUrl) || null,
|
|
482
542
|
imageMasterUrl: safeString(banner.imageMasterUrl) || null,
|
|
543
|
+
imageVariants: readImageVariants(banner.imageVariants),
|
|
483
544
|
};
|
|
484
545
|
}
|
|
485
546
|
if (kind === "videoPlaceholder") {
|
|
@@ -489,6 +550,7 @@ function readImageRef(eventData, kind, cardId) {
|
|
|
489
550
|
imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
490
551
|
imageUrl: safeString(placeholder.imageUrl) || null,
|
|
491
552
|
imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
|
|
553
|
+
imageVariants: readImageVariants(placeholder.imageVariants),
|
|
492
554
|
};
|
|
493
555
|
}
|
|
494
556
|
if (!cardId)
|
|
@@ -502,6 +564,7 @@ function readImageRef(eventData, kind, cardId) {
|
|
|
502
564
|
imageId: cardId,
|
|
503
565
|
imageUrl: safeString(item.imageUrl) || null,
|
|
504
566
|
imageMasterUrl: safeString(item.imageMasterUrl) || null,
|
|
567
|
+
imageVariants: readImageVariants(item.imageVariants),
|
|
505
568
|
};
|
|
506
569
|
}
|
|
507
570
|
function cardItemsWithImage(eventData, cardId, patch) {
|
|
@@ -662,6 +725,141 @@ async function uploadMasterImage(path, image) {
|
|
|
662
725
|
});
|
|
663
726
|
return getDownloadURL(ref);
|
|
664
727
|
}
|
|
728
|
+
async function updateCardImageInTransaction(eventId, cardId, patch) {
|
|
729
|
+
const eventRef = doc(firestore, "events", eventId);
|
|
730
|
+
await runFirestoreTransaction(firestore, async (transaction) => {
|
|
731
|
+
const snap = await transaction.get(eventRef);
|
|
732
|
+
if (!snap.exists())
|
|
733
|
+
throw new Error("Event not found.");
|
|
734
|
+
const data = snap.data();
|
|
735
|
+
transaction.update(eventRef, {
|
|
736
|
+
"blocks.cards.items": cardItemsWithImage(data, cardId, patch),
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
function imageVariantReason(ref) {
|
|
741
|
+
if (!ref.imageMasterUrl && !ref.imageUrl)
|
|
742
|
+
return "image_missing";
|
|
743
|
+
if (!ref.imageMasterUrl)
|
|
744
|
+
return "master_missing";
|
|
745
|
+
if (!ref.imageVariants)
|
|
746
|
+
return "variants_pending";
|
|
747
|
+
if (!ref.imageUrl)
|
|
748
|
+
return "image_url_pending";
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
function imageVariantStatus(eventId, eventData, kind, cardId, elapsedMs) {
|
|
752
|
+
const ref = readImageRef(eventData, kind, cardId ?? undefined);
|
|
753
|
+
const reason = imageVariantReason(ref);
|
|
754
|
+
return {
|
|
755
|
+
eventId,
|
|
756
|
+
kind,
|
|
757
|
+
cardId,
|
|
758
|
+
imageId: ref.imageId,
|
|
759
|
+
imageUrl: ref.imageUrl,
|
|
760
|
+
imageMasterUrl: ref.imageMasterUrl,
|
|
761
|
+
imageVariants: ref.imageVariants,
|
|
762
|
+
ready: reason === null,
|
|
763
|
+
reason,
|
|
764
|
+
elapsedMs,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
function sleep(ms) {
|
|
768
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
769
|
+
}
|
|
770
|
+
async function waitForImageVariantTargets(uid, eventId, targets, timeoutSec) {
|
|
771
|
+
const startedAt = Date.now();
|
|
772
|
+
const deadline = startedAt + timeoutSec * 1000;
|
|
773
|
+
let latest = targets.map((target) => ({
|
|
774
|
+
eventId,
|
|
775
|
+
kind: target.kind,
|
|
776
|
+
cardId: target.cardId,
|
|
777
|
+
imageId: target.cardId,
|
|
778
|
+
imageUrl: null,
|
|
779
|
+
imageMasterUrl: null,
|
|
780
|
+
imageVariants: null,
|
|
781
|
+
ready: false,
|
|
782
|
+
reason: "not_checked",
|
|
783
|
+
elapsedMs: 0,
|
|
784
|
+
}));
|
|
785
|
+
while (true) {
|
|
786
|
+
const elapsedMs = Date.now() - startedAt;
|
|
787
|
+
const event = await readEvent(eventId, uid, "read");
|
|
788
|
+
latest = targets.map((target) => imageVariantStatus(eventId, event, target.kind, target.cardId, elapsedMs));
|
|
789
|
+
if (latest.every((item) => item.ready) || Date.now() >= deadline)
|
|
790
|
+
return latest;
|
|
791
|
+
await sleep(Math.min(2000, Math.max(250, deadline - Date.now())));
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
795
|
+
if (kind === "card" && !cardId)
|
|
796
|
+
throw new Error("cardId is required when kind is card.");
|
|
797
|
+
if (kind !== "card" && cardId)
|
|
798
|
+
throw new Error("cardId is only valid when kind is card.");
|
|
799
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
800
|
+
const previous = readImageRef(event, kind, cardId);
|
|
801
|
+
const sourceBuffer = await readImageSource(source);
|
|
802
|
+
const masterBuffer = await compressToMasterWebp(sourceBuffer);
|
|
803
|
+
const imageId = kind === "card" ? cardId : randomUUID();
|
|
804
|
+
const masterPath = imageStoragePath(eventId, uid, kind, imageId);
|
|
805
|
+
const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
|
|
806
|
+
const deleteWarnings = [];
|
|
807
|
+
let deletesAttempted = 0;
|
|
808
|
+
if (kind === "card" && previousMasterPath === masterPath) {
|
|
809
|
+
const variantPaths = variantPathsFromMasterPath(masterPath);
|
|
810
|
+
deletesAttempted += variantPaths.length;
|
|
811
|
+
deleteWarnings.push(...await deleteStoragePaths(variantPaths));
|
|
812
|
+
}
|
|
813
|
+
let masterUrl = null;
|
|
814
|
+
try {
|
|
815
|
+
masterUrl = await uploadMasterImage(masterPath, masterBuffer);
|
|
816
|
+
if (kind === "promoBanner") {
|
|
817
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
818
|
+
"blocks.promoBanner.imageMasterUrl": masterUrl,
|
|
819
|
+
"blocks.promoBanner.imageUrl": masterUrl,
|
|
820
|
+
"blocks.promoBanner.imageVariants": null,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
else if (kind === "videoPlaceholder") {
|
|
824
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
825
|
+
"blocks.video.placeholder.imageMasterUrl": masterUrl,
|
|
826
|
+
"blocks.video.placeholder.imageUrl": masterUrl,
|
|
827
|
+
"blocks.video.placeholder.imageVariants": null,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
await updateCardImageInTransaction(eventId, cardId, {
|
|
832
|
+
imageUrl: masterUrl,
|
|
833
|
+
imageMasterUrl: masterUrl,
|
|
834
|
+
imageVariants: null,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
if (masterUrl) {
|
|
840
|
+
await deleteStoragePaths([masterPath, ...variantPathsFromMasterPath(masterPath)]).catch(() => []);
|
|
841
|
+
}
|
|
842
|
+
throw err;
|
|
843
|
+
}
|
|
844
|
+
if (kind !== "card" || (previousMasterPath && previousMasterPath !== masterPath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
845
|
+
const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
|
|
846
|
+
deletesAttempted += cleanup.attempted;
|
|
847
|
+
deleteWarnings.push(...cleanup.warnings);
|
|
848
|
+
}
|
|
849
|
+
return {
|
|
850
|
+
eventId,
|
|
851
|
+
kind,
|
|
852
|
+
imageId,
|
|
853
|
+
storagePath: masterPath,
|
|
854
|
+
masterUrl,
|
|
855
|
+
variantsPending: true,
|
|
856
|
+
sourceBytes: sourceBuffer.byteLength,
|
|
857
|
+
masterBytes: masterBuffer.byteLength,
|
|
858
|
+
note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
|
|
859
|
+
storageDeletesAttempted: deletesAttempted,
|
|
860
|
+
storageDeleteWarnings: deleteWarnings,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
665
863
|
async function authed(scopes) {
|
|
666
864
|
return ensureAuth(scopes);
|
|
667
865
|
}
|
|
@@ -685,6 +883,9 @@ function setupUrl(eventId) {
|
|
|
685
883
|
function analyticsUrl(eventId) {
|
|
686
884
|
return `https://okostream.app/analytics/${eventId}`;
|
|
687
885
|
}
|
|
886
|
+
function sessionReportUrl(eventId, sessionId) {
|
|
887
|
+
return `${analyticsUrl(eventId)}/report/${sessionId}`;
|
|
888
|
+
}
|
|
688
889
|
function nmoRegisterUrl(eventId) {
|
|
689
890
|
return `${eventUrl(eventId)}/register`;
|
|
690
891
|
}
|
|
@@ -729,6 +930,145 @@ async function readEvent(eventId, uid, mode = "read") {
|
|
|
729
930
|
assertEventReadable(data, uid);
|
|
730
931
|
return data;
|
|
731
932
|
}
|
|
933
|
+
function asRecord(value) {
|
|
934
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
935
|
+
}
|
|
936
|
+
function validateEventSetup(eventId, event) {
|
|
937
|
+
const errors = [];
|
|
938
|
+
const warnings = [];
|
|
939
|
+
const suggestions = [];
|
|
940
|
+
const add = (target, code, path, message) => {
|
|
941
|
+
target.push({ code, path, message });
|
|
942
|
+
};
|
|
943
|
+
const blocks = asRecord(event.blocks);
|
|
944
|
+
const forms = Array.isArray(event.forms) ? event.forms.filter((item) => item && typeof item === "object") : [];
|
|
945
|
+
const formIds = eventFormIds(event);
|
|
946
|
+
const title = safeString(event.title).trim();
|
|
947
|
+
if (!title)
|
|
948
|
+
add(errors, "title_missing", "title", "Event title is required.");
|
|
949
|
+
const video = asRecord(event.video);
|
|
950
|
+
if (!safeString(video.embedUrl).trim()) {
|
|
951
|
+
add(warnings, "video_missing", "video.embedUrl", "No normalized video URL is configured.");
|
|
952
|
+
}
|
|
953
|
+
const meta = asRecord(event.meta);
|
|
954
|
+
const eventDateMs = toMillis(meta.eventDate);
|
|
955
|
+
if (eventDateMs == null) {
|
|
956
|
+
add(suggestions, "date_missing", "meta.eventDate", "Add the webinar date/time if it is known.");
|
|
957
|
+
}
|
|
958
|
+
else if (eventDateMs < Date.now() - 60 * 60 * 1000) {
|
|
959
|
+
add(warnings, "date_in_past", "meta.eventDate", "Configured webinar date appears to be in the past.");
|
|
960
|
+
}
|
|
961
|
+
const duplicateFormIds = new Set();
|
|
962
|
+
const seenFormIds = new Set();
|
|
963
|
+
forms.forEach((form, index) => {
|
|
964
|
+
const id = safeString(form.id).trim();
|
|
965
|
+
if (!id)
|
|
966
|
+
add(errors, "form_id_missing", `forms[${index}].id`, "Every form must have a stable id.");
|
|
967
|
+
else if (seenFormIds.has(id))
|
|
968
|
+
duplicateFormIds.add(id);
|
|
969
|
+
else
|
|
970
|
+
seenFormIds.add(id);
|
|
971
|
+
const fields = Array.isArray(form.fields) ? form.fields : [];
|
|
972
|
+
if (fields.length === 0)
|
|
973
|
+
add(warnings, "form_fields_missing", `forms[${index}].fields`, "Lead form has no fields.");
|
|
974
|
+
});
|
|
975
|
+
for (const id of duplicateFormIds)
|
|
976
|
+
add(errors, "form_id_duplicate", "forms", `Duplicate form id: ${id}.`);
|
|
977
|
+
const checkFormBinding = (path, formId) => {
|
|
978
|
+
const id = safeString(formId).trim();
|
|
979
|
+
if (id && !formIds.has(id))
|
|
980
|
+
add(errors, "form_binding_missing", path, `Referenced formId "${id}" does not exist in event.forms.`);
|
|
981
|
+
};
|
|
982
|
+
const promo = asRecord(blocks.promoBanner);
|
|
983
|
+
if (promo.enabled === true) {
|
|
984
|
+
checkFormBinding("blocks.promoBanner.formId", promo.formId);
|
|
985
|
+
if (!safeString(promo.link).trim() && !safeString(promo.formId).trim()) {
|
|
986
|
+
add(suggestions, "promo_target_missing", "blocks.promoBanner", "Enabled promo banner has no link or form binding.");
|
|
987
|
+
}
|
|
988
|
+
if (!safeString(promo.imageUrl).trim()) {
|
|
989
|
+
add(warnings, "promo_image_missing", "blocks.promoBanner.imageUrl", "Enabled promo banner has no image.");
|
|
990
|
+
}
|
|
991
|
+
else if (safeString(promo.imageMasterUrl).trim() && !readImageVariants(promo.imageVariants)) {
|
|
992
|
+
add(warnings, "promo_variants_pending", "blocks.promoBanner.imageVariants", "Promo banner image variants are not ready yet.");
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const cta = asRecord(blocks.primaryCta);
|
|
996
|
+
if (cta.enabled === true) {
|
|
997
|
+
checkFormBinding("blocks.primaryCta.formId", cta.formId);
|
|
998
|
+
if (!safeString(cta.title).trim() && !safeString(cta.text).trim()) {
|
|
999
|
+
add(suggestions, "cta_copy_missing", "blocks.primaryCta", "Enabled CTA has no title or text.");
|
|
1000
|
+
}
|
|
1001
|
+
if (!safeString(cta.buttonLabel).trim()) {
|
|
1002
|
+
add(warnings, "cta_button_missing", "blocks.primaryCta.buttonLabel", "Enabled CTA has no button label.");
|
|
1003
|
+
}
|
|
1004
|
+
if (safeString(cta.buttonLabel).trim() && !safeString(cta.buttonUrl).trim() && !safeString(cta.formId).trim()) {
|
|
1005
|
+
add(warnings, "cta_target_missing", "blocks.primaryCta", "CTA button has no URL or form binding.");
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
const videoPlaceholder = asRecord(asRecord(blocks.video).placeholder);
|
|
1009
|
+
if (videoPlaceholder.enabled === true && videoPlaceholder.mode === "image") {
|
|
1010
|
+
if (!safeString(videoPlaceholder.imageUrl).trim()) {
|
|
1011
|
+
add(warnings, "placeholder_image_missing", "blocks.video.placeholder.imageUrl", "Image placeholder mode is enabled but no image is configured.");
|
|
1012
|
+
}
|
|
1013
|
+
else if (safeString(videoPlaceholder.imageMasterUrl).trim() && !readImageVariants(videoPlaceholder.imageVariants)) {
|
|
1014
|
+
add(warnings, "placeholder_variants_pending", "blocks.video.placeholder.imageVariants", "Video placeholder image variants are not ready yet.");
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
const cards = readCardsConfig(event);
|
|
1018
|
+
if (cards.enabled) {
|
|
1019
|
+
if (cards.items.length === 0)
|
|
1020
|
+
add(warnings, "cards_empty", "blocks.cards.items", "Cards block is enabled but has no cards.");
|
|
1021
|
+
const cardIds = new Set();
|
|
1022
|
+
cards.items.forEach((card, index) => {
|
|
1023
|
+
const path = `blocks.cards.items[${index}]`;
|
|
1024
|
+
if (cardIds.has(card.id))
|
|
1025
|
+
add(errors, "card_id_duplicate", `${path}.id`, `Duplicate card id: ${card.id}.`);
|
|
1026
|
+
cardIds.add(card.id);
|
|
1027
|
+
if (!card.title && !card.text)
|
|
1028
|
+
add(suggestions, "card_copy_missing", path, "Card has no title or text.");
|
|
1029
|
+
if (card.buttonLabel && !card.buttonUrl && !card.formId)
|
|
1030
|
+
add(warnings, "card_target_missing", path, "Card button has no URL or form binding.");
|
|
1031
|
+
if (card.formId && !formIds.has(card.formId))
|
|
1032
|
+
add(errors, "card_form_missing", `${path}.formId`, `Referenced formId "${card.formId}" does not exist.`);
|
|
1033
|
+
if (card.imageMasterUrl && !card.imageVariants)
|
|
1034
|
+
add(warnings, "card_variants_pending", `${path}.imageVariants`, "Card image variants are not ready yet.");
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
const telegram = asRecord(event.telegram);
|
|
1038
|
+
const telegramChannels = Array.isArray(telegram.channelIds) ? telegram.channelIds : [];
|
|
1039
|
+
if (telegram.enabled === true && telegramChannels.length === 0 && typeof telegram.channelId !== "number") {
|
|
1040
|
+
add(warnings, "telegram_channel_missing", "telegram.channelIds", "Telegram is enabled but no channel is configured.");
|
|
1041
|
+
}
|
|
1042
|
+
if (event.nmoMode === true) {
|
|
1043
|
+
const criteria = asRecord(event.nmoCriteria);
|
|
1044
|
+
const watch = typeof criteria.minWatchTimeRatio === "number" ? criteria.minWatchTimeRatio : 0.8;
|
|
1045
|
+
const checkpoints = typeof criteria.minCheckpointRatio === "number" ? criteria.minCheckpointRatio : 0.8;
|
|
1046
|
+
if (watch < 0 || watch > 1 || checkpoints < 0 || checkpoints > 1) {
|
|
1047
|
+
add(errors, "nmo_criteria_invalid", "nmoCriteria", "NMO criteria ratios must be between 0 and 1.");
|
|
1048
|
+
}
|
|
1049
|
+
const participantUids = Array.isArray(event.participantUids) ? event.participantUids : [];
|
|
1050
|
+
if (participantUids.length === 0) {
|
|
1051
|
+
add(warnings, "nmo_participants_missing", "participantUids", "NMO mode is enabled but no participants are registered yet.");
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
const nextActions = [
|
|
1055
|
+
...errors.map((item) => `Fix error: ${item.message}`),
|
|
1056
|
+
...warnings.slice(0, 5).map((item) => `Review warning: ${item.message}`),
|
|
1057
|
+
];
|
|
1058
|
+
if (nextActions.length === 0)
|
|
1059
|
+
nextActions.push("No blocking setup issues found.");
|
|
1060
|
+
const penalty = errors.length * 25 + warnings.length * 8 + suggestions.length * 2;
|
|
1061
|
+
return {
|
|
1062
|
+
eventId,
|
|
1063
|
+
ok: errors.length === 0,
|
|
1064
|
+
score: Math.max(0, Math.min(100, 100 - penalty)),
|
|
1065
|
+
errors,
|
|
1066
|
+
warnings,
|
|
1067
|
+
suggestions,
|
|
1068
|
+
nextActions,
|
|
1069
|
+
links: eventLinks(eventId, event),
|
|
1070
|
+
};
|
|
1071
|
+
}
|
|
732
1072
|
function canEditEventWithPermissions(perms) {
|
|
733
1073
|
return perms.editSetup === true || perms.controlLive === true || perms.startSession === true;
|
|
734
1074
|
}
|
|
@@ -1200,6 +1540,70 @@ function compactAnalyticsSession(raw, options) {
|
|
|
1200
1540
|
}
|
|
1201
1541
|
return session;
|
|
1202
1542
|
}
|
|
1543
|
+
function numberMetric(value) {
|
|
1544
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1545
|
+
}
|
|
1546
|
+
function formatSeconds(value) {
|
|
1547
|
+
const seconds = numberMetric(value);
|
|
1548
|
+
if (seconds == null)
|
|
1549
|
+
return "n/a";
|
|
1550
|
+
const total = Math.max(0, Math.round(seconds));
|
|
1551
|
+
const h = Math.floor(total / 3600);
|
|
1552
|
+
const m = Math.floor((total % 3600) / 60);
|
|
1553
|
+
const s = total % 60;
|
|
1554
|
+
if (h > 0)
|
|
1555
|
+
return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
|
|
1556
|
+
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
1557
|
+
}
|
|
1558
|
+
function formatDateTime(value) {
|
|
1559
|
+
const millis = toMillis(value);
|
|
1560
|
+
if (millis == null)
|
|
1561
|
+
return "n/a";
|
|
1562
|
+
return new Date(millis).toISOString();
|
|
1563
|
+
}
|
|
1564
|
+
function markdownMetric(label, value, suffix = "") {
|
|
1565
|
+
const metric = value === null || value === undefined || value === "" ? "n/a" : String(value);
|
|
1566
|
+
return `- ${label}: ${metric}${suffix}`;
|
|
1567
|
+
}
|
|
1568
|
+
function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
|
|
1569
|
+
const lines = [
|
|
1570
|
+
`# OKO session report`,
|
|
1571
|
+
"",
|
|
1572
|
+
`Event: ${safeString(event.title, eventId)}`,
|
|
1573
|
+
`Event URL: ${eventUrl(eventId)}`,
|
|
1574
|
+
`Report URL: ${sessionReportUrl(eventId, sessionId)}`,
|
|
1575
|
+
`Session: ${sessionId}`,
|
|
1576
|
+
"",
|
|
1577
|
+
"## Timing",
|
|
1578
|
+
markdownMetric("Started", formatDateTime(session.startedAt)),
|
|
1579
|
+
markdownMetric("Ended", formatDateTime(session.endedAt)),
|
|
1580
|
+
markdownMetric("Duration", formatSeconds(session.durationSec)),
|
|
1581
|
+
"",
|
|
1582
|
+
"## Audience",
|
|
1583
|
+
markdownMetric("Peak viewers", session.peakViewers),
|
|
1584
|
+
markdownMetric("Unique visitors", session.uniqueVisitors),
|
|
1585
|
+
markdownMetric("Views", session.viewCount),
|
|
1586
|
+
markdownMetric("Average watch time", formatSeconds(session.avgTimeSec)),
|
|
1587
|
+
markdownMetric("Median watch time", formatSeconds(session.medianTimeSec)),
|
|
1588
|
+
"",
|
|
1589
|
+
"## Engagement",
|
|
1590
|
+
markdownMetric("Chat messages", session.chatMessages),
|
|
1591
|
+
markdownMetric("Banner impressions", session.bannerImpressions),
|
|
1592
|
+
markdownMetric("Banner clicks", session.bannerClicks),
|
|
1593
|
+
markdownMetric("Banner CTR", session.bannerCtr, session.bannerCtr == null ? "" : "%"),
|
|
1594
|
+
markdownMetric("CTA clicks", session.ctaClicks),
|
|
1595
|
+
markdownMetric("Cards clicks", session.cardsClicks),
|
|
1596
|
+
];
|
|
1597
|
+
const nmo = session.nmoEvaluation;
|
|
1598
|
+
if (nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
|
|
1599
|
+
const summary = nmo.summary;
|
|
1600
|
+
if (summary && typeof summary === "object" && !Array.isArray(summary)) {
|
|
1601
|
+
const s = summary;
|
|
1602
|
+
lines.push("", "## NMO", markdownMetric("Passed", s.passed), markdownMetric("Failed", s.failed), markdownMetric("No-show", s.noShow), markdownMetric("Total", s.total));
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
return lines.join("\n");
|
|
1606
|
+
}
|
|
1203
1607
|
export function registerTools(server) {
|
|
1204
1608
|
server.registerTool("oko_whoami", {
|
|
1205
1609
|
title: "Who am I",
|
|
@@ -1217,6 +1621,39 @@ export function registerTools(server) {
|
|
|
1217
1621
|
const auth = await authed([]);
|
|
1218
1622
|
return textResult({ uid: auth.uid, scopes: currentScopes() });
|
|
1219
1623
|
});
|
|
1624
|
+
server.registerTool("oko_update_profile", {
|
|
1625
|
+
title: "Update profile",
|
|
1626
|
+
description: toolDescription({
|
|
1627
|
+
summary: "Update the authenticated organizer profile fields used by OKO UI.",
|
|
1628
|
+
args: [
|
|
1629
|
+
"displayName: optional display name, max 40 chars; null clears it.",
|
|
1630
|
+
"timezone: optional IANA timezone such as Europe/Moscow or UTC.",
|
|
1631
|
+
],
|
|
1632
|
+
returns: "uid, updated field names, and the resulting profile snapshot.",
|
|
1633
|
+
examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
|
|
1634
|
+
notes: ["This edits only the current organizer's own userProfiles document and does not require extra workspace/event scopes."],
|
|
1635
|
+
}),
|
|
1636
|
+
inputSchema: {
|
|
1637
|
+
displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
|
|
1638
|
+
timezone: timezoneSchema.optional().describe("IANA timezone, for example Europe/Moscow or UTC."),
|
|
1639
|
+
},
|
|
1640
|
+
outputSchema: profileOutputSchema,
|
|
1641
|
+
annotations: idempotentWriteTool,
|
|
1642
|
+
}, async ({ displayName, timezone }) => {
|
|
1643
|
+
const { uid } = await authed([]);
|
|
1644
|
+
const patch = { updatedAt: serverTimestamp() };
|
|
1645
|
+
if (displayName !== undefined)
|
|
1646
|
+
patch.displayName = displayName?.trim() || null;
|
|
1647
|
+
if (timezone !== undefined)
|
|
1648
|
+
patch.timezone = timezone.trim();
|
|
1649
|
+
const updated = Object.keys(patch).filter((key) => key !== "updatedAt");
|
|
1650
|
+
if (updated.length === 0)
|
|
1651
|
+
throw new Error("Provide displayName and/or timezone.");
|
|
1652
|
+
const profileRef = doc(firestore, "userProfiles", uid);
|
|
1653
|
+
await setDoc(profileRef, patch, { merge: true });
|
|
1654
|
+
const profileSnap = await getDoc(profileRef);
|
|
1655
|
+
return textResult({ uid, updated, profile: profileSnap.exists() ? serialize(profileSnap.data()) : {} });
|
|
1656
|
+
});
|
|
1220
1657
|
server.registerTool("oko_list_workspaces", {
|
|
1221
1658
|
title: "List workspaces",
|
|
1222
1659
|
description: toolDescription({
|
|
@@ -1487,6 +1924,26 @@ export function registerTools(server) {
|
|
|
1487
1924
|
const data = await readEvent(eventId, uid, "read");
|
|
1488
1925
|
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId, data) });
|
|
1489
1926
|
});
|
|
1927
|
+
server.registerTool("oko_validate_event_setup", {
|
|
1928
|
+
title: "Validate event setup",
|
|
1929
|
+
description: toolDescription({
|
|
1930
|
+
summary: "Run a read-only setup readiness lint for one OKO event.",
|
|
1931
|
+
args: ["eventId: OKO event document id."],
|
|
1932
|
+
returns: "ok flag, score, errors, warnings, suggestions, next actions, and useful links.",
|
|
1933
|
+
examples: [
|
|
1934
|
+
"Use after creating/configuring a webinar and before telling the organizer it is ready.",
|
|
1935
|
+
"Use before live-session prep to catch missing video, pending image variants, invalid form bindings, or NMO participant gaps.",
|
|
1936
|
+
],
|
|
1937
|
+
notes: ["This tool does not modify the event and does not start/end analytics sessions."],
|
|
1938
|
+
}),
|
|
1939
|
+
inputSchema: { eventId: eventIdField },
|
|
1940
|
+
outputSchema: setupValidationOutputSchema,
|
|
1941
|
+
annotations: readOnlyTool,
|
|
1942
|
+
}, async ({ eventId }) => {
|
|
1943
|
+
const { uid } = await authed(["events:read"]);
|
|
1944
|
+
const data = await readEvent(eventId, uid, "read");
|
|
1945
|
+
return textResult(validateEventSetup(eventId, data));
|
|
1946
|
+
});
|
|
1490
1947
|
server.registerTool("oko_create_webinar", {
|
|
1491
1948
|
title: "Create webinar",
|
|
1492
1949
|
description: toolDescription({
|
|
@@ -1813,72 +2270,89 @@ export function registerTools(server) {
|
|
|
1813
2270
|
annotations: writeTool,
|
|
1814
2271
|
}, async ({ eventId, kind, cardId, source }) => {
|
|
1815
2272
|
const { uid } = await authed(["setup:write"]);
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
}
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
imageVariants: null,
|
|
1853
|
-
});
|
|
1854
|
-
}
|
|
1855
|
-
await updateDoc(doc(firestore, "events", eventId), patch);
|
|
1856
|
-
}
|
|
1857
|
-
catch (err) {
|
|
1858
|
-
if (masterUrl) {
|
|
1859
|
-
await deleteStoragePaths([masterPath, ...variantPathsFromMasterPath(masterPath)]).catch(() => []);
|
|
1860
|
-
}
|
|
1861
|
-
throw err;
|
|
1862
|
-
}
|
|
1863
|
-
if (kind !== "card" || (previousMasterPath && previousMasterPath !== masterPath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
1864
|
-
const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
|
|
1865
|
-
deletesAttempted += cleanup.attempted;
|
|
1866
|
-
deleteWarnings.push(...cleanup.warnings);
|
|
2273
|
+
return textResult(await uploadEventImageForUser(uid, eventId, kind, cardId, source));
|
|
2274
|
+
});
|
|
2275
|
+
server.registerTool("oko_upload_card_images_batch", {
|
|
2276
|
+
title: "Upload card images batch",
|
|
2277
|
+
description: toolDescription({
|
|
2278
|
+
summary: "Upload images for multiple existing cards, preserving card metadata with Firestore transactions and waiting for generated variants.",
|
|
2279
|
+
args: [
|
|
2280
|
+
"eventId: event to edit.",
|
|
2281
|
+
"images: 1-6 card image uploads. Each cardId must already exist in blocks.cards.items.",
|
|
2282
|
+
"timeoutSec: max wait for all CF-generated image variants, default 60 seconds.",
|
|
2283
|
+
],
|
|
2284
|
+
returns: "eventId, per-card upload details, per-card variant readiness, ready flag, and warnings.",
|
|
2285
|
+
examples: [
|
|
2286
|
+
"Use after oko_configure_cards has returned stable card ids.",
|
|
2287
|
+
"Use for webinar cards where an agent generated several local images and needs to attach all of them.",
|
|
2288
|
+
],
|
|
2289
|
+
notes: [
|
|
2290
|
+
"This is a workflow tool for cards only. Use oko_upload_image for promoBanner or videoPlaceholder.",
|
|
2291
|
+
"The tool does not create cards; it only attaches images to existing cards.",
|
|
2292
|
+
],
|
|
2293
|
+
}),
|
|
2294
|
+
inputSchema: {
|
|
2295
|
+
eventId: eventIdField,
|
|
2296
|
+
images: z.array(z.object({
|
|
2297
|
+
cardId: cardIdSchema.describe("Existing card id."),
|
|
2298
|
+
source: imageSourceSchema.describe("Image source for this card."),
|
|
2299
|
+
}).strict()).min(1).max(MAX_CARD_ITEMS).describe("Card images to upload."),
|
|
2300
|
+
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait for all generated variants."),
|
|
2301
|
+
},
|
|
2302
|
+
outputSchema: cardImagesBatchOutputSchema,
|
|
2303
|
+
annotations: writeTool,
|
|
2304
|
+
}, async ({ eventId, images, timeoutSec }) => {
|
|
2305
|
+
const { uid } = await authed(["setup:write"]);
|
|
2306
|
+
const uploads = [];
|
|
2307
|
+
for (const image of images) {
|
|
2308
|
+
uploads.push(await uploadEventImageForUser(uid, eventId, "card", image.cardId, image.source));
|
|
1867
2309
|
}
|
|
2310
|
+
const variants = await waitForImageVariantTargets(uid, eventId, images.map((image) => ({ kind: "card", cardId: image.cardId })), timeoutSec);
|
|
2311
|
+
const warnings = variants
|
|
2312
|
+
.filter((item) => !item.ready)
|
|
2313
|
+
.map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`);
|
|
1868
2314
|
return textResult({
|
|
1869
2315
|
eventId,
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
variantsPending: true,
|
|
1875
|
-
sourceBytes: sourceBuffer.byteLength,
|
|
1876
|
-
masterBytes: masterBuffer.byteLength,
|
|
1877
|
-
note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
|
|
1878
|
-
storageDeletesAttempted: deletesAttempted,
|
|
1879
|
-
storageDeleteWarnings: deleteWarnings,
|
|
2316
|
+
uploads,
|
|
2317
|
+
variants,
|
|
2318
|
+
ready: variants.every((item) => item.ready),
|
|
2319
|
+
warnings,
|
|
1880
2320
|
});
|
|
1881
2321
|
});
|
|
2322
|
+
server.registerTool("oko_wait_for_image_variants", {
|
|
2323
|
+
title: "Wait for image variants",
|
|
2324
|
+
description: toolDescription({
|
|
2325
|
+
summary: "Poll an event image slot until Cloud Function generated thumb/card/hero variants are visible in Firestore.",
|
|
2326
|
+
args: [
|
|
2327
|
+
"eventId: event to inspect.",
|
|
2328
|
+
"kind: promoBanner, videoPlaceholder, or card.",
|
|
2329
|
+
"cardId: required when kind=card.",
|
|
2330
|
+
"timeoutSec: max wait, default 60 seconds.",
|
|
2331
|
+
],
|
|
2332
|
+
returns: "image URLs, imageVariants, ready flag, pending reason, and elapsed time.",
|
|
2333
|
+
examples: ["Use after oko_upload_image to avoid checking the UI before image variants are ready."],
|
|
2334
|
+
notes: ["Read-only. It does not trigger variant generation; it only waits for CF4's Firestore update."],
|
|
2335
|
+
}),
|
|
2336
|
+
inputSchema: {
|
|
2337
|
+
eventId: eventIdField,
|
|
2338
|
+
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
2339
|
+
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
2340
|
+
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait."),
|
|
2341
|
+
},
|
|
2342
|
+
outputSchema: imageVariantsOutputSchema,
|
|
2343
|
+
annotations: readOnlyTool,
|
|
2344
|
+
}, async ({ eventId, kind, cardId, timeoutSec }) => {
|
|
2345
|
+
const { uid } = await authed(["events:read"]);
|
|
2346
|
+
if (kind === "card" && !cardId)
|
|
2347
|
+
throw new Error("cardId is required when kind is card.");
|
|
2348
|
+
if (kind !== "card" && cardId)
|
|
2349
|
+
throw new Error("cardId is only valid when kind is card.");
|
|
2350
|
+
const statuses = await waitForImageVariantTargets(uid, eventId, [{ kind, cardId: cardId ?? null }], timeoutSec);
|
|
2351
|
+
const status = statuses[0];
|
|
2352
|
+
if (!status)
|
|
2353
|
+
throw new Error("Image variant status was not produced.");
|
|
2354
|
+
return textResult(status);
|
|
2355
|
+
});
|
|
1882
2356
|
server.registerTool("oko_delete_image", {
|
|
1883
2357
|
title: "Delete event image",
|
|
1884
2358
|
description: toolDescription({
|
|
@@ -1911,25 +2385,27 @@ export function registerTools(server) {
|
|
|
1911
2385
|
const previous = readImageRef(event, kind, cardId);
|
|
1912
2386
|
requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
|
|
1913
2387
|
const cleanup = await deleteStorageUrls(previous.imageMasterUrl, previous.imageUrl);
|
|
1914
|
-
const patch = {};
|
|
1915
2388
|
if (kind === "promoBanner") {
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
2389
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
2390
|
+
"blocks.promoBanner.imageMasterUrl": deleteField(),
|
|
2391
|
+
"blocks.promoBanner.imageUrl": deleteField(),
|
|
2392
|
+
"blocks.promoBanner.imageVariants": deleteField(),
|
|
2393
|
+
});
|
|
1919
2394
|
}
|
|
1920
2395
|
else if (kind === "videoPlaceholder") {
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
2396
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
2397
|
+
"blocks.video.placeholder.imageMasterUrl": deleteField(),
|
|
2398
|
+
"blocks.video.placeholder.imageUrl": deleteField(),
|
|
2399
|
+
"blocks.video.placeholder.imageVariants": deleteField(),
|
|
2400
|
+
});
|
|
1924
2401
|
}
|
|
1925
2402
|
else {
|
|
1926
|
-
|
|
2403
|
+
await updateCardImageInTransaction(eventId, cardId, {
|
|
1927
2404
|
imageUrl: null,
|
|
1928
2405
|
imageMasterUrl: null,
|
|
1929
2406
|
imageVariants: null,
|
|
1930
2407
|
});
|
|
1931
2408
|
}
|
|
1932
|
-
await updateDoc(doc(firestore, "events", eventId), patch);
|
|
1933
2409
|
return textResult({
|
|
1934
2410
|
eventId,
|
|
1935
2411
|
kind,
|
|
@@ -2505,6 +2981,65 @@ export function registerTools(server) {
|
|
|
2505
2981
|
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
|
|
2506
2982
|
return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots/includeNmoRows disabled." });
|
|
2507
2983
|
});
|
|
2984
|
+
server.registerTool("oko_get_session_report", {
|
|
2985
|
+
title: "Get session report",
|
|
2986
|
+
description: toolDescription({
|
|
2987
|
+
summary: "Build a ready-to-share report for one analytics session, with compact structured data and markdown.",
|
|
2988
|
+
args: [
|
|
2989
|
+
"eventId: event to inspect.",
|
|
2990
|
+
"sessionId: optional analytics session id; omitted means latest session.",
|
|
2991
|
+
"includeTimeline: include recent timeline buckets; default false.",
|
|
2992
|
+
"timelineLimit: max timeline buckets when includeTimeline=true.",
|
|
2993
|
+
"includeSnapshots: include baseline/final snapshots; default false.",
|
|
2994
|
+
"includeNmoRows: include full NMO participant rows; default false.",
|
|
2995
|
+
],
|
|
2996
|
+
returns: "event/session ids, report URL, markdown summary, event summary, and compact session object.",
|
|
2997
|
+
examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
|
|
2998
|
+
}),
|
|
2999
|
+
inputSchema: {
|
|
3000
|
+
eventId: eventIdField,
|
|
3001
|
+
sessionId: z.string().min(1).optional().describe("analyticsSessions document id. Omit to use the latest session."),
|
|
3002
|
+
includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets."),
|
|
3003
|
+
timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets when includeTimeline=true."),
|
|
3004
|
+
includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
|
|
3005
|
+
includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
|
|
3006
|
+
},
|
|
3007
|
+
outputSchema: sessionReportOutputSchema,
|
|
3008
|
+
annotations: readOnlyTool,
|
|
3009
|
+
}, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots, includeNmoRows }) => {
|
|
3010
|
+
const { uid } = await authed(["analytics:read"]);
|
|
3011
|
+
const event = await readEvent(eventId, uid, "read");
|
|
3012
|
+
let resolvedSessionId = sessionId;
|
|
3013
|
+
let sessionData = null;
|
|
3014
|
+
if (resolvedSessionId) {
|
|
3015
|
+
const sessionSnap = await getDoc(doc(firestore, "events", eventId, "analyticsSessions", resolvedSessionId));
|
|
3016
|
+
if (!sessionSnap.exists())
|
|
3017
|
+
throw new Error("Analytics session not found.");
|
|
3018
|
+
sessionData = sessionSnap.data();
|
|
3019
|
+
}
|
|
3020
|
+
else {
|
|
3021
|
+
const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), orderBy("startedAt", "desc"), limit(1)));
|
|
3022
|
+
const first = snap.docs[0];
|
|
3023
|
+
if (!first)
|
|
3024
|
+
throw new Error("No analytics sessions found for this event.");
|
|
3025
|
+
resolvedSessionId = first.id;
|
|
3026
|
+
sessionData = first.data();
|
|
3027
|
+
}
|
|
3028
|
+
const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots, includeNmoRows });
|
|
3029
|
+
const markdown = sessionReportMarkdown({ eventId, event, sessionId: resolvedSessionId, session });
|
|
3030
|
+
return textResult({
|
|
3031
|
+
eventId,
|
|
3032
|
+
sessionId: resolvedSessionId,
|
|
3033
|
+
event: {
|
|
3034
|
+
id: eventId,
|
|
3035
|
+
title: safeString(event.title, eventId),
|
|
3036
|
+
...eventLinks(eventId, event),
|
|
3037
|
+
},
|
|
3038
|
+
reportUrl: sessionReportUrl(eventId, resolvedSessionId),
|
|
3039
|
+
markdown,
|
|
3040
|
+
session,
|
|
3041
|
+
}, { truncationHint: "Keep includeTimeline/includeSnapshots/includeNmoRows disabled or lower timelineLimit." });
|
|
3042
|
+
});
|
|
2508
3043
|
server.registerTool("oko_list_recent_chat", {
|
|
2509
3044
|
title: "List recent chat",
|
|
2510
3045
|
description: toolDescription({
|
|
@@ -2910,6 +3445,108 @@ export function registerTools(server) {
|
|
|
2910
3445
|
}
|
|
2911
3446
|
return textResult({ leads, ...meta });
|
|
2912
3447
|
});
|
|
3448
|
+
server.registerTool("oko_delete_lead", {
|
|
3449
|
+
title: "Delete form lead",
|
|
3450
|
+
description: toolDescription({
|
|
3451
|
+
summary: "Delete one lead/form submission from an event.",
|
|
3452
|
+
args: ["eventId: event id.", "leadId: events/{eventId}/leads document id.", "confirm: must be true."],
|
|
3453
|
+
returns: "eventId, leadId, deleted=true, and a small preview of the deleted lead.",
|
|
3454
|
+
examples: ["Use after listing leads and confirming the exact submission to remove."],
|
|
3455
|
+
notes: ["Destructive. Requires setup:write in addition to leads:read so read-only keys cannot delete leads."],
|
|
3456
|
+
}),
|
|
3457
|
+
inputSchema: {
|
|
3458
|
+
eventId: eventIdField,
|
|
3459
|
+
leadId: z.string().min(1).max(160).describe("Lead document id under events/{eventId}/leads."),
|
|
3460
|
+
confirm: z.boolean().optional().describe("Required true for lead deletion."),
|
|
3461
|
+
},
|
|
3462
|
+
outputSchema: deleteLeadOutputSchema,
|
|
3463
|
+
annotations: destructiveTool,
|
|
3464
|
+
}, async ({ eventId, leadId, confirm }) => {
|
|
3465
|
+
const { uid } = await authed(["leads:read", "setup:write"]);
|
|
3466
|
+
await readEvent(eventId, uid, "edit");
|
|
3467
|
+
const leadRef = doc(firestore, "events", eventId, "leads", leadId);
|
|
3468
|
+
const leadSnap = await getDoc(leadRef);
|
|
3469
|
+
if (!leadSnap.exists())
|
|
3470
|
+
throw new Error("Lead not found.");
|
|
3471
|
+
const lead = serialize(leadSnap.data());
|
|
3472
|
+
requireConfirm(confirm, "oko_delete_lead", { eventId, leadId, formId: lead.formId, source: lead.source, submittedAt: lead.submittedAt });
|
|
3473
|
+
await deleteDoc(leadRef);
|
|
3474
|
+
return textResult({ eventId, leadId, deleted: true, leadPreview: lead });
|
|
3475
|
+
});
|
|
3476
|
+
server.registerTool("oko_list_notifications", {
|
|
3477
|
+
title: "List notifications",
|
|
3478
|
+
description: toolDescription({
|
|
3479
|
+
summary: "List in-app notifications for the authenticated organizer.",
|
|
3480
|
+
args: [
|
|
3481
|
+
"unreadOnly: only unread notifications, default false.",
|
|
3482
|
+
"limit: max notifications, default 20, max 100.",
|
|
3483
|
+
"cursor: createdAt cursor returned as nextCursor by the previous page.",
|
|
3484
|
+
],
|
|
3485
|
+
returns: "notifications plus unreadCountOnPage and cursor pagination metadata.",
|
|
3486
|
+
examples: ["Use when the organizer asks what changed in OKO or what notifications are waiting."],
|
|
3487
|
+
}),
|
|
3488
|
+
inputSchema: {
|
|
3489
|
+
unreadOnly: z.boolean().default(false).describe("Return only unread notifications."),
|
|
3490
|
+
limit: limitField(100, 20),
|
|
3491
|
+
cursor: cursorField,
|
|
3492
|
+
},
|
|
3493
|
+
outputSchema: notificationsOutputSchema,
|
|
3494
|
+
annotations: readOnlyTool,
|
|
3495
|
+
}, async ({ unreadOnly, limit: max, cursor }) => {
|
|
3496
|
+
const { uid } = await authed(["workspace:read"]);
|
|
3497
|
+
const fetchLimit = unreadOnly ? Math.min(max * 5 + 1, 501) : max + 1;
|
|
3498
|
+
const constraints = [
|
|
3499
|
+
orderBy("createdAt", "desc"),
|
|
3500
|
+
limit(fetchLimit),
|
|
3501
|
+
];
|
|
3502
|
+
if (cursor)
|
|
3503
|
+
constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
|
|
3504
|
+
const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), ...constraints));
|
|
3505
|
+
const matchingDocs = unreadOnly ? snap.docs.filter((item) => item.data().read !== true) : snap.docs;
|
|
3506
|
+
const { page, hasMore: hasMoreInMatches } = pickPage(matchingDocs, max);
|
|
3507
|
+
const hasMore = hasMoreInMatches || snap.docs.length >= fetchLimit;
|
|
3508
|
+
const cursorSource = hasMoreInMatches ? page.at(-1) : snap.docs.at(-1);
|
|
3509
|
+
const notifications = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
3510
|
+
const nextCursor = hasMore ? timestampCursorFrom(cursorSource?.data().createdAt) : null;
|
|
3511
|
+
return textResult({
|
|
3512
|
+
notifications,
|
|
3513
|
+
unreadCountOnPage: notifications.filter((item) => item.read !== true).length,
|
|
3514
|
+
...cursorMeta(notifications.length, max, hasMore, nextCursor),
|
|
3515
|
+
});
|
|
3516
|
+
});
|
|
3517
|
+
server.registerTool("oko_mark_notifications_read", {
|
|
3518
|
+
title: "Mark notifications read",
|
|
3519
|
+
description: toolDescription({
|
|
3520
|
+
summary: "Mark own OKO in-app notifications as read.",
|
|
3521
|
+
args: [
|
|
3522
|
+
"notificationIds: optional explicit notification ids, max 100.",
|
|
3523
|
+
"allUnread: true to mark unread notifications on the first page, max controlled by limit.",
|
|
3524
|
+
"limit: when allUnread=true, max unread notifications to mark, default 100, max 200.",
|
|
3525
|
+
],
|
|
3526
|
+
returns: "markedRead count and notification ids updated.",
|
|
3527
|
+
examples: ["Use after oko_list_notifications when the user asks to clear the bell/unread state."],
|
|
3528
|
+
notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications."],
|
|
3529
|
+
}),
|
|
3530
|
+
inputSchema: {
|
|
3531
|
+
notificationIds: z.array(z.string().min(1).max(160)).max(100).optional().describe("Specific notification document ids."),
|
|
3532
|
+
allUnread: z.boolean().default(false).describe("Mark unread notifications as read."),
|
|
3533
|
+
limit: limitField(200, 100),
|
|
3534
|
+
},
|
|
3535
|
+
outputSchema: notificationsMarkOutputSchema,
|
|
3536
|
+
annotations: idempotentWriteTool,
|
|
3537
|
+
}, async ({ notificationIds, allUnread, limit: max }) => {
|
|
3538
|
+
const { uid } = await authed(["workspace:read"]);
|
|
3539
|
+
if (Boolean(notificationIds?.length) === allUnread) {
|
|
3540
|
+
throw new Error("Provide either notificationIds or allUnread=true.");
|
|
3541
|
+
}
|
|
3542
|
+
let ids = [...new Set(notificationIds ?? [])];
|
|
3543
|
+
if (allUnread) {
|
|
3544
|
+
const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), where("read", "==", false), limit(max)));
|
|
3545
|
+
ids = snap.docs.map((item) => item.id);
|
|
3546
|
+
}
|
|
3547
|
+
await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
|
|
3548
|
+
return textResult({ markedRead: ids.length, notificationIds: ids });
|
|
3549
|
+
});
|
|
2913
3550
|
server.registerTool("oko_list_telegram_channels", {
|
|
2914
3551
|
title: "List Telegram channels",
|
|
2915
3552
|
description: toolDescription({
|