@okostream/mcp 0.12.2 → 0.12.4
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 +38 -0
- package/dist/server.js +120498 -41
- package/package.json +8 -9
- package/dist/auth.js +0 -115
- package/dist/env.js +0 -71
- package/dist/firebase.js +0 -31
- package/dist/firebaseDefaults.js +0 -11
- package/dist/guides.js +0 -266
- package/dist/networkPolicy.js +0 -18
- package/dist/onboarding.js +0 -126
- package/dist/scopes.js +0 -22
- package/dist/sessionRepair.js +0 -43
- package/dist/telegramChannelSchema.js +0 -13
- package/dist/tools/account.js +0 -77
- package/dist/tools/audit.js +0 -44
- package/dist/tools/chat.js +0 -228
- package/dist/tools/context.js +0 -40
- package/dist/tools/eventAccess.js +0 -65
- package/dist/tools/eventCreate.js +0 -43
- package/dist/tools/events.js +0 -497
- package/dist/tools/formatting.js +0 -216
- package/dist/tools/imagePipeline.js +0 -597
- package/dist/tools/images.js +0 -243
- package/dist/tools/instrumentation.js +0 -40
- package/dist/tools/leads.js +0 -85
- package/dist/tools/lifecycle.js +0 -168
- package/dist/tools/live.js +0 -227
- package/dist/tools/notifications.js +0 -110
- package/dist/tools/schemas.js +0 -329
- package/dist/tools/sessions.js +0 -313
- package/dist/tools/setupValidation.js +0 -144
- package/dist/tools/telegram.js +0 -297
- package/dist/tools/workspaceAccess.js +0 -60
- package/dist/tools/workspaces.js +0 -345
- package/dist/tools.js +0 -38
- package/dist/utils.js +0 -76
- package/dist/version.js +0 -2
- package/dist/videoUrl.js +0 -166
package/dist/tools/sessions.js
DELETED
|
@@ -1,313 +0,0 @@
|
|
|
1
|
-
// Сессии аналитики и отчёты по ним.
|
|
2
|
-
//
|
|
3
|
-
// Start и End управляют ЗАМЕРОМ, а не эфиром: видео идёт из плеера
|
|
4
|
-
// организатора независимо от них. Отчёт завершённой сессии неизменяем —
|
|
5
|
-
// его считает CF49 один раз и больше не пересчитывает.
|
|
6
|
-
import { z } from "zod";
|
|
7
|
-
import { requireConfirm, safeString, textResult } from "../utils.js";
|
|
8
|
-
import { authed, eventLinks, sessionReportUrl } from "./context.js";
|
|
9
|
-
import { ANALYTICS_DEFAULT_LIMIT, ANALYTICS_TIMELINE_DEFAULT_LIMIT, analyticsOutputSchema, basicEventOutputSchema, cursorField, destructiveTool, eventIdField, limitField, readOnlyTool, sessionReportOutputSchema, toolDescription, writeTool } from "./schemas.js";
|
|
10
|
-
import { collection, deleteDoc, deleteField, doc, getDoc, getDocs, limit, orderBy, query, startAfter, updateDoc } from "firebase/firestore";
|
|
11
|
-
import { summarizeSessionRepair } from "../sessionRepair.js";
|
|
12
|
-
import { compactAnalyticsSession, cursorMeta, parseTimestampCursor, pickPage, sessionReportMarkdown, timestampCursorFrom } from "./formatting.js";
|
|
13
|
-
import { firestore, functions, rtdb } from "../firebase.js";
|
|
14
|
-
import { get, ref, remove } from "firebase/database";
|
|
15
|
-
import { httpsCallable } from "firebase/functions";
|
|
16
|
-
import { readEvent } from "./eventAccess.js";
|
|
17
|
-
export function registerSessionTools(server) {
|
|
18
|
-
server.registerTool("oko_start_session", {
|
|
19
|
-
title: "Start analytics session",
|
|
20
|
-
description: toolDescription({
|
|
21
|
-
summary: "Start an analytics session using the same Firestore/RTDB pointers as the dashboard.",
|
|
22
|
-
args: ["eventId: event whose analytics session should start.", "confirm: must be true."],
|
|
23
|
-
returns: "eventId, new sessionId, and started=true.",
|
|
24
|
-
examples: ["Use when the organizer wants analytics tracking to begin; it does not start the video stream."],
|
|
25
|
-
notes: ["OKO Start/End controls analytics sessions only, not stream publishing."],
|
|
26
|
-
}),
|
|
27
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to start analytics collection session.") },
|
|
28
|
-
outputSchema: basicEventOutputSchema,
|
|
29
|
-
annotations: writeTool,
|
|
30
|
-
}, async ({ eventId, confirm }) => {
|
|
31
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
32
|
-
const event = await readEvent(eventId, uid, "session");
|
|
33
|
-
requireConfirm(confirm, "oko_start_session", { eventId, title: event.title ?? null });
|
|
34
|
-
if (event.activeAnalyticsSessionId)
|
|
35
|
-
throw new Error("An analytics session is already active.");
|
|
36
|
-
const start = httpsCallable(functions, "startAnalyticsSessionV3");
|
|
37
|
-
const result = await start({ eventId });
|
|
38
|
-
// 🔴 `eventId` обязателен по `basicEventOutputSchema`. Без него SDK
|
|
39
|
-
// отвергал ответ ПОСЛЕ того, как сессия уже стартовала: клиент видел
|
|
40
|
-
// ошибку схемы и считал, что запуска не было, — Pattern 18.
|
|
41
|
-
return textResult({ eventId, started: true, ...result.data });
|
|
42
|
-
});
|
|
43
|
-
server.registerTool("oko_end_session", {
|
|
44
|
-
title: "End analytics session",
|
|
45
|
-
description: toolDescription({
|
|
46
|
-
summary: "End the active analytics session and persist the session snapshot.",
|
|
47
|
-
args: ["eventId: event with an active analytics session.", "confirm: must be true."],
|
|
48
|
-
returns: "eventId, sessionId, and ended=true.",
|
|
49
|
-
examples: ["Use after the webinar analytics window is over and the user confirms the exact event."],
|
|
50
|
-
notes: ["Destructive for the active session pointer, but it does not stop the video stream."],
|
|
51
|
-
}),
|
|
52
|
-
inputSchema: {
|
|
53
|
-
eventId: eventIdField,
|
|
54
|
-
confirm: z.boolean().optional().describe("Required true for ending the active session."),
|
|
55
|
-
reportLocale: z.enum(["en", "ru", "es", "pt-BR", "fr", "de"]).optional()
|
|
56
|
-
.describe("Language of the session report PDF, pre-rendered right after the session ends. Server defaults to ru; the choice is permanent for this session."),
|
|
57
|
-
},
|
|
58
|
-
outputSchema: basicEventOutputSchema,
|
|
59
|
-
annotations: destructiveTool,
|
|
60
|
-
}, async ({ eventId, confirm, reportLocale }) => {
|
|
61
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
62
|
-
const event = await readEvent(eventId, uid, "session");
|
|
63
|
-
const sessionId = safeString(event.activeAnalyticsSessionId);
|
|
64
|
-
if (!sessionId)
|
|
65
|
-
throw new Error("No active analytics session.");
|
|
66
|
-
requireConfirm(confirm, "oko_end_session", { eventId, sessionId, reportLocale: reportLocale ?? null });
|
|
67
|
-
const end = httpsCallable(functions, "endAnalyticsSessionV3");
|
|
68
|
-
// 🔴 Язык отчёта задаётся ЗДЕСЬ и больше нигде: CF55 рендерит PDF сразу
|
|
69
|
-
// после эфира, организатора в этот момент нет, а в профиле языка не
|
|
70
|
-
// хранится. Локаль входит в путь готового файла, и сессия неизменяема —
|
|
71
|
-
// русский PDF у англоязычного организатора исправить уже нельзя. Веб
|
|
72
|
-
// передаёт `locale` именно поэтому (ревью 02.09, Б3).
|
|
73
|
-
const result = await end({ eventId, sessionId, ...(reportLocale ? { locale: reportLocale } : {}) });
|
|
74
|
-
// Тот же случай, что у запуска: сессия уже завершена и отчёт сохранён,
|
|
75
|
-
// а ответ без `eventId` не проходил собственную схему.
|
|
76
|
-
return textResult({ eventId, ...result.data });
|
|
77
|
-
});
|
|
78
|
-
server.registerTool("oko_fix_orphan_session", {
|
|
79
|
-
title: "Fix orphan analytics session",
|
|
80
|
-
description: toolDescription({
|
|
81
|
-
summary: "Clear stuck active-session pointers when an analytics session cannot be ended normally.",
|
|
82
|
-
args: [
|
|
83
|
-
"eventId: event with stuck session pointers.",
|
|
84
|
-
"deleteRtdbSessionData: optional, also delete events/{eventId}/sessions/{sessionId}; default false.",
|
|
85
|
-
"confirm: must be true.",
|
|
86
|
-
],
|
|
87
|
-
returns: "eventId, cleared sessionId if found, and cleaned paths.",
|
|
88
|
-
examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
|
|
89
|
-
notes: ["This does not stop the video stream; OKO Start/End controls analytics sessions only."],
|
|
90
|
-
}),
|
|
91
|
-
inputSchema: {
|
|
92
|
-
eventId: eventIdField,
|
|
93
|
-
deleteRtdbSessionData: z.boolean().default(false).describe("Also delete RTDB session data for the stuck session."),
|
|
94
|
-
confirm: z.boolean().optional().describe("Required true for clearing session pointers."),
|
|
95
|
-
},
|
|
96
|
-
outputSchema: basicEventOutputSchema,
|
|
97
|
-
annotations: destructiveTool,
|
|
98
|
-
}, async ({ eventId, deleteRtdbSessionData, confirm }) => {
|
|
99
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
100
|
-
const event = await readEvent(eventId, uid, "session");
|
|
101
|
-
const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
|
|
102
|
-
const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
|
|
103
|
-
requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
|
|
104
|
-
const cleanup = [
|
|
105
|
-
{
|
|
106
|
-
path: "events.activeAnalyticsSessionId",
|
|
107
|
-
promise: updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
path: "ctrl/activeSessionId",
|
|
111
|
-
promise: remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
112
|
-
},
|
|
113
|
-
];
|
|
114
|
-
if (sessionId) {
|
|
115
|
-
cleanup.push({
|
|
116
|
-
path: `activeSessions/${sessionId}`,
|
|
117
|
-
promise: remove(ref(rtdb, `activeSessions/${sessionId}`)),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
if (sessionId && deleteRtdbSessionData) {
|
|
121
|
-
cleanup.push({
|
|
122
|
-
path: `events/${eventId}/sessions/${sessionId}`,
|
|
123
|
-
promise: remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
const settled = await Promise.allSettled(cleanup.map((operation) => operation.promise));
|
|
127
|
-
const attempts = settled.map((result, index) => {
|
|
128
|
-
const operation = cleanup[index];
|
|
129
|
-
if (!operation)
|
|
130
|
-
throw new Error("Session repair result has no matching cleanup operation.");
|
|
131
|
-
return {
|
|
132
|
-
path: operation.path,
|
|
133
|
-
status: result.status,
|
|
134
|
-
...(result.status === "rejected" ? {
|
|
135
|
-
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
136
|
-
} : {}),
|
|
137
|
-
};
|
|
138
|
-
});
|
|
139
|
-
let firestorePointer = null;
|
|
140
|
-
let rtdbPointer = null;
|
|
141
|
-
let verificationError;
|
|
142
|
-
try {
|
|
143
|
-
const [eventAfter, ctrlAfter] = await Promise.all([
|
|
144
|
-
getDoc(doc(firestore, "events", eventId)),
|
|
145
|
-
get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
146
|
-
]);
|
|
147
|
-
firestorePointer = eventAfter.exists()
|
|
148
|
-
? safeString(eventAfter.data().activeAnalyticsSessionId) || null
|
|
149
|
-
: null;
|
|
150
|
-
rtdbPointer = safeString(ctrlAfter.val()) || null;
|
|
151
|
-
}
|
|
152
|
-
catch (error) {
|
|
153
|
-
verificationError = error instanceof Error ? error.message : String(error);
|
|
154
|
-
}
|
|
155
|
-
return textResult(summarizeSessionRepair({
|
|
156
|
-
eventId,
|
|
157
|
-
sessionId: sessionId || null,
|
|
158
|
-
deleteRtdbSessionData,
|
|
159
|
-
attempts,
|
|
160
|
-
firestorePointer,
|
|
161
|
-
rtdbPointer,
|
|
162
|
-
verificationError,
|
|
163
|
-
}));
|
|
164
|
-
});
|
|
165
|
-
server.registerTool("oko_delete_session", {
|
|
166
|
-
title: "Delete analytics session",
|
|
167
|
-
description: toolDescription({
|
|
168
|
-
summary: "Delete an analytics session document and optionally its RTDB session data.",
|
|
169
|
-
args: [
|
|
170
|
-
"eventId: event id.",
|
|
171
|
-
"sessionId: analytics session id.",
|
|
172
|
-
"deleteRtdbData: best-effort delete of events/{eventId}/sessions/{sessionId}; default false.",
|
|
173
|
-
"confirm: must be true.",
|
|
174
|
-
],
|
|
175
|
-
returns: "eventId, sessionId, deleted=true, and cleaned paths.",
|
|
176
|
-
examples: ["Use only after confirming the exact session id with the organizer."],
|
|
177
|
-
notes: ["Destructive. If the session is active, active pointers are cleared first. RTDB session data cleanup is best-effort because rules may deny deleting historical session snapshots."],
|
|
178
|
-
}),
|
|
179
|
-
inputSchema: {
|
|
180
|
-
eventId: eventIdField,
|
|
181
|
-
sessionId: z.string().min(1).describe("analyticsSessions document id."),
|
|
182
|
-
deleteRtdbData: z.boolean().default(false).describe("Best-effort delete RTDB session data for this session."),
|
|
183
|
-
confirm: z.boolean().optional().describe("Required true for session deletion."),
|
|
184
|
-
},
|
|
185
|
-
outputSchema: basicEventOutputSchema,
|
|
186
|
-
annotations: destructiveTool,
|
|
187
|
-
}, async ({ eventId, sessionId, deleteRtdbData, confirm }) => {
|
|
188
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
189
|
-
const event = await readEvent(eventId, uid, "session");
|
|
190
|
-
requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
|
|
191
|
-
const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
|
|
192
|
-
const cleanup = [];
|
|
193
|
-
if (isActive) {
|
|
194
|
-
cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
|
|
195
|
-
cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
|
|
196
|
-
cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
|
|
197
|
-
}
|
|
198
|
-
cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
|
|
199
|
-
const results = await Promise.allSettled(cleanup);
|
|
200
|
-
const warnings = results
|
|
201
|
-
.filter((result) => result.status === "rejected")
|
|
202
|
-
.map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
203
|
-
if (warnings.length > 0)
|
|
204
|
-
throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
|
|
205
|
-
const rtdbCleanupWarnings = [];
|
|
206
|
-
if (deleteRtdbData) {
|
|
207
|
-
try {
|
|
208
|
-
await remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`));
|
|
209
|
-
}
|
|
210
|
-
catch (err) {
|
|
211
|
-
rtdbCleanupWarnings.push(err instanceof Error ? err.message : String(err));
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return textResult({ eventId, sessionId, deleted: true, wasActive: isActive, deleteRtdbData, rtdbCleanupWarnings });
|
|
215
|
-
});
|
|
216
|
-
server.registerTool("oko_get_analytics", {
|
|
217
|
-
title: "Get analytics sessions",
|
|
218
|
-
description: toolDescription({
|
|
219
|
-
summary: "Read paginated analytics session summaries for an event.",
|
|
220
|
-
args: [
|
|
221
|
-
"eventId: event to inspect.",
|
|
222
|
-
"limit: max sessions to return, default 10, max 50.",
|
|
223
|
-
"cursor: timestamp cursor returned as nextCursor by the previous page.",
|
|
224
|
-
"includeTimeline: include recent timeline buckets; default false to keep context small.",
|
|
225
|
-
"timelineLimit: max timeline buckets per session when includeTimeline=true.",
|
|
226
|
-
"includeSnapshots: include baseline/final snapshots; default false.",
|
|
227
|
-
],
|
|
228
|
-
returns: "sessions plus count, limit, hasMore/has_more, nextCursor/next_cursor. Heavy timeline/snapshot fields are omitted unless requested.",
|
|
229
|
-
examples: ["Use default options for dashboard-like summaries; request includeTimeline only for chart/debug tasks."],
|
|
230
|
-
}),
|
|
231
|
-
inputSchema: {
|
|
232
|
-
eventId: eventIdField,
|
|
233
|
-
limit: limitField(50, ANALYTICS_DEFAULT_LIMIT),
|
|
234
|
-
cursor: cursorField,
|
|
235
|
-
includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets for each session."),
|
|
236
|
-
timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets per session when includeTimeline=true."),
|
|
237
|
-
includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
|
|
238
|
-
},
|
|
239
|
-
outputSchema: analyticsOutputSchema,
|
|
240
|
-
annotations: readOnlyTool,
|
|
241
|
-
}, async ({ eventId, limit: max, cursor, includeTimeline, timelineLimit, includeSnapshots }) => {
|
|
242
|
-
const { uid } = await authed(["analytics:read"]);
|
|
243
|
-
await readEvent(eventId, uid, "analytics");
|
|
244
|
-
const constraints = [
|
|
245
|
-
orderBy("startedAt", "desc"),
|
|
246
|
-
limit(max + 1),
|
|
247
|
-
];
|
|
248
|
-
if (cursor)
|
|
249
|
-
constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
|
|
250
|
-
const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), ...constraints));
|
|
251
|
-
const { page, hasMore } = pickPage(snap.docs, max);
|
|
252
|
-
const sessions = page.map((item) => compactAnalyticsSession({ id: item.id, ...item.data() }, { includeTimeline, timelineLimit, includeSnapshots }));
|
|
253
|
-
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
|
|
254
|
-
return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots disabled." });
|
|
255
|
-
});
|
|
256
|
-
server.registerTool("oko_get_session_report", {
|
|
257
|
-
title: "Get session report",
|
|
258
|
-
description: toolDescription({
|
|
259
|
-
summary: "Build a ready-to-share report for one analytics session, with compact structured data and markdown.",
|
|
260
|
-
args: [
|
|
261
|
-
"eventId: event to inspect.",
|
|
262
|
-
"sessionId: optional analytics session id; omitted means latest session.",
|
|
263
|
-
"includeTimeline: include recent timeline buckets; default false.",
|
|
264
|
-
"timelineLimit: max timeline buckets when includeTimeline=true.",
|
|
265
|
-
"includeSnapshots: include baseline/final snapshots; default false.",
|
|
266
|
-
],
|
|
267
|
-
returns: "event/session ids, report URL, markdown summary, event summary, and compact session object.",
|
|
268
|
-
examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
|
|
269
|
-
}),
|
|
270
|
-
inputSchema: {
|
|
271
|
-
eventId: eventIdField,
|
|
272
|
-
sessionId: z.string().min(1).optional().describe("analyticsSessions document id. Omit to use the latest session."),
|
|
273
|
-
includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets."),
|
|
274
|
-
timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets when includeTimeline=true."),
|
|
275
|
-
includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
|
|
276
|
-
},
|
|
277
|
-
outputSchema: sessionReportOutputSchema,
|
|
278
|
-
annotations: readOnlyTool,
|
|
279
|
-
}, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots }) => {
|
|
280
|
-
const { uid } = await authed(["analytics:read"]);
|
|
281
|
-
const event = await readEvent(eventId, uid, "report");
|
|
282
|
-
let resolvedSessionId = sessionId;
|
|
283
|
-
let sessionData = null;
|
|
284
|
-
if (resolvedSessionId) {
|
|
285
|
-
const sessionSnap = await getDoc(doc(firestore, "events", eventId, "analyticsSessions", resolvedSessionId));
|
|
286
|
-
if (!sessionSnap.exists())
|
|
287
|
-
throw new Error("Analytics session not found.");
|
|
288
|
-
sessionData = sessionSnap.data();
|
|
289
|
-
}
|
|
290
|
-
else {
|
|
291
|
-
const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), orderBy("startedAt", "desc"), limit(1)));
|
|
292
|
-
const first = snap.docs[0];
|
|
293
|
-
if (!first)
|
|
294
|
-
throw new Error("No analytics sessions found for this event.");
|
|
295
|
-
resolvedSessionId = first.id;
|
|
296
|
-
sessionData = first.data();
|
|
297
|
-
}
|
|
298
|
-
const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots });
|
|
299
|
-
const markdown = sessionReportMarkdown({ eventId, event, sessionId: resolvedSessionId, session });
|
|
300
|
-
return textResult({
|
|
301
|
-
eventId,
|
|
302
|
-
sessionId: resolvedSessionId,
|
|
303
|
-
event: {
|
|
304
|
-
id: eventId,
|
|
305
|
-
title: safeString(event.title, eventId),
|
|
306
|
-
...eventLinks(eventId),
|
|
307
|
-
},
|
|
308
|
-
reportUrl: sessionReportUrl(eventId, resolvedSessionId),
|
|
309
|
-
markdown,
|
|
310
|
-
session,
|
|
311
|
-
}, { truncationHint: "Keep includeTimeline/includeSnapshots disabled or lower timelineLimit." });
|
|
312
|
-
});
|
|
313
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
// Проверка готовности события к эфиру — чистая функция над документом.
|
|
2
|
-
//
|
|
3
|
-
// Отвечает на вопрос «можно ли это показывать зрителям»: есть ли заголовок и
|
|
4
|
-
// видео, осмысленны ли блоки, не ведут ли кнопки в никуда, не потеряны ли
|
|
5
|
-
// картинки. Ни одного обращения к базам: всё решается по уже прочитанному
|
|
6
|
-
// документу — поэтому логика проверяется юнит-тестом, а не прогоном по проду.
|
|
7
|
-
//
|
|
8
|
-
// Различие уровней намеренное: ошибка мешает эфиру, предупреждение видно
|
|
9
|
-
// только организатору, подсказка — совет.
|
|
10
|
-
import { safeString } from "../utils.js";
|
|
11
|
-
import { asRecord, eventLinks } from "./context.js";
|
|
12
|
-
import { toMillis } from "./formatting.js";
|
|
13
|
-
import { eventFormIds, readCardsConfig, readImageVariants } from "./imagePipeline.js";
|
|
14
|
-
export function validateEventSetup(eventId, event) {
|
|
15
|
-
const errors = [];
|
|
16
|
-
const warnings = [];
|
|
17
|
-
const suggestions = [];
|
|
18
|
-
const add = (target, code, path, message) => {
|
|
19
|
-
target.push({ code, path, message });
|
|
20
|
-
};
|
|
21
|
-
const blocks = asRecord(event.blocks);
|
|
22
|
-
const forms = Array.isArray(event.forms) ? event.forms.filter((item) => item && typeof item === "object") : [];
|
|
23
|
-
const formIds = eventFormIds(event);
|
|
24
|
-
const title = safeString(event.title).trim();
|
|
25
|
-
if (!title)
|
|
26
|
-
add(errors, "title_missing", "title", "Event title is required.");
|
|
27
|
-
const video = asRecord(event.video);
|
|
28
|
-
if (!safeString(video.embedUrl).trim()) {
|
|
29
|
-
add(warnings, "video_missing", "video.embedUrl", "No normalized video URL is configured. This warning is for the organizer only and never appears as page text above other blocks. Without a video URL the player slot shows a small 'video not configured' notice, but when the video placeholder block is enabled and switched on, the placeholder fully covers that slot and viewers see only the placeholder.");
|
|
30
|
-
}
|
|
31
|
-
const meta = asRecord(event.meta);
|
|
32
|
-
const eventDateMs = toMillis(meta.eventDate);
|
|
33
|
-
if (eventDateMs == null) {
|
|
34
|
-
add(suggestions, "date_missing", "meta.eventDate", "Add the webinar date/time if it is known.");
|
|
35
|
-
}
|
|
36
|
-
else if (eventDateMs < Date.now() - 60 * 60 * 1000) {
|
|
37
|
-
add(warnings, "date_in_past", "meta.eventDate", "Configured webinar date appears to be in the past.");
|
|
38
|
-
}
|
|
39
|
-
const duplicateFormIds = new Set();
|
|
40
|
-
const seenFormIds = new Set();
|
|
41
|
-
forms.forEach((form, index) => {
|
|
42
|
-
const id = safeString(form.id).trim();
|
|
43
|
-
if (!id)
|
|
44
|
-
add(errors, "form_id_missing", `forms[${index}].id`, "Every form must have a stable id.");
|
|
45
|
-
else if (seenFormIds.has(id))
|
|
46
|
-
duplicateFormIds.add(id);
|
|
47
|
-
else
|
|
48
|
-
seenFormIds.add(id);
|
|
49
|
-
const fields = Array.isArray(form.fields) ? form.fields : [];
|
|
50
|
-
if (fields.length === 0)
|
|
51
|
-
add(warnings, "form_fields_missing", `forms[${index}].fields`, "Lead form has no fields.");
|
|
52
|
-
});
|
|
53
|
-
for (const id of duplicateFormIds)
|
|
54
|
-
add(errors, "form_id_duplicate", "forms", `Duplicate form id: ${id}.`);
|
|
55
|
-
const checkFormBinding = (path, formId) => {
|
|
56
|
-
const id = safeString(formId).trim();
|
|
57
|
-
if (id && !formIds.has(id))
|
|
58
|
-
add(errors, "form_binding_missing", path, `Referenced formId "${id}" does not exist in event.forms.`);
|
|
59
|
-
};
|
|
60
|
-
const promo = asRecord(blocks.promoBanner);
|
|
61
|
-
if (promo.enabled === true) {
|
|
62
|
-
checkFormBinding("blocks.promoBanner.formId", promo.formId);
|
|
63
|
-
if (!safeString(promo.link).trim() && !safeString(promo.formId).trim()) {
|
|
64
|
-
add(suggestions, "promo_target_missing", "blocks.promoBanner", "Enabled promo banner has no link or form binding.");
|
|
65
|
-
}
|
|
66
|
-
if (!safeString(promo.imageUrl).trim()) {
|
|
67
|
-
add(warnings, "promo_image_missing", "blocks.promoBanner.imageUrl", "Enabled promo banner has no image.");
|
|
68
|
-
}
|
|
69
|
-
else if (safeString(promo.imageMasterUrl).trim() && !readImageVariants(promo.imageVariants)) {
|
|
70
|
-
add(warnings, "promo_variants_pending", "blocks.promoBanner.imageVariants", "Promo banner image variants are not ready yet.");
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
const cta = asRecord(blocks.primaryCta);
|
|
74
|
-
if (cta.enabled === true) {
|
|
75
|
-
checkFormBinding("blocks.primaryCta.formId", cta.formId);
|
|
76
|
-
if (!safeString(cta.title).trim() && !safeString(cta.text).trim()) {
|
|
77
|
-
add(suggestions, "cta_copy_missing", "blocks.primaryCta", "Enabled CTA has no title or text.");
|
|
78
|
-
}
|
|
79
|
-
if (!safeString(cta.buttonLabel).trim()) {
|
|
80
|
-
add(warnings, "cta_button_missing", "blocks.primaryCta.buttonLabel", "Enabled CTA has no button label.");
|
|
81
|
-
}
|
|
82
|
-
if (safeString(cta.buttonLabel).trim() && !safeString(cta.buttonUrl).trim() && !safeString(cta.formId).trim()) {
|
|
83
|
-
add(warnings, "cta_target_missing", "blocks.primaryCta", "CTA button has no URL or form binding.");
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
const videoPlaceholder = asRecord(asRecord(blocks.video).placeholder);
|
|
87
|
-
if (videoPlaceholder.enabled === true && videoPlaceholder.mode === "image") {
|
|
88
|
-
if (!safeString(videoPlaceholder.imageUrl).trim()) {
|
|
89
|
-
add(warnings, "placeholder_image_missing", "blocks.video.placeholder.imageUrl", "Image placeholder mode is enabled but no image is configured.");
|
|
90
|
-
}
|
|
91
|
-
else if (safeString(videoPlaceholder.imageMasterUrl).trim() && !readImageVariants(videoPlaceholder.imageVariants)) {
|
|
92
|
-
add(warnings, "placeholder_variants_pending", "blocks.video.placeholder.imageVariants", "Video placeholder image variants are not ready yet.");
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
const cards = readCardsConfig(event);
|
|
96
|
-
// 🔴 Карточка без пригодного `id` — это ошибка данных, а не мелочь: id
|
|
97
|
-
// связывает запись с её картинкой в Storage и с обратной записью CF4.
|
|
98
|
-
// Пока подстановка молчала, проверка не могла назвать причину, по которой
|
|
99
|
-
// у карточки пропадает изображение при каждой правке.
|
|
100
|
-
cards.repairedIdPositions.forEach((position) => {
|
|
101
|
-
add(errors, "card_id_missing", `blocks.cards.items[${position}].id`, "Card has no usable id in Firestore; its image cannot be matched and will be dropped on the next update.");
|
|
102
|
-
});
|
|
103
|
-
if (cards.enabled) {
|
|
104
|
-
if (cards.items.length === 0)
|
|
105
|
-
add(warnings, "cards_empty", "blocks.cards.items", "Cards block is enabled but has no cards.");
|
|
106
|
-
const cardIds = new Set();
|
|
107
|
-
cards.items.forEach((card, index) => {
|
|
108
|
-
const path = `blocks.cards.items[${index}]`;
|
|
109
|
-
if (cardIds.has(card.id))
|
|
110
|
-
add(errors, "card_id_duplicate", `${path}.id`, `Duplicate card id: ${card.id}.`);
|
|
111
|
-
cardIds.add(card.id);
|
|
112
|
-
if (!card.title && !card.text)
|
|
113
|
-
add(suggestions, "card_copy_missing", path, "Card has no title or text.");
|
|
114
|
-
if (card.buttonLabel && !card.buttonUrl && !card.formId)
|
|
115
|
-
add(warnings, "card_target_missing", path, "Card button has no URL or form binding.");
|
|
116
|
-
if (card.formId && !formIds.has(card.formId))
|
|
117
|
-
add(errors, "card_form_missing", `${path}.formId`, `Referenced formId "${card.formId}" does not exist.`);
|
|
118
|
-
if (card.imageMasterUrl && !card.imageVariants)
|
|
119
|
-
add(warnings, "card_variants_pending", `${path}.imageVariants`, "Card image variants are not ready yet.");
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
const telegram = asRecord(event.telegram);
|
|
123
|
-
const telegramChannels = Array.isArray(telegram.channelIds) ? telegram.channelIds : [];
|
|
124
|
-
if (telegram.enabled === true && telegramChannels.length === 0) {
|
|
125
|
-
add(warnings, "telegram_channel_missing", "telegram.channelIds", "Telegram is enabled but no channel is configured.");
|
|
126
|
-
}
|
|
127
|
-
const nextActions = [
|
|
128
|
-
...errors.map((item) => `Fix error: ${item.message}`),
|
|
129
|
-
...warnings.slice(0, 5).map((item) => `Review warning: ${item.message}`),
|
|
130
|
-
];
|
|
131
|
-
if (nextActions.length === 0)
|
|
132
|
-
nextActions.push("No blocking setup issues found.");
|
|
133
|
-
const penalty = errors.length * 25 + warnings.length * 8 + suggestions.length * 2;
|
|
134
|
-
return {
|
|
135
|
-
eventId,
|
|
136
|
-
ok: errors.length === 0,
|
|
137
|
-
score: Math.max(0, Math.min(100, 100 - penalty)),
|
|
138
|
-
errors,
|
|
139
|
-
warnings,
|
|
140
|
-
suggestions,
|
|
141
|
-
nextActions,
|
|
142
|
-
links: eventLinks(eventId),
|
|
143
|
-
};
|
|
144
|
-
}
|