@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
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// Контур прав MCP на конкретное событие.
|
|
2
|
-
//
|
|
3
|
-
// Источник истины — grant `eventAccess/{uid}/grants/{eventId}`, который
|
|
4
|
-
// поддерживают CF7 и CF18. Legacy-путь по массивам события остаётся только
|
|
5
|
-
// на случай, когда правила отказали в чтении grants: отличить отказ доступа
|
|
6
|
-
// от сетевого сбоя обязательно, иначе первый молча превращается во второй.
|
|
7
|
-
//
|
|
8
|
-
// Сторож инварианта — `test/event-access-index.test.mjs`.
|
|
9
|
-
import { doc, getDoc } from "firebase/firestore";
|
|
10
|
-
import { firestore } from "../firebase.js";
|
|
11
|
-
import { asRecord } from "./context.js";
|
|
12
|
-
export const EVENT_ACCESS_FIELD_BY_MODE = {
|
|
13
|
-
read: "accessibleBy",
|
|
14
|
-
edit: "editors",
|
|
15
|
-
delete: "deleters",
|
|
16
|
-
live: "liveControllers",
|
|
17
|
-
session: "sessionManagers",
|
|
18
|
-
analytics: "analyticsViewers",
|
|
19
|
-
report: "reportDownloaders",
|
|
20
|
-
moderate: "chatModerators",
|
|
21
|
-
notify: "notificationSenders",
|
|
22
|
-
};
|
|
23
|
-
export function grantAllowsMode(data, mode) {
|
|
24
|
-
const field = EVENT_ACCESS_FIELD_BY_MODE[mode];
|
|
25
|
-
return asRecord(data.capabilities)[field] === true;
|
|
26
|
-
}
|
|
27
|
-
export function legacyEventAllowsMode(data, uid, mode) {
|
|
28
|
-
if (data.ownerId === uid)
|
|
29
|
-
return true;
|
|
30
|
-
const fields = mode === "read"
|
|
31
|
-
? ["accessibleBy", "editors"]
|
|
32
|
-
: [EVENT_ACCESS_FIELD_BY_MODE[mode]];
|
|
33
|
-
return fields.some((field) => (Array.isArray(data[field]) && data[field].includes(uid)));
|
|
34
|
-
}
|
|
35
|
-
export function isFirestorePermissionDenied(error) {
|
|
36
|
-
if (!error || typeof error !== "object")
|
|
37
|
-
return false;
|
|
38
|
-
const code = error.code;
|
|
39
|
-
return code === "permission-denied"
|
|
40
|
-
|| code === "firestore/permission-denied"
|
|
41
|
-
|| code === "PERMISSION_DENIED";
|
|
42
|
-
}
|
|
43
|
-
export async function readEvent(eventId, uid, mode = "read") {
|
|
44
|
-
const [snap, grant] = await Promise.all([
|
|
45
|
-
getDoc(doc(firestore, "events", eventId)),
|
|
46
|
-
getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error) => {
|
|
47
|
-
if (!isFirestorePermissionDenied(error))
|
|
48
|
-
throw error;
|
|
49
|
-
return null;
|
|
50
|
-
}),
|
|
51
|
-
]);
|
|
52
|
-
if (!snap.exists())
|
|
53
|
-
throw new Error("Event not found.");
|
|
54
|
-
const data = snap.data();
|
|
55
|
-
const allowed = grant === null
|
|
56
|
-
? legacyEventAllowsMode(data, uid, mode)
|
|
57
|
-
: data.ownerId === uid || (grant.exists() && grantAllowsMode(grant.data(), mode));
|
|
58
|
-
if (!allowed) {
|
|
59
|
-
throw new Error(`Event does not grant ${mode} access to this organizer.`);
|
|
60
|
-
}
|
|
61
|
-
if (data.trashedAt && mode !== "read" && mode !== "delete") {
|
|
62
|
-
throw new Error("Event is in trash. Restore it before making changes or live actions.");
|
|
63
|
-
}
|
|
64
|
-
return data;
|
|
65
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
// Создание события: проверка права на пространство и запись с зеркалом RTDB.
|
|
2
|
-
//
|
|
3
|
-
// Зеркало ownerId/workspaceId в основной RTDB — опора правил доступа, и
|
|
4
|
-
// пишет его сервер, а не клиент. Создание идёт парой: документ Firestore,
|
|
5
|
-
// затем ensureEventRuntime (CF32) — она ставит зеркало сразу, не дожидаясь
|
|
6
|
-
// триггера.
|
|
7
|
-
//
|
|
8
|
-
// 🔴 Отката здесь намеренно НЕТ, хотя прежняя редакция этой шапки его
|
|
9
|
-
// обещала. Сбой CF32 не оставляет событие сломанным: зеркало всё равно
|
|
10
|
-
// поставит CF20 `onEventCreated` — она пишет ownerId/workspaceId сама
|
|
11
|
-
// (`functions/src/eventsLifecycle.ts`) и объявлена с `retry: true`, то есть
|
|
12
|
-
// доставка гарантирована. Удалять документ из-за сбоя быстрого пути значило
|
|
13
|
-
// бы терять событие там, где медленный путь его чинит.
|
|
14
|
-
import { addDoc, collection, doc, getDoc } from "firebase/firestore";
|
|
15
|
-
import { httpsCallable } from "firebase/functions";
|
|
16
|
-
import { firestore, functions } from "../firebase.js";
|
|
17
|
-
import { safeString } from "../utils.js";
|
|
18
|
-
export async function assertCanCreateInWorkspace(uid, workspaceId) {
|
|
19
|
-
if (workspaceId === uid)
|
|
20
|
-
return;
|
|
21
|
-
const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
22
|
-
if (!wsSnap.exists())
|
|
23
|
-
throw new Error("Workspace not found or not readable.");
|
|
24
|
-
const wsOwnerId = safeString(wsSnap.data().ownerId);
|
|
25
|
-
if (wsOwnerId !== uid) {
|
|
26
|
-
const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
|
|
27
|
-
const perms = (shareSnap.data()?.permissions ?? {});
|
|
28
|
-
if (!shareSnap.exists() || perms.createEvents !== true) {
|
|
29
|
-
throw new Error("This organizer lacks createEvents permission in the workspace.");
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
export async function addEventWithRtdbMirror(eventData) {
|
|
34
|
-
const eventRef = await addDoc(collection(firestore, "events"), eventData);
|
|
35
|
-
try {
|
|
36
|
-
const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
|
|
37
|
-
await ensureRuntime({ eventId: eventRef.id });
|
|
38
|
-
}
|
|
39
|
-
catch (error) {
|
|
40
|
-
console.error(`[OKO MCP] RTDB mirror deferred to onEventCreated for event ${eventRef.id}:`, error);
|
|
41
|
-
}
|
|
42
|
-
return eventRef;
|
|
43
|
-
}
|
package/dist/tools/events.js
DELETED
|
@@ -1,497 +0,0 @@
|
|
|
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, normalizeCardButtonUrl, readCardsConfig, writeEventPatchWithCards } 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
|
-
const current = await readEvent(args.eventId, uid, "edit");
|
|
235
|
-
// 🔴 Режим по расписанию без даты — состояние, которое молча не работает:
|
|
236
|
-
// CF51 отбирает события запросом `analyticsSessionMode == "schedule"` И
|
|
237
|
-
// `meta.eventDate` в окне, поэтому событие без даты не выбирается никогда,
|
|
238
|
-
// а инструмент рапортовал успех. Веб на том же состоянии показывает
|
|
239
|
-
// подсказку `sessionMode.needsDate` (ревью 02.09, Б8).
|
|
240
|
-
if (args.sessionMode === "schedule") {
|
|
241
|
-
const dateAfterPatch = args.eventDate !== undefined
|
|
242
|
-
? args.eventDate
|
|
243
|
-
: current.meta?.eventDate ?? null;
|
|
244
|
-
if (!dateAfterPatch) {
|
|
245
|
-
throw new Error("sessionMode 'schedule' requires meta.eventDate: without a date OKO can never start "
|
|
246
|
-
+ "the session automatically. Set eventDate in the same call, or keep sessionMode 'manual'.");
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
const patch = {};
|
|
250
|
-
if (args.title !== undefined)
|
|
251
|
-
patch.title = args.title.trim();
|
|
252
|
-
if (args.videoUrl !== undefined) {
|
|
253
|
-
const video = normalizeVideo(args.videoUrl);
|
|
254
|
-
patch["video.originalUrl"] = video.originalUrl;
|
|
255
|
-
patch["video.embedUrl"] = video.embedUrl;
|
|
256
|
-
}
|
|
257
|
-
if (args.eventDate !== undefined)
|
|
258
|
-
patch["meta.eventDate"] = args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null;
|
|
259
|
-
if (args.speakerName !== undefined)
|
|
260
|
-
patch["meta.speakerName"] = args.speakerName?.trim() || null;
|
|
261
|
-
if (args.description !== undefined)
|
|
262
|
-
patch["meta.description"] = args.description?.trim() || null;
|
|
263
|
-
if (args.pinned !== undefined)
|
|
264
|
-
patch.pinned = args.pinned;
|
|
265
|
-
if (args.sessionMode !== undefined)
|
|
266
|
-
patch.analyticsSessionMode = args.sessionMode;
|
|
267
|
-
if (args.durationMinutes !== undefined)
|
|
268
|
-
patch["meta.durationMinutes"] = args.durationMinutes;
|
|
269
|
-
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
270
|
-
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
271
|
-
});
|
|
272
|
-
server.registerTool("oko_configure_blocks", {
|
|
273
|
-
title: "Configure event blocks",
|
|
274
|
-
description: toolDescription({
|
|
275
|
-
summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
|
|
276
|
-
args: [
|
|
277
|
-
"eventId: event to edit.",
|
|
278
|
-
"chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
|
|
279
|
-
"forms: replaces configured lead forms when provided.",
|
|
280
|
-
"telegramChannelIds: replaces configured Telegram channels when provided.",
|
|
281
|
-
],
|
|
282
|
-
returns: "event id and the list of Firestore fields updated.",
|
|
283
|
-
examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
|
|
284
|
-
notes: [
|
|
285
|
-
"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.",
|
|
286
|
-
"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.",
|
|
287
|
-
],
|
|
288
|
-
}),
|
|
289
|
-
inputSchema: {
|
|
290
|
-
eventId: eventIdField,
|
|
291
|
-
chat: z.object({ enabled: z.boolean().optional(), visibility: z.enum(["always", "hidden"]).optional() }).optional(),
|
|
292
|
-
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(),
|
|
293
|
-
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(),
|
|
294
|
-
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(),
|
|
295
|
-
forms: z.array(formSchema).max(20).optional(),
|
|
296
|
-
telegramChannelIds: z.array(z.number().int()).max(20).optional(),
|
|
297
|
-
},
|
|
298
|
-
outputSchema: updateOutputSchema,
|
|
299
|
-
annotations: writeTool,
|
|
300
|
-
}, async (args) => {
|
|
301
|
-
const { uid } = await authed(["setup:write"]);
|
|
302
|
-
const current = await readEvent(args.eventId, uid, "edit");
|
|
303
|
-
const patch = {};
|
|
304
|
-
if (args.chat?.enabled !== undefined)
|
|
305
|
-
patch["blocks.chat.enabled"] = args.chat.enabled;
|
|
306
|
-
if (args.chat?.visibility !== undefined)
|
|
307
|
-
patch["blocks.chat.visibility"] = args.chat.visibility;
|
|
308
|
-
if (args.promoBanner?.enabled !== undefined)
|
|
309
|
-
patch["blocks.promoBanner.enabled"] = args.promoBanner.enabled;
|
|
310
|
-
// 🔴 Протокол проверяется так же, как у карточек в этом же файле
|
|
311
|
-
// (`normalizeCardButtonUrl`). Оба значения попадают в `href` кнопки на
|
|
312
|
-
// публичной странице: `javascript:` там — исполнение кода у зрителя, а
|
|
313
|
-
// единственным отличием ссылки баннера от ссылки карточки было то, что
|
|
314
|
-
// её никто не проверял.
|
|
315
|
-
if (args.promoBanner?.link !== undefined)
|
|
316
|
-
patch["blocks.promoBanner.link"] = normalizeCardButtonUrl(args.promoBanner.link);
|
|
317
|
-
if (args.promoBanner?.visibility !== undefined)
|
|
318
|
-
patch["blocks.promoBanner.visibility"] = args.promoBanner.visibility;
|
|
319
|
-
if (args.promoBanner?.formId !== undefined)
|
|
320
|
-
patch["blocks.promoBanner.formId"] = args.promoBanner.formId;
|
|
321
|
-
if (args.primaryCta?.enabled !== undefined)
|
|
322
|
-
patch["blocks.primaryCta.enabled"] = args.primaryCta.enabled;
|
|
323
|
-
if (args.primaryCta?.visibility !== undefined)
|
|
324
|
-
patch["blocks.primaryCta.visibility"] = args.primaryCta.visibility;
|
|
325
|
-
if (args.primaryCta?.title !== undefined)
|
|
326
|
-
patch["blocks.primaryCta.title"] = args.primaryCta.title?.trim() || null;
|
|
327
|
-
if (args.primaryCta?.text !== undefined)
|
|
328
|
-
patch["blocks.primaryCta.text"] = args.primaryCta.text?.trim() || null;
|
|
329
|
-
if (args.primaryCta?.buttonLabel !== undefined)
|
|
330
|
-
patch["blocks.primaryCta.buttonLabel"] = args.primaryCta.buttonLabel?.trim() || null;
|
|
331
|
-
if (args.primaryCta?.buttonUrl !== undefined)
|
|
332
|
-
patch["blocks.primaryCta.buttonUrl"] = normalizeCardButtonUrl(args.primaryCta.buttonUrl);
|
|
333
|
-
if (args.primaryCta?.formId !== undefined)
|
|
334
|
-
patch["blocks.primaryCta.formId"] = args.primaryCta.formId;
|
|
335
|
-
if (args.videoPlaceholder?.enabled !== undefined)
|
|
336
|
-
patch["blocks.video.placeholder.enabled"] = args.videoPlaceholder.enabled;
|
|
337
|
-
if (args.videoPlaceholder?.mode !== undefined)
|
|
338
|
-
patch["blocks.video.placeholder.mode"] = args.videoPlaceholder.mode;
|
|
339
|
-
if (args.videoPlaceholder?.title !== undefined)
|
|
340
|
-
patch["blocks.video.placeholder.title"] = args.videoPlaceholder.title?.trim() || null;
|
|
341
|
-
if (args.videoPlaceholder?.subtitle !== undefined)
|
|
342
|
-
patch["blocks.video.placeholder.subtitle"] = args.videoPlaceholder.subtitle?.trim() || null;
|
|
343
|
-
if (args.videoPlaceholder?.showTimer !== undefined)
|
|
344
|
-
patch["blocks.video.placeholder.showTimer"] = args.videoPlaceholder.showTimer;
|
|
345
|
-
if (args.forms !== undefined)
|
|
346
|
-
patch.forms = args.forms.map((form) => ({
|
|
347
|
-
id: form.id ?? randomId(),
|
|
348
|
-
type: "lead",
|
|
349
|
-
title: form.title?.trim() || null,
|
|
350
|
-
description: form.description?.trim() || null,
|
|
351
|
-
submitLabel: form.submitLabel?.trim() || null,
|
|
352
|
-
successText: form.successText?.trim() || null,
|
|
353
|
-
consentText: form.consentText?.trim() || null,
|
|
354
|
-
consentRequired: form.consentRequired !== false,
|
|
355
|
-
fields: form.fields.map((field) => ({
|
|
356
|
-
id: field.id ?? randomId(),
|
|
357
|
-
label: field.label.trim(),
|
|
358
|
-
type: field.type,
|
|
359
|
-
required: field.required === true,
|
|
360
|
-
placeholder: field.placeholder?.trim() || null,
|
|
361
|
-
})),
|
|
362
|
-
}));
|
|
363
|
-
if (args.telegramChannelIds !== undefined) {
|
|
364
|
-
patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
|
|
365
|
-
patch["telegram.channelIds"] = args.telegramChannelIds;
|
|
366
|
-
}
|
|
367
|
-
// 🔴 `formId` баннера и CTA проверяется на существование — ровно так же,
|
|
368
|
-
// как у карточек в этом же файле (`buildCardsItems` бросает «Form X not
|
|
369
|
-
// found»). Прежде запись проходила, инструмент рапортовал «привязано», а
|
|
370
|
-
// кнопка у зрителей не делала НИЧЕГО: FormHost формы не находит, а
|
|
371
|
-
// buttonUrl в этом сценарии пуст. Формы сверяются с состоянием ПОСЛЕ
|
|
372
|
-
// патча: замена `forms` в том же вызове перегенерирует id (02.09, Б6).
|
|
373
|
-
const formsAfterPatch = (patch.forms ?? current.forms);
|
|
374
|
-
const knownFormIds = new Set((Array.isArray(formsAfterPatch) ? formsAfterPatch : [])
|
|
375
|
-
.map((form) => (typeof form?.id === "string" ? form.id : null))
|
|
376
|
-
.filter((id) => Boolean(id)));
|
|
377
|
-
for (const [field, value] of [
|
|
378
|
-
["blocks.promoBanner.formId", patch["blocks.promoBanner.formId"]],
|
|
379
|
-
["blocks.primaryCta.formId", patch["blocks.primaryCta.formId"]],
|
|
380
|
-
]) {
|
|
381
|
-
if (typeof value === "string" && value && !knownFormIds.has(value)) {
|
|
382
|
-
throw new Error(`${field}: form "${value}" not found in event.forms. Create the form in the same call `
|
|
383
|
-
+ "(or beforehand) — a button bound to a missing form does nothing for viewers.");
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
387
|
-
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
388
|
-
});
|
|
389
|
-
server.registerTool("oko_configure_cards", {
|
|
390
|
-
title: "Configure event cards",
|
|
391
|
-
description: toolDescription({
|
|
392
|
-
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.",
|
|
393
|
-
args: [
|
|
394
|
-
"eventId: event to edit.",
|
|
395
|
-
"enabled: optional Cards block enabled flag.",
|
|
396
|
-
"visibility: optional default visibility, always or hidden.",
|
|
397
|
-
"cardImageRatio: optional image ratio, one of auto, 1:1, 4:3, 3:2, 16:9, 2:3, 3:4.",
|
|
398
|
-
"items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId.",
|
|
399
|
-
],
|
|
400
|
-
returns: "event id, updated Firestore fields, normalized cards config, generated card ids, preserved image ids, removed card ids, and warnings.",
|
|
401
|
-
examples: [
|
|
402
|
-
"Use after oko_create_webinar to create cards before uploading per-card images.",
|
|
403
|
-
"Call oko_get_event first and pass the full desired items array when editing existing cards.",
|
|
404
|
-
"Use formId instead of buttonUrl when a card button should open a lead form.",
|
|
405
|
-
],
|
|
406
|
-
notes: [
|
|
407
|
-
"This tool replaces blocks.cards.items when items is provided; omitted text fields become null.",
|
|
408
|
-
"Existing card images are preserved only when the same card id remains in the replacement list.",
|
|
409
|
-
"buttonUrl allows http(s), mailto, or tel URLs; javascript/data URLs are rejected.",
|
|
410
|
-
"If a removed card had an image, Storage cleanup is not automatic; this matches the web Setup behavior.",
|
|
411
|
-
],
|
|
412
|
-
}),
|
|
413
|
-
inputSchema: {
|
|
414
|
-
eventId: eventIdField,
|
|
415
|
-
enabled: z.boolean().optional().describe("Whether the Cards block is enabled in setup."),
|
|
416
|
-
visibility: z.enum(["always", "hidden"]).optional().describe("Default visibility when no live override exists."),
|
|
417
|
-
cardImageRatio: z.enum(CARD_IMAGE_RATIOS).optional().describe("Image aspect ratio for all cards."),
|
|
418
|
-
items: z.array(cardItemInputSchema).max(MAX_CARD_ITEMS).optional().describe("Full replacement list, max 6 cards. Omit to update only block settings."),
|
|
419
|
-
},
|
|
420
|
-
outputSchema: cardsConfigOutputSchema,
|
|
421
|
-
annotations: writeTool,
|
|
422
|
-
}, async (args) => {
|
|
423
|
-
const { uid } = await authed(["setup:write"]);
|
|
424
|
-
const event = await readEvent(args.eventId, uid, "edit");
|
|
425
|
-
const currentCards = readCardsConfig(event);
|
|
426
|
-
const patch = {};
|
|
427
|
-
const finalCards = {
|
|
428
|
-
enabled: args.enabled ?? currentCards.enabled,
|
|
429
|
-
visibility: args.visibility ?? currentCards.visibility,
|
|
430
|
-
cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
|
|
431
|
-
items: currentCards.items,
|
|
432
|
-
repairedIdPositions: currentCards.repairedIdPositions,
|
|
433
|
-
};
|
|
434
|
-
const generatedCardIds = [];
|
|
435
|
-
const preservedImageCardIds = [];
|
|
436
|
-
let removedCardIds = [];
|
|
437
|
-
let removedCardsWithImages = [];
|
|
438
|
-
if (args.enabled !== undefined)
|
|
439
|
-
patch["blocks.cards.enabled"] = args.enabled;
|
|
440
|
-
if (args.visibility !== undefined)
|
|
441
|
-
patch["blocks.cards.visibility"] = args.visibility;
|
|
442
|
-
if (args.cardImageRatio !== undefined)
|
|
443
|
-
patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
|
|
444
|
-
// 🔴 Карточки пересобираются ВНУТРИ транзакции, из свежего документа.
|
|
445
|
-
// Массив `blocks.cards.items` пишется целиком, а между чтением события и
|
|
446
|
-
// записью успевает отработать CF4 и дописать в него `imageVariants`,
|
|
447
|
-
// `imageUrl` и размеры. Реконструкция из устаревшего снимка их стирала —
|
|
448
|
-
// картинка у зрителя откатывалась к мастеру или пропадала. Это Pattern 23,
|
|
449
|
-
// уже разобранный на веб-стороне и вернувшийся сюда через MCP.
|
|
450
|
-
//
|
|
451
|
-
// Отчёт (что создано, что сохранено, что удалено) тоже считается внутри:
|
|
452
|
-
// иначе он описывал бы не ту запись, которая случилась.
|
|
453
|
-
const buildItems = args.items === undefined ? null : (fresh) => {
|
|
454
|
-
const freshCards = readCardsConfig(fresh);
|
|
455
|
-
const built = buildCardsItems({
|
|
456
|
-
inputs: args.items,
|
|
457
|
-
existing: freshCards.items,
|
|
458
|
-
formIds: eventFormIds(fresh),
|
|
459
|
-
});
|
|
460
|
-
finalCards.items = built.items;
|
|
461
|
-
generatedCardIds.length = 0;
|
|
462
|
-
preservedImageCardIds.length = 0;
|
|
463
|
-
generatedCardIds.push(...built.generatedCardIds);
|
|
464
|
-
preservedImageCardIds.push(...built.preservedImageCardIds);
|
|
465
|
-
const nextIds = new Set(built.items.map((item) => item.id));
|
|
466
|
-
const removed = freshCards.items.filter((item) => !nextIds.has(item.id));
|
|
467
|
-
removedCardIds = removed.map((item) => item.id);
|
|
468
|
-
removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
|
|
469
|
-
return built.items;
|
|
470
|
-
};
|
|
471
|
-
if (Object.keys(patch).length > 0 || buildItems) {
|
|
472
|
-
await writeEventPatchWithCards(args.eventId, patch, buildItems);
|
|
473
|
-
}
|
|
474
|
-
const warnings = removedCardsWithImages.length > 0
|
|
475
|
-
? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
|
|
476
|
-
: [];
|
|
477
|
-
// 🔴 Карточка без пригодного `id` получает подставленный при чтении, и он
|
|
478
|
-
// не совпадает ни с чем в документе: прежняя запись не находится, её
|
|
479
|
-
// картинка обнуляется. Пока это молчало, организатор видел успешный ответ
|
|
480
|
-
// и пропавшее изображение без единого объяснения.
|
|
481
|
-
if (finalCards.repairedIdPositions.length > 0) {
|
|
482
|
-
warnings.push(`Cards at position(s) ${finalCards.repairedIdPositions.join(", ")} had no usable id in Firestore; `
|
|
483
|
-
+ "a temporary one was generated, so their previous images could not be matched and were dropped. "
|
|
484
|
-
+ "Re-upload images for these cards.");
|
|
485
|
-
}
|
|
486
|
-
return textResult({
|
|
487
|
-
eventId: args.eventId,
|
|
488
|
-
updated: Object.keys(patch),
|
|
489
|
-
cards: finalCards,
|
|
490
|
-
generatedCardIds,
|
|
491
|
-
removedCardIds,
|
|
492
|
-
removedCardsWithImages,
|
|
493
|
-
preservedImageCardIds,
|
|
494
|
-
warnings,
|
|
495
|
-
});
|
|
496
|
-
});
|
|
497
|
-
}
|