@okostream/mcp 0.11.1 → 0.11.2
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/onboarding.js +8 -3
- package/dist/server.js +3 -3
- package/dist/tools/account.js +77 -0
- package/dist/tools/audit.js +44 -0
- package/dist/tools/chat.js +206 -0
- package/dist/tools/context.js +40 -0
- package/dist/tools/eventAccess.js +65 -0
- package/dist/tools/eventCreate.js +37 -0
- package/dist/tools/events.js +437 -0
- package/dist/tools/formatting.js +177 -0
- package/dist/tools/imagePipeline.js +541 -0
- package/dist/tools/images.js +205 -0
- package/dist/tools/instrumentation.js +40 -0
- package/dist/tools/leads.js +85 -0
- package/dist/tools/lifecycle.js +161 -0
- package/dist/tools/live.js +190 -0
- package/dist/tools/notifications.js +88 -0
- package/dist/tools/schemas.js +296 -0
- package/dist/tools/sessions.js +298 -0
- package/dist/tools/setupValidation.js +137 -0
- package/dist/tools/telegram.js +279 -0
- package/dist/tools/workspaceAccess.js +55 -0
- package/dist/tools/workspaces.js +292 -0
- package/dist/tools.js +36 -3475
- package/dist/version.js +1 -1
- package/dist/videoUrl.js +8 -4
- package/package.json +2 -1
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// Создание и настройка события: карточка, блоки, карточки-предложения.
|
|
2
|
+
//
|
|
3
|
+
// Список строится по индексу доступа eventAccess/{uid}/grants, а не по
|
|
4
|
+
// массивам события: массивы сняты 31.08.
|
|
5
|
+
//
|
|
6
|
+
// Настройка меняет только Firestore. Живое состояние (что показано зрителю
|
|
7
|
+
// прямо сейчас) живёт в RTDB и правится инструментами live.
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { serialize, textResult } from "../utils.js";
|
|
10
|
+
import { authed, eventLinks, randomId } from "./context.js";
|
|
11
|
+
import { cardsConfigOutputSchema, createWebinarOutputSchema, cursorField, eventIdField, eventOutputSchema, eventsOutputSchema, formSchema, limitField, readOnlyTool, setupValidationOutputSchema, toolDescription, updateOutputSchema, writeTool } from "./schemas.js";
|
|
12
|
+
import { buildCardsItems, CARD_IMAGE_RATIOS, cardHasImage, cardItemInputSchema, eventFormIds, MAX_CARD_ITEMS, readCardsConfig } from "./imagePipeline.js";
|
|
13
|
+
import { collection, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, startAfter, Timestamp, updateDoc, where } from "firebase/firestore";
|
|
14
|
+
import { addEventWithRtdbMirror, assertCanCreateInWorkspace } from "./eventCreate.js";
|
|
15
|
+
import { cursorMeta, normalizeVideo, parseIsoBound, parseTimestampCursor, pickPage, timestampCursorFrom } from "./formatting.js";
|
|
16
|
+
import { firestore } from "../firebase.js";
|
|
17
|
+
import { isFirestorePermissionDenied, readEvent } from "./eventAccess.js";
|
|
18
|
+
import { validateEventSetup } from "./setupValidation.js";
|
|
19
|
+
export function registerEventTools(server) {
|
|
20
|
+
server.registerTool("oko_list_my_events", {
|
|
21
|
+
title: "List my events",
|
|
22
|
+
description: toolDescription({
|
|
23
|
+
summary: "List OKO events visible to the authenticated organizer.",
|
|
24
|
+
args: [
|
|
25
|
+
"limit: maximum events to return, default 30, max 100.",
|
|
26
|
+
"cursor: pagination cursor returned as nextCursor by the previous page; reuse it with the same filters.",
|
|
27
|
+
"workspaceId: optional workspace filter.",
|
|
28
|
+
'detail: "compact" (default) returns the light card projection; "full" returns complete event documents.',
|
|
29
|
+
"from/to: optional ISO timestamps bounding meta.eventDate; sorting switches to eventDate descending and undated events are excluded.",
|
|
30
|
+
],
|
|
31
|
+
returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
32
|
+
examples: [
|
|
33
|
+
"Use limit=10 first; if hasMore is true, call again with cursor=nextCursor.",
|
|
34
|
+
'For "today\'s webinar" pass from/to covering the day; use detail="full" only when block contents are needed.',
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
inputSchema: {
|
|
38
|
+
limit: limitField(),
|
|
39
|
+
cursor: cursorField,
|
|
40
|
+
workspaceId: z.string().optional().describe("Optional workspace id to filter returned events."),
|
|
41
|
+
detail: z.enum(["compact", "full"]).default("compact").describe('Response shape: "compact" card projection (default) or "full" event documents.'),
|
|
42
|
+
from: z.string().optional().describe("Include only events with meta.eventDate at or after this ISO timestamp."),
|
|
43
|
+
to: z.string().optional().describe("Include only events with meta.eventDate at or before this ISO timestamp."),
|
|
44
|
+
},
|
|
45
|
+
outputSchema: eventsOutputSchema,
|
|
46
|
+
annotations: readOnlyTool,
|
|
47
|
+
}, async (args) => {
|
|
48
|
+
const { uid } = await authed(["events:read"]);
|
|
49
|
+
const fromTs = parseIsoBound(args.from, "from");
|
|
50
|
+
const toTs = parseIsoBound(args.to, "to");
|
|
51
|
+
const dateFiltered = fromTs !== null || toTs !== null;
|
|
52
|
+
const orderField = dateFiltered ? "eventDate" : "createdAt";
|
|
53
|
+
const constraints = [
|
|
54
|
+
// При датном фильтре workspaceId отсеивается по загруженной странице:
|
|
55
|
+
// составного индекса grants(workspaceId+eventDate) нет намеренно.
|
|
56
|
+
...(!dateFiltered && args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
|
|
57
|
+
...(fromTs ? [where("eventDate", ">=", fromTs)] : []),
|
|
58
|
+
...(toTs ? [where("eventDate", "<=", toTs)] : []),
|
|
59
|
+
orderBy(orderField, "desc"),
|
|
60
|
+
limit(args.limit + 1),
|
|
61
|
+
];
|
|
62
|
+
if (args.cursor)
|
|
63
|
+
constraints.splice(constraints.length - 1, 0, startAfter(parseTimestampCursor(args.cursor)));
|
|
64
|
+
const snap = await getDocs(query(collection(firestore, "eventAccess", uid, "grants"), ...constraints)).catch((error) => {
|
|
65
|
+
// Отказ в чтении собственных grants — это не повод молча уйти на
|
|
66
|
+
// legacy-путь: с 31.08 он мёртв вдвойне (правило `allow list` оставлено
|
|
67
|
+
// только владельцу платформы, а девять массивов сняты с документов).
|
|
68
|
+
// Фолбэк дал бы вторую, непонятную ошибку вместо настоящей причины.
|
|
69
|
+
if (!isFirestorePermissionDenied(error))
|
|
70
|
+
throw error;
|
|
71
|
+
throw new Error("Не удалось прочитать eventAccess/grants организатора. Права событий "
|
|
72
|
+
+ "живут только в индексе; проверь, что ключ активен и его область "
|
|
73
|
+
+ "доступа включает events:read.");
|
|
74
|
+
});
|
|
75
|
+
if (snap !== null) {
|
|
76
|
+
const { page, hasMore } = pickPage(snap.docs, args.limit);
|
|
77
|
+
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data()[orderField]) : null;
|
|
78
|
+
const pageItems = dateFiltered && args.workspaceId
|
|
79
|
+
? page.filter((item) => item.data().workspaceId === args.workspaceId)
|
|
80
|
+
: page;
|
|
81
|
+
let events;
|
|
82
|
+
if (args.detail === "full") {
|
|
83
|
+
const eventDocuments = await Promise.all(pageItems.map((item) => (getDoc(doc(firestore, "events", item.id)))));
|
|
84
|
+
events = eventDocuments.flatMap((item) => (item.exists()
|
|
85
|
+
? [{ id: item.id, ...serialize(item.data()) }]
|
|
86
|
+
: []));
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
// Компактный режим отдаёт grant-проекцию как есть — без чтения
|
|
90
|
+
// полных документов событий.
|
|
91
|
+
events = pageItems.map((item) => {
|
|
92
|
+
const data = serialize(item.data());
|
|
93
|
+
delete data.schemaVersion;
|
|
94
|
+
delete data.uid;
|
|
95
|
+
delete data.eventId;
|
|
96
|
+
return { id: item.id, ...data };
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
|
|
100
|
+
}
|
|
101
|
+
// Legacy-путь (запрос событий по accessibleBy) снят 31.08 вместе с самими
|
|
102
|
+
// массивами: правило `allow list` оставлено только владельцу платформы, а
|
|
103
|
+
// поля стёрты со всех документов. Ветка была недостижима — выше остаётся
|
|
104
|
+
// единственный источник списка, индекс grants.
|
|
105
|
+
throw new Error("Список событий не получен: индекс eventAccess вернул пустой результат.");
|
|
106
|
+
});
|
|
107
|
+
server.registerTool("oko_get_event", {
|
|
108
|
+
title: "Get event",
|
|
109
|
+
description: toolDescription({
|
|
110
|
+
summary: "Read one OKO event visible to the organizer.",
|
|
111
|
+
args: ["eventId: OKO event document id."],
|
|
112
|
+
returns: "full event document fields plus id and public event URL.",
|
|
113
|
+
examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
|
|
114
|
+
}),
|
|
115
|
+
inputSchema: { eventId: eventIdField },
|
|
116
|
+
outputSchema: eventOutputSchema,
|
|
117
|
+
annotations: readOnlyTool,
|
|
118
|
+
}, async ({ eventId }) => {
|
|
119
|
+
const { uid } = await authed(["events:read"]);
|
|
120
|
+
const data = await readEvent(eventId, uid, "read");
|
|
121
|
+
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId) });
|
|
122
|
+
});
|
|
123
|
+
server.registerTool("oko_validate_event_setup", {
|
|
124
|
+
title: "Validate event setup",
|
|
125
|
+
description: toolDescription({
|
|
126
|
+
summary: "Run a read-only setup readiness lint for one OKO event.",
|
|
127
|
+
args: ["eventId: OKO event document id."],
|
|
128
|
+
returns: "ok flag, score, errors, warnings, suggestions, next actions, and useful links.",
|
|
129
|
+
examples: [
|
|
130
|
+
"Use after creating/configuring a webinar and before telling the organizer it is ready.",
|
|
131
|
+
"Use before live-session prep to catch missing video, pending image variants, and invalid form bindings.",
|
|
132
|
+
],
|
|
133
|
+
notes: ["This tool does not modify the event and does not start/end analytics sessions."],
|
|
134
|
+
}),
|
|
135
|
+
inputSchema: { eventId: eventIdField },
|
|
136
|
+
outputSchema: setupValidationOutputSchema,
|
|
137
|
+
annotations: readOnlyTool,
|
|
138
|
+
}, async ({ eventId }) => {
|
|
139
|
+
const { uid } = await authed(["events:read"]);
|
|
140
|
+
const data = await readEvent(eventId, uid, "read");
|
|
141
|
+
return textResult(validateEventSetup(eventId, data));
|
|
142
|
+
});
|
|
143
|
+
server.registerTool("oko_create_webinar", {
|
|
144
|
+
title: "Create webinar",
|
|
145
|
+
description: toolDescription({
|
|
146
|
+
summary: "Create an OKO webinar and its required RTDB owner/workspace mirror.",
|
|
147
|
+
args: [
|
|
148
|
+
"title: webinar title.",
|
|
149
|
+
"workspaceId: optional workspace id; omitted means personal workspace.",
|
|
150
|
+
"videoUrl: optional iframe/source/player URL; normalized by OKO.",
|
|
151
|
+
"eventDate: optional ISO datetime.",
|
|
152
|
+
"chatEnabled: whether the chat block is enabled at creation.",
|
|
153
|
+
],
|
|
154
|
+
returns: "new event id, public event URL, and workspaceId.",
|
|
155
|
+
examples: ["Use for a complete create flow, then call oko_configure_blocks and oko_get_event."],
|
|
156
|
+
notes: ["This creates an event page; OKO has no draft/live/ended event status."],
|
|
157
|
+
}),
|
|
158
|
+
inputSchema: {
|
|
159
|
+
title: z.string().min(1).max(160).describe("Webinar title shown on the public event page."),
|
|
160
|
+
workspaceId: z.string().optional().describe("Workspace id; omit for the organizer's personal workspace."),
|
|
161
|
+
videoUrl: z.string().nullable().optional().describe("Optional source/player/iframe URL to normalize."),
|
|
162
|
+
eventDate: z.string().datetime().nullable().optional().describe("Optional webinar datetime in ISO format."),
|
|
163
|
+
speakerName: z.string().max(160).nullable().optional().describe("Optional speaker display name."),
|
|
164
|
+
description: z.string().max(3000).nullable().optional().describe("Optional event description."),
|
|
165
|
+
chatEnabled: z.boolean().default(false).describe("Whether the chat block starts enabled."),
|
|
166
|
+
},
|
|
167
|
+
outputSchema: createWebinarOutputSchema,
|
|
168
|
+
annotations: writeTool,
|
|
169
|
+
}, async (args) => {
|
|
170
|
+
const { uid } = await authed(["events:write"]);
|
|
171
|
+
const workspaceId = args.workspaceId ?? uid;
|
|
172
|
+
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
173
|
+
const video = normalizeVideo(args.videoUrl);
|
|
174
|
+
// Девять массивов доступа не пишутся (31.08): grants строит CF7 из
|
|
175
|
+
// владельца и shares рабочего пространства.
|
|
176
|
+
const docRef = await addEventWithRtdbMirror({
|
|
177
|
+
ownerId: uid,
|
|
178
|
+
workspaceId,
|
|
179
|
+
createdAt: serverTimestamp(),
|
|
180
|
+
title: args.title.trim(),
|
|
181
|
+
pinned: false,
|
|
182
|
+
video,
|
|
183
|
+
meta: {
|
|
184
|
+
eventDate: args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null,
|
|
185
|
+
speakerName: args.speakerName?.trim() || null,
|
|
186
|
+
description: args.description?.trim() || null,
|
|
187
|
+
},
|
|
188
|
+
blocks: {
|
|
189
|
+
chat: { enabled: args.chatEnabled, visibility: "always" },
|
|
190
|
+
promoBanner: { enabled: false, text: "", link: "", visibility: "always" },
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
return textResult({ id: docRef.id, ...eventLinks(docRef.id), workspaceId });
|
|
194
|
+
});
|
|
195
|
+
server.registerTool("oko_update_event", {
|
|
196
|
+
title: "Update event metadata",
|
|
197
|
+
description: toolDescription({
|
|
198
|
+
summary: "Update safe top-level event/setup metadata fields.",
|
|
199
|
+
args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned, sessionMode, durationMinutes."],
|
|
200
|
+
returns: "event id and the list of Firestore fields updated.",
|
|
201
|
+
examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for blocks and forms."],
|
|
202
|
+
notes: [
|
|
203
|
+
"sessionMode 'schedule' makes OKO start and end the analytics session automatically at eventDate for durationMinutes; 'manual' keeps the Start/End buttons and a Telegram reminder 10 minutes before eventDate. This is still an analytics session, not an event status.",
|
|
204
|
+
],
|
|
205
|
+
}),
|
|
206
|
+
inputSchema: {
|
|
207
|
+
eventId: eventIdField,
|
|
208
|
+
title: z.string().min(1).max(160).optional().describe("New webinar title."),
|
|
209
|
+
videoUrl: z.string().nullable().optional().describe("New source/player/iframe URL, or null to clear."),
|
|
210
|
+
eventDate: z.string().datetime().nullable().optional().describe("New ISO datetime, or null to clear."),
|
|
211
|
+
speakerName: z.string().max(160).nullable().optional().describe("New speaker display name, or null to clear."),
|
|
212
|
+
description: z.string().max(3000).nullable().optional().describe("New description, or null to clear."),
|
|
213
|
+
pinned: z.boolean().optional().describe("Whether this event is pinned in the dashboard."),
|
|
214
|
+
sessionMode: z.enum(["manual", "schedule"]).optional().describe("Analytics session mode: manual Start/End buttons, or automatic start/end on schedule at eventDate."),
|
|
215
|
+
durationMinutes: z.number().int().min(15).max(1440).optional().describe("Event duration in minutes (15-1440) used by schedule mode to end the session."),
|
|
216
|
+
},
|
|
217
|
+
outputSchema: updateOutputSchema,
|
|
218
|
+
annotations: writeTool,
|
|
219
|
+
}, async (args) => {
|
|
220
|
+
const requiredScopes = new Set();
|
|
221
|
+
if (args.title !== undefined || args.pinned !== undefined)
|
|
222
|
+
requiredScopes.add("events:write");
|
|
223
|
+
if (args.videoUrl !== undefined
|
|
224
|
+
|| args.eventDate !== undefined
|
|
225
|
+
|| args.speakerName !== undefined
|
|
226
|
+
|| args.description !== undefined
|
|
227
|
+
|| args.sessionMode !== undefined
|
|
228
|
+
|| args.durationMinutes !== undefined) {
|
|
229
|
+
requiredScopes.add("setup:write");
|
|
230
|
+
}
|
|
231
|
+
if (requiredScopes.size === 0)
|
|
232
|
+
throw new Error("Provide at least one field to update.");
|
|
233
|
+
const { uid } = await authed([...requiredScopes]);
|
|
234
|
+
await readEvent(args.eventId, uid, "edit");
|
|
235
|
+
const patch = {};
|
|
236
|
+
if (args.title !== undefined)
|
|
237
|
+
patch.title = args.title.trim();
|
|
238
|
+
if (args.videoUrl !== undefined) {
|
|
239
|
+
const video = normalizeVideo(args.videoUrl);
|
|
240
|
+
patch["video.originalUrl"] = video.originalUrl;
|
|
241
|
+
patch["video.embedUrl"] = video.embedUrl;
|
|
242
|
+
}
|
|
243
|
+
if (args.eventDate !== undefined)
|
|
244
|
+
patch["meta.eventDate"] = args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null;
|
|
245
|
+
if (args.speakerName !== undefined)
|
|
246
|
+
patch["meta.speakerName"] = args.speakerName?.trim() || null;
|
|
247
|
+
if (args.description !== undefined)
|
|
248
|
+
patch["meta.description"] = args.description?.trim() || null;
|
|
249
|
+
if (args.pinned !== undefined)
|
|
250
|
+
patch.pinned = args.pinned;
|
|
251
|
+
if (args.sessionMode !== undefined)
|
|
252
|
+
patch.analyticsSessionMode = args.sessionMode;
|
|
253
|
+
if (args.durationMinutes !== undefined)
|
|
254
|
+
patch["meta.durationMinutes"] = args.durationMinutes;
|
|
255
|
+
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
256
|
+
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
257
|
+
});
|
|
258
|
+
server.registerTool("oko_configure_blocks", {
|
|
259
|
+
title: "Configure event blocks",
|
|
260
|
+
description: toolDescription({
|
|
261
|
+
summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
|
|
262
|
+
args: [
|
|
263
|
+
"eventId: event to edit.",
|
|
264
|
+
"chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
|
|
265
|
+
"forms: replaces configured lead forms when provided.",
|
|
266
|
+
"telegramChannelIds: replaces configured Telegram channels when provided.",
|
|
267
|
+
],
|
|
268
|
+
returns: "event id and the list of Firestore fields updated.",
|
|
269
|
+
examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
|
|
270
|
+
notes: [
|
|
271
|
+
"A block toggle and its image are independent: enabling a block here works with an image already uploaded via oko_upload_image, and uploading an image never enables the block by itself.",
|
|
272
|
+
"promoBanner has no text field by design: the banner is image+link. blocks.promoBanner.text seen in event documents is legacy and is never rendered on the event page.",
|
|
273
|
+
],
|
|
274
|
+
}),
|
|
275
|
+
inputSchema: {
|
|
276
|
+
eventId: eventIdField,
|
|
277
|
+
chat: z.object({ enabled: z.boolean().optional(), visibility: z.enum(["always", "hidden"]).optional() }).optional(),
|
|
278
|
+
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(),
|
|
279
|
+
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(),
|
|
280
|
+
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(),
|
|
281
|
+
forms: z.array(formSchema).max(20).optional(),
|
|
282
|
+
telegramChannelIds: z.array(z.number().int()).max(20).optional(),
|
|
283
|
+
},
|
|
284
|
+
outputSchema: updateOutputSchema,
|
|
285
|
+
annotations: writeTool,
|
|
286
|
+
}, async (args) => {
|
|
287
|
+
const { uid } = await authed(["setup:write"]);
|
|
288
|
+
await readEvent(args.eventId, uid, "edit");
|
|
289
|
+
const patch = {};
|
|
290
|
+
if (args.chat?.enabled !== undefined)
|
|
291
|
+
patch["blocks.chat.enabled"] = args.chat.enabled;
|
|
292
|
+
if (args.chat?.visibility !== undefined)
|
|
293
|
+
patch["blocks.chat.visibility"] = args.chat.visibility;
|
|
294
|
+
if (args.promoBanner?.enabled !== undefined)
|
|
295
|
+
patch["blocks.promoBanner.enabled"] = args.promoBanner.enabled;
|
|
296
|
+
if (args.promoBanner?.link !== undefined)
|
|
297
|
+
patch["blocks.promoBanner.link"] = args.promoBanner.link;
|
|
298
|
+
if (args.promoBanner?.visibility !== undefined)
|
|
299
|
+
patch["blocks.promoBanner.visibility"] = args.promoBanner.visibility;
|
|
300
|
+
if (args.promoBanner?.formId !== undefined)
|
|
301
|
+
patch["blocks.promoBanner.formId"] = args.promoBanner.formId;
|
|
302
|
+
if (args.primaryCta?.enabled !== undefined)
|
|
303
|
+
patch["blocks.primaryCta.enabled"] = args.primaryCta.enabled;
|
|
304
|
+
if (args.primaryCta?.visibility !== undefined)
|
|
305
|
+
patch["blocks.primaryCta.visibility"] = args.primaryCta.visibility;
|
|
306
|
+
if (args.primaryCta?.title !== undefined)
|
|
307
|
+
patch["blocks.primaryCta.title"] = args.primaryCta.title?.trim() || null;
|
|
308
|
+
if (args.primaryCta?.text !== undefined)
|
|
309
|
+
patch["blocks.primaryCta.text"] = args.primaryCta.text?.trim() || null;
|
|
310
|
+
if (args.primaryCta?.buttonLabel !== undefined)
|
|
311
|
+
patch["blocks.primaryCta.buttonLabel"] = args.primaryCta.buttonLabel?.trim() || null;
|
|
312
|
+
if (args.primaryCta?.buttonUrl !== undefined)
|
|
313
|
+
patch["blocks.primaryCta.buttonUrl"] = args.primaryCta.buttonUrl?.trim() || null;
|
|
314
|
+
if (args.primaryCta?.formId !== undefined)
|
|
315
|
+
patch["blocks.primaryCta.formId"] = args.primaryCta.formId;
|
|
316
|
+
if (args.videoPlaceholder?.enabled !== undefined)
|
|
317
|
+
patch["blocks.video.placeholder.enabled"] = args.videoPlaceholder.enabled;
|
|
318
|
+
if (args.videoPlaceholder?.mode !== undefined)
|
|
319
|
+
patch["blocks.video.placeholder.mode"] = args.videoPlaceholder.mode;
|
|
320
|
+
if (args.videoPlaceholder?.title !== undefined)
|
|
321
|
+
patch["blocks.video.placeholder.title"] = args.videoPlaceholder.title?.trim() || null;
|
|
322
|
+
if (args.videoPlaceholder?.subtitle !== undefined)
|
|
323
|
+
patch["blocks.video.placeholder.subtitle"] = args.videoPlaceholder.subtitle?.trim() || null;
|
|
324
|
+
if (args.videoPlaceholder?.showTimer !== undefined)
|
|
325
|
+
patch["blocks.video.placeholder.showTimer"] = args.videoPlaceholder.showTimer;
|
|
326
|
+
if (args.forms !== undefined)
|
|
327
|
+
patch.forms = args.forms.map((form) => ({
|
|
328
|
+
id: form.id ?? randomId(),
|
|
329
|
+
type: "lead",
|
|
330
|
+
title: form.title?.trim() || null,
|
|
331
|
+
description: form.description?.trim() || null,
|
|
332
|
+
submitLabel: form.submitLabel?.trim() || null,
|
|
333
|
+
successText: form.successText?.trim() || null,
|
|
334
|
+
consentText: form.consentText?.trim() || null,
|
|
335
|
+
consentRequired: form.consentRequired !== false,
|
|
336
|
+
fields: form.fields.map((field) => ({
|
|
337
|
+
id: field.id ?? randomId(),
|
|
338
|
+
label: field.label.trim(),
|
|
339
|
+
type: field.type,
|
|
340
|
+
required: field.required === true,
|
|
341
|
+
placeholder: field.placeholder?.trim() || null,
|
|
342
|
+
})),
|
|
343
|
+
}));
|
|
344
|
+
if (args.telegramChannelIds !== undefined) {
|
|
345
|
+
patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
|
|
346
|
+
patch["telegram.channelIds"] = args.telegramChannelIds;
|
|
347
|
+
}
|
|
348
|
+
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
349
|
+
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
350
|
+
});
|
|
351
|
+
server.registerTool("oko_configure_cards", {
|
|
352
|
+
title: "Configure event cards",
|
|
353
|
+
description: toolDescription({
|
|
354
|
+
summary: "Configure the Cards setup block and replace its card list. Images are managed separately through oko_upload_image and are preserved by matching card id.",
|
|
355
|
+
args: [
|
|
356
|
+
"eventId: event to edit.",
|
|
357
|
+
"enabled: optional Cards block enabled flag.",
|
|
358
|
+
"visibility: optional default visibility, always or hidden.",
|
|
359
|
+
"cardImageRatio: optional image ratio, one of auto, 1:1, 4:3, 3:2, 16:9, 2:3, 3:4.",
|
|
360
|
+
"items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId.",
|
|
361
|
+
],
|
|
362
|
+
returns: "event id, updated Firestore fields, normalized cards config, generated card ids, preserved image ids, removed card ids, and warnings.",
|
|
363
|
+
examples: [
|
|
364
|
+
"Use after oko_create_webinar to create cards before uploading per-card images.",
|
|
365
|
+
"Call oko_get_event first and pass the full desired items array when editing existing cards.",
|
|
366
|
+
"Use formId instead of buttonUrl when a card button should open a lead form.",
|
|
367
|
+
],
|
|
368
|
+
notes: [
|
|
369
|
+
"This tool replaces blocks.cards.items when items is provided; omitted text fields become null.",
|
|
370
|
+
"Existing card images are preserved only when the same card id remains in the replacement list.",
|
|
371
|
+
"buttonUrl allows http(s), mailto, or tel URLs; javascript/data URLs are rejected.",
|
|
372
|
+
"If a removed card had an image, Storage cleanup is not automatic; this matches the web Setup behavior.",
|
|
373
|
+
],
|
|
374
|
+
}),
|
|
375
|
+
inputSchema: {
|
|
376
|
+
eventId: eventIdField,
|
|
377
|
+
enabled: z.boolean().optional().describe("Whether the Cards block is enabled in setup."),
|
|
378
|
+
visibility: z.enum(["always", "hidden"]).optional().describe("Default visibility when no live override exists."),
|
|
379
|
+
cardImageRatio: z.enum(CARD_IMAGE_RATIOS).optional().describe("Image aspect ratio for all cards."),
|
|
380
|
+
items: z.array(cardItemInputSchema).max(MAX_CARD_ITEMS).optional().describe("Full replacement list, max 6 cards. Omit to update only block settings."),
|
|
381
|
+
},
|
|
382
|
+
outputSchema: cardsConfigOutputSchema,
|
|
383
|
+
annotations: writeTool,
|
|
384
|
+
}, async (args) => {
|
|
385
|
+
const { uid } = await authed(["setup:write"]);
|
|
386
|
+
const event = await readEvent(args.eventId, uid, "edit");
|
|
387
|
+
const currentCards = readCardsConfig(event);
|
|
388
|
+
const patch = {};
|
|
389
|
+
const finalCards = {
|
|
390
|
+
enabled: args.enabled ?? currentCards.enabled,
|
|
391
|
+
visibility: args.visibility ?? currentCards.visibility,
|
|
392
|
+
cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
|
|
393
|
+
items: currentCards.items,
|
|
394
|
+
};
|
|
395
|
+
const generatedCardIds = [];
|
|
396
|
+
const preservedImageCardIds = [];
|
|
397
|
+
let removedCardIds = [];
|
|
398
|
+
let removedCardsWithImages = [];
|
|
399
|
+
if (args.enabled !== undefined)
|
|
400
|
+
patch["blocks.cards.enabled"] = args.enabled;
|
|
401
|
+
if (args.visibility !== undefined)
|
|
402
|
+
patch["blocks.cards.visibility"] = args.visibility;
|
|
403
|
+
if (args.cardImageRatio !== undefined)
|
|
404
|
+
patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
|
|
405
|
+
if (args.items !== undefined) {
|
|
406
|
+
const built = buildCardsItems({
|
|
407
|
+
inputs: args.items,
|
|
408
|
+
existing: currentCards.items,
|
|
409
|
+
formIds: eventFormIds(event),
|
|
410
|
+
});
|
|
411
|
+
finalCards.items = built.items;
|
|
412
|
+
generatedCardIds.push(...built.generatedCardIds);
|
|
413
|
+
preservedImageCardIds.push(...built.preservedImageCardIds);
|
|
414
|
+
const nextIds = new Set(built.items.map((item) => item.id));
|
|
415
|
+
const removed = currentCards.items.filter((item) => !nextIds.has(item.id));
|
|
416
|
+
removedCardIds = removed.map((item) => item.id);
|
|
417
|
+
removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
|
|
418
|
+
patch["blocks.cards.items"] = built.items;
|
|
419
|
+
}
|
|
420
|
+
if (Object.keys(patch).length > 0) {
|
|
421
|
+
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
422
|
+
}
|
|
423
|
+
const warnings = removedCardsWithImages.length > 0
|
|
424
|
+
? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
|
|
425
|
+
: [];
|
|
426
|
+
return textResult({
|
|
427
|
+
eventId: args.eventId,
|
|
428
|
+
updated: Object.keys(patch),
|
|
429
|
+
cards: finalCards,
|
|
430
|
+
generatedCardIds,
|
|
431
|
+
removedCardIds,
|
|
432
|
+
removedCardsWithImages,
|
|
433
|
+
preservedImageCardIds,
|
|
434
|
+
warnings,
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Пагинация, курсоры и приведение данных к виду, который читает модель.
|
|
2
|
+
//
|
|
3
|
+
// Курсор — непрозрачная для клиента строка: пара `timestampCursorFrom` и
|
|
4
|
+
// `parseTimestampCursor` обязана быть обратимой, иначе листание молча
|
|
5
|
+
// зацикливается или теряет страницу.
|
|
6
|
+
//
|
|
7
|
+
// Отчёт по сессии собирается в Markdown здесь, а не в обработчике: модели
|
|
8
|
+
// нужен текст, пригодный к пересказу, а не сырой документ.
|
|
9
|
+
import { Timestamp } from "firebase/firestore";
|
|
10
|
+
import { csvCell, safeString, serialize } from "../utils.js";
|
|
11
|
+
import { detectAndNormalize, extractIframeSrc } from "../videoUrl.js";
|
|
12
|
+
import { eventUrl, sessionReportUrl } from "./context.js";
|
|
13
|
+
export function cursorMeta(count, max, hasMore, nextCursor) {
|
|
14
|
+
return {
|
|
15
|
+
count,
|
|
16
|
+
limit: max,
|
|
17
|
+
hasMore,
|
|
18
|
+
nextCursor,
|
|
19
|
+
has_more: hasMore,
|
|
20
|
+
next_cursor: nextCursor,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export function pickPage(items, max) {
|
|
24
|
+
return {
|
|
25
|
+
page: items.slice(0, max),
|
|
26
|
+
hasMore: items.length > max,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function normalizeVideo(videoUrl) {
|
|
30
|
+
const raw = videoUrl?.trim();
|
|
31
|
+
if (!raw)
|
|
32
|
+
return { originalUrl: null, embedUrl: null };
|
|
33
|
+
const effective = extractIframeSrc(raw) ?? raw;
|
|
34
|
+
const detected = detectAndNormalize(effective);
|
|
35
|
+
if (detected.provider !== "unknown" && !detected.normalizedUrl) {
|
|
36
|
+
throw new Error(`Could not extract a valid ${detected.provider} video id from videoUrl.`);
|
|
37
|
+
}
|
|
38
|
+
return { originalUrl: raw, embedUrl: detected.normalizedUrl ?? effective };
|
|
39
|
+
}
|
|
40
|
+
export function csvRows(headers, rows) {
|
|
41
|
+
return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
|
|
42
|
+
}
|
|
43
|
+
export function toMillis(value) {
|
|
44
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
45
|
+
return value;
|
|
46
|
+
if (value && typeof value === "object" && typeof value.toMillis === "function") {
|
|
47
|
+
return value.toMillis();
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
export function parseTimestampCursor(cursor) {
|
|
52
|
+
const millis = Number(cursor);
|
|
53
|
+
if (Number.isFinite(millis) && millis > 0)
|
|
54
|
+
return Timestamp.fromMillis(millis);
|
|
55
|
+
const parsed = Date.parse(cursor);
|
|
56
|
+
if (Number.isFinite(parsed))
|
|
57
|
+
return Timestamp.fromMillis(parsed);
|
|
58
|
+
throw new Error("Invalid cursor. Use the nextCursor value returned by the previous page.");
|
|
59
|
+
}
|
|
60
|
+
export function timestampCursorFrom(value) {
|
|
61
|
+
const millis = toMillis(value);
|
|
62
|
+
return millis == null ? null : String(millis);
|
|
63
|
+
}
|
|
64
|
+
export function parseIsoBound(value, label) {
|
|
65
|
+
if (value === undefined)
|
|
66
|
+
return null;
|
|
67
|
+
const parsed = Date.parse(value);
|
|
68
|
+
if (!Number.isFinite(parsed)) {
|
|
69
|
+
throw new Error(`Invalid ${label}: expected an ISO timestamp such as 2026-08-28T00:00:00+03:00.`);
|
|
70
|
+
}
|
|
71
|
+
return Timestamp.fromMillis(parsed);
|
|
72
|
+
}
|
|
73
|
+
// Компактная карточка события из полного (сериализованного) документа —
|
|
74
|
+
// той же формы, что grant-проекция в eventAccess.
|
|
75
|
+
export function compactEventFromFullDoc(event) {
|
|
76
|
+
const meta = (event.meta && typeof event.meta === "object" ? event.meta : {});
|
|
77
|
+
const telegram = (event.telegram && typeof event.telegram === "object" ? event.telegram : {});
|
|
78
|
+
return {
|
|
79
|
+
id: event.id,
|
|
80
|
+
ownerId: event.ownerId ?? null,
|
|
81
|
+
workspaceId: event.workspaceId ?? null,
|
|
82
|
+
title: event.title ?? null,
|
|
83
|
+
createdAt: event.createdAt ?? null,
|
|
84
|
+
eventDate: meta.eventDate ?? null,
|
|
85
|
+
speakerName: meta.speakerName ?? null,
|
|
86
|
+
pinned: event.pinned === true,
|
|
87
|
+
telegramEnabled: telegram.enabled === true,
|
|
88
|
+
trashed: Boolean(event.trashedAt),
|
|
89
|
+
trashedAt: event.trashedAt ?? null,
|
|
90
|
+
activeAnalyticsSessionId: event.activeAnalyticsSessionId ?? null,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
export function compactAnalyticsSession(raw, options) {
|
|
94
|
+
const session = serialize(raw);
|
|
95
|
+
const timeline = Array.isArray(session.timeline) ? session.timeline : null;
|
|
96
|
+
if (timeline) {
|
|
97
|
+
session.timelineBucketCount = timeline.length;
|
|
98
|
+
if (options.includeTimeline) {
|
|
99
|
+
const page = timeline.slice(-options.timelineLimit);
|
|
100
|
+
session.timeline = page;
|
|
101
|
+
session.timelineTruncated = timeline.length > page.length;
|
|
102
|
+
session.timelineReturned = page.length;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
delete session.timeline;
|
|
106
|
+
session.timelineOmitted = true;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (!options.includeSnapshots) {
|
|
110
|
+
if (session.baselineSnapshot !== undefined) {
|
|
111
|
+
delete session.baselineSnapshot;
|
|
112
|
+
session.baselineSnapshotOmitted = true;
|
|
113
|
+
}
|
|
114
|
+
if (session.finalSnapshot !== undefined) {
|
|
115
|
+
delete session.finalSnapshot;
|
|
116
|
+
session.finalSnapshotOmitted = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
delete session[["n", "moEvaluation"].join("")];
|
|
120
|
+
return session;
|
|
121
|
+
}
|
|
122
|
+
export function numberMetric(value) {
|
|
123
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
124
|
+
}
|
|
125
|
+
export function formatSeconds(value) {
|
|
126
|
+
const seconds = numberMetric(value);
|
|
127
|
+
if (seconds == null)
|
|
128
|
+
return "n/a";
|
|
129
|
+
const total = Math.max(0, Math.round(seconds));
|
|
130
|
+
const h = Math.floor(total / 3600);
|
|
131
|
+
const m = Math.floor((total % 3600) / 60);
|
|
132
|
+
const s = total % 60;
|
|
133
|
+
if (h > 0)
|
|
134
|
+
return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
|
|
135
|
+
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
136
|
+
}
|
|
137
|
+
export function formatDateTime(value) {
|
|
138
|
+
const millis = toMillis(value);
|
|
139
|
+
if (millis == null)
|
|
140
|
+
return "n/a";
|
|
141
|
+
return new Date(millis).toISOString();
|
|
142
|
+
}
|
|
143
|
+
export function markdownMetric(label, value, suffix = "") {
|
|
144
|
+
const metric = value === null || value === undefined || value === "" ? "n/a" : String(value);
|
|
145
|
+
return `- ${label}: ${metric}${suffix}`;
|
|
146
|
+
}
|
|
147
|
+
export function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
|
|
148
|
+
const lines = [
|
|
149
|
+
`# OKO session report`,
|
|
150
|
+
"",
|
|
151
|
+
`Event: ${safeString(event.title, eventId)}`,
|
|
152
|
+
`Event URL: ${eventUrl(eventId)}`,
|
|
153
|
+
`Report URL: ${sessionReportUrl(eventId, sessionId)}`,
|
|
154
|
+
`Session: ${sessionId}`,
|
|
155
|
+
"",
|
|
156
|
+
"## Timing",
|
|
157
|
+
markdownMetric("Started", formatDateTime(session.startedAt)),
|
|
158
|
+
markdownMetric("Ended", formatDateTime(session.endedAt)),
|
|
159
|
+
markdownMetric("Duration", formatSeconds(session.durationSec)),
|
|
160
|
+
"",
|
|
161
|
+
"## Audience",
|
|
162
|
+
markdownMetric("Peak viewers", session.peakViewers),
|
|
163
|
+
markdownMetric("Unique visitors", session.uniqueVisitors),
|
|
164
|
+
markdownMetric("Views", session.viewCount),
|
|
165
|
+
markdownMetric("Average watch time", formatSeconds(session.avgTimeSec)),
|
|
166
|
+
markdownMetric("Median watch time", formatSeconds(session.medianTimeSec)),
|
|
167
|
+
"",
|
|
168
|
+
"## Engagement",
|
|
169
|
+
markdownMetric("Chat messages", session.chatMessages),
|
|
170
|
+
markdownMetric("Banner impressions", session.bannerImpressions),
|
|
171
|
+
markdownMetric("Banner clicks", session.bannerClicks),
|
|
172
|
+
markdownMetric("Banner CTR", session.bannerCtr, session.bannerCtr == null ? "" : "%"),
|
|
173
|
+
markdownMetric("CTA clicks", session.ctaClicks),
|
|
174
|
+
markdownMetric("Cards clicks", session.cardsClicks),
|
|
175
|
+
];
|
|
176
|
+
return lines.join("\n");
|
|
177
|
+
}
|