@okostream/mcp 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/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 +221 -0
- package/dist/tools/context.js +40 -0
- package/dist/tools/eventAccess.js +65 -0
- package/dist/tools/eventCreate.js +43 -0
- package/dist/tools/events.js +464 -0
- package/dist/tools/formatting.js +198 -0
- package/dist/tools/imagePipeline.js +584 -0
- package/dist/tools/images.js +214 -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 +105 -0
- package/dist/tools/schemas.js +296 -0
- package/dist/tools/sessions.js +303 -0
- package/dist/tools/setupValidation.js +144 -0
- package/dist/tools/telegram.js +284 -0
- package/dist/tools/workspaceAccess.js +55 -0
- package/dist/tools/workspaces.js +320 -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,214 @@
|
|
|
1
|
+
// Загрузка и удаление изображений события.
|
|
2
|
+
//
|
|
3
|
+
// Клиент не пишет в Storage напрямую — только через CF26/CF27, которые
|
|
4
|
+
// проверяют доступ, размер и формат. Варианты 320/800/1600 делает CF4 уже
|
|
5
|
+
// после загрузки, поэтому есть отдельный инструмент ожидания: сразу после
|
|
6
|
+
// загрузки картинка ещё не адаптивна.
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { requireConfirm, textResult } from "../utils.js";
|
|
9
|
+
import { authed } from "./context.js";
|
|
10
|
+
import { cardImagesBatchOutputSchema, deleteImageOutputSchema, destructiveTool, eventIdField, imageOutputSchema, imageVariantsOutputSchema, readOnlyTool, toolDescription, writeTool } from "./schemas.js";
|
|
11
|
+
import { cardIdSchema, deleteStorageUrls, IMAGE_KINDS, imageSourceSchema, MAX_CARD_ITEMS, readImageRef, updateCardImageInTransaction, uploadEventImageForUser, waitForImageVariantTargets } from "./imagePipeline.js";
|
|
12
|
+
import { deleteField, doc, updateDoc } from "firebase/firestore";
|
|
13
|
+
import { firestore } from "../firebase.js";
|
|
14
|
+
import { readEvent } from "./eventAccess.js";
|
|
15
|
+
export function registerImageTools(server) {
|
|
16
|
+
server.registerTool("oko_upload_image", {
|
|
17
|
+
title: "Upload event image",
|
|
18
|
+
description: toolDescription({
|
|
19
|
+
summary: "Upload and compress an image for an OKO setup block, then write the master URL back to Firestore.",
|
|
20
|
+
args: [
|
|
21
|
+
"eventId: event to edit.",
|
|
22
|
+
"kind: promoBanner, videoPlaceholder, or card.",
|
|
23
|
+
"cardId: required when kind=card; must match an existing blocks.cards.items id.",
|
|
24
|
+
"source: exactly one of local path or HTTPS image URL.",
|
|
25
|
+
"confirm: must be true because the public event image may be replaced.",
|
|
26
|
+
],
|
|
27
|
+
returns: "eventId, kind, imageId, Storage path, masterUrl, byte counts, and a note that CF-generated variants are pending.",
|
|
28
|
+
examples: [
|
|
29
|
+
'Use kind="promoBanner" with source.url to set a webinar banner from an image link.',
|
|
30
|
+
'Use kind="card" with cardId after the card item already exists.',
|
|
31
|
+
],
|
|
32
|
+
notes: [
|
|
33
|
+
"Uploading an image does not enable its block: toggle the block independently via oko_configure_blocks, in either order.",
|
|
34
|
+
"The MCP server reads local paths on the organizer machine.",
|
|
35
|
+
"Remote URLs must be public HTTPS images, max 15MB; private/local hosts are blocked.",
|
|
36
|
+
"For card images, imageId is the cardId so OKO CF4 can update the correct item.",
|
|
37
|
+
],
|
|
38
|
+
}),
|
|
39
|
+
inputSchema: {
|
|
40
|
+
eventId: eventIdField,
|
|
41
|
+
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
42
|
+
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
43
|
+
source: imageSourceSchema.describe("Image source: exactly one of path or url."),
|
|
44
|
+
confirm: z.boolean().optional().describe("Required true for image upload/replacement."),
|
|
45
|
+
},
|
|
46
|
+
outputSchema: imageOutputSchema,
|
|
47
|
+
annotations: writeTool,
|
|
48
|
+
}, async ({ eventId, kind, cardId, source, confirm }) => {
|
|
49
|
+
const { uid } = await authed(["setup:write"]);
|
|
50
|
+
requireConfirm(confirm, "oko_upload_image", { eventId, kind, cardId: cardId ?? null, sourceType: source.path ? "local" : "https" });
|
|
51
|
+
return textResult(await uploadEventImageForUser(uid, eventId, kind, cardId, source));
|
|
52
|
+
});
|
|
53
|
+
server.registerTool("oko_upload_card_images_batch", {
|
|
54
|
+
title: "Upload card images batch",
|
|
55
|
+
description: toolDescription({
|
|
56
|
+
summary: "Upload images for multiple existing cards, preserving card metadata with Firestore transactions and waiting for generated variants.",
|
|
57
|
+
args: [
|
|
58
|
+
"eventId: event to edit.",
|
|
59
|
+
"images: 1-6 card image uploads. Each cardId must already exist in blocks.cards.items.",
|
|
60
|
+
"timeoutSec: max wait for all CF-generated image variants, default 60 seconds.",
|
|
61
|
+
"confirm: must be true because existing card images may be replaced.",
|
|
62
|
+
],
|
|
63
|
+
returns: "eventId, per-card upload details, per-card variant readiness, ready flag, and warnings.",
|
|
64
|
+
examples: [
|
|
65
|
+
"Use after oko_configure_cards has returned stable card ids.",
|
|
66
|
+
"Use for webinar cards where an agent generated several local images and needs to attach all of them.",
|
|
67
|
+
],
|
|
68
|
+
notes: [
|
|
69
|
+
"This is a workflow tool for cards only. Use oko_upload_image for promoBanner or videoPlaceholder.",
|
|
70
|
+
"The tool does not create cards; it only attaches images to existing cards.",
|
|
71
|
+
],
|
|
72
|
+
}),
|
|
73
|
+
inputSchema: {
|
|
74
|
+
eventId: eventIdField,
|
|
75
|
+
images: z.array(z.object({
|
|
76
|
+
cardId: cardIdSchema.describe("Existing card id."),
|
|
77
|
+
source: imageSourceSchema.describe("Image source for this card."),
|
|
78
|
+
}).strict()).min(1).max(MAX_CARD_ITEMS).describe("Card images to upload."),
|
|
79
|
+
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait for all generated variants."),
|
|
80
|
+
confirm: z.boolean().optional().describe("Required true for batch image upload/replacement."),
|
|
81
|
+
},
|
|
82
|
+
outputSchema: cardImagesBatchOutputSchema,
|
|
83
|
+
annotations: writeTool,
|
|
84
|
+
}, async ({ eventId, images, timeoutSec, confirm }) => {
|
|
85
|
+
const { uid } = await authed(["setup:write"]);
|
|
86
|
+
requireConfirm(confirm, "oko_upload_card_images_batch", { eventId, cardIds: images.map((image) => image.cardId).join(",") });
|
|
87
|
+
const uploads = [];
|
|
88
|
+
for (const image of images) {
|
|
89
|
+
uploads.push(await uploadEventImageForUser(uid, eventId, "card", image.cardId, image.source));
|
|
90
|
+
}
|
|
91
|
+
const variants = await waitForImageVariantTargets(uid, eventId, images.map((image) => ({ kind: "card", cardId: image.cardId })), timeoutSec);
|
|
92
|
+
const warnings = variants
|
|
93
|
+
.filter((item) => !item.ready)
|
|
94
|
+
.map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`);
|
|
95
|
+
return textResult({
|
|
96
|
+
eventId,
|
|
97
|
+
uploads,
|
|
98
|
+
variants,
|
|
99
|
+
ready: variants.every((item) => item.ready),
|
|
100
|
+
warnings,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
server.registerTool("oko_wait_for_image_variants", {
|
|
104
|
+
title: "Wait for image variants",
|
|
105
|
+
description: toolDescription({
|
|
106
|
+
summary: "Poll an event image slot until Cloud Function generated thumb/card/hero variants are visible in Firestore.",
|
|
107
|
+
args: [
|
|
108
|
+
"eventId: event to inspect.",
|
|
109
|
+
"kind: promoBanner, videoPlaceholder, or card.",
|
|
110
|
+
"cardId: required when kind=card.",
|
|
111
|
+
"timeoutSec: max wait, default 60 seconds.",
|
|
112
|
+
],
|
|
113
|
+
returns: "image URLs, imageVariants, ready flag, pending reason, and elapsed time.",
|
|
114
|
+
examples: ["Use after oko_upload_image to avoid checking the UI before image variants are ready."],
|
|
115
|
+
notes: ["Read-only. It does not trigger variant generation; it only waits for CF4's Firestore update."],
|
|
116
|
+
}),
|
|
117
|
+
inputSchema: {
|
|
118
|
+
eventId: eventIdField,
|
|
119
|
+
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
120
|
+
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
121
|
+
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait."),
|
|
122
|
+
},
|
|
123
|
+
outputSchema: imageVariantsOutputSchema,
|
|
124
|
+
annotations: readOnlyTool,
|
|
125
|
+
}, async ({ eventId, kind, cardId, timeoutSec }) => {
|
|
126
|
+
const { uid } = await authed(["events:read"]);
|
|
127
|
+
if (kind === "card" && !cardId)
|
|
128
|
+
throw new Error("cardId is required when kind is card.");
|
|
129
|
+
if (kind !== "card" && cardId)
|
|
130
|
+
throw new Error("cardId is only valid when kind is card.");
|
|
131
|
+
const statuses = await waitForImageVariantTargets(uid, eventId, [{ kind, cardId: cardId ?? null }], timeoutSec);
|
|
132
|
+
const status = statuses[0];
|
|
133
|
+
if (!status)
|
|
134
|
+
throw new Error("Image variant status was not produced.");
|
|
135
|
+
return textResult(status);
|
|
136
|
+
});
|
|
137
|
+
server.registerTool("oko_delete_image", {
|
|
138
|
+
title: "Delete event image",
|
|
139
|
+
description: toolDescription({
|
|
140
|
+
summary: "Delete an OKO setup block image from Storage and clear the corresponding Firestore image fields.",
|
|
141
|
+
args: [
|
|
142
|
+
"eventId: event to edit.",
|
|
143
|
+
"kind: promoBanner, videoPlaceholder, or card.",
|
|
144
|
+
"cardId: required when kind=card.",
|
|
145
|
+
"confirm: must be true.",
|
|
146
|
+
],
|
|
147
|
+
returns: "eventId, kind, deleted=true, and best-effort Storage cleanup warnings if any.",
|
|
148
|
+
examples: ["Use only after confirming the exact event and image slot with the user."],
|
|
149
|
+
notes: ["Destructive. Storage cleanup mirrors the web Setup screen: failed object deletes do not block clearing Firestore fields."],
|
|
150
|
+
}),
|
|
151
|
+
inputSchema: {
|
|
152
|
+
eventId: eventIdField,
|
|
153
|
+
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
154
|
+
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
155
|
+
confirm: z.boolean().optional().describe("Required true for image deletion."),
|
|
156
|
+
},
|
|
157
|
+
outputSchema: deleteImageOutputSchema,
|
|
158
|
+
annotations: destructiveTool,
|
|
159
|
+
}, async ({ eventId, kind, cardId, confirm }) => {
|
|
160
|
+
const { uid } = await authed(["setup:write"]);
|
|
161
|
+
if (kind === "card" && !cardId)
|
|
162
|
+
throw new Error("cardId is required when kind is card.");
|
|
163
|
+
if (kind !== "card" && cardId)
|
|
164
|
+
throw new Error("cardId is only valid when kind is card.");
|
|
165
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
166
|
+
const previous = readImageRef(event, kind, cardId);
|
|
167
|
+
requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
|
|
168
|
+
// 🔴 Сначала снимаем ссылки, потом удаляем объекты. Прежде порядок был
|
|
169
|
+
// обратный: упавший `updateDoc` оставлял документ со ссылками на уже
|
|
170
|
+
// удалённые файлы — зритель видел 404, а повтор восстановить их не мог,
|
|
171
|
+
// потому что объектов больше нет. В обратном порядке отказ Storage
|
|
172
|
+
// оставляет осиротевшие файлы, и это поправимо: их подберёт CF31 при
|
|
173
|
+
// удалении события, а ссылок на них уже нет.
|
|
174
|
+
if (kind === "promoBanner") {
|
|
175
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
176
|
+
"blocks.promoBanner.imageMasterUrl": deleteField(),
|
|
177
|
+
"blocks.promoBanner.imageUrl": deleteField(),
|
|
178
|
+
"blocks.promoBanner.imageVariants": deleteField(),
|
|
179
|
+
"blocks.promoBanner.imageWidth": deleteField(),
|
|
180
|
+
"blocks.promoBanner.imageHeight": deleteField(),
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
else if (kind === "videoPlaceholder") {
|
|
184
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
185
|
+
"blocks.video.placeholder.imageMasterUrl": deleteField(),
|
|
186
|
+
"blocks.video.placeholder.imageUrl": deleteField(),
|
|
187
|
+
"blocks.video.placeholder.imageVariants": deleteField(),
|
|
188
|
+
"blocks.video.placeholder.imageWidth": deleteField(),
|
|
189
|
+
"blocks.video.placeholder.imageHeight": deleteField(),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
await updateCardImageInTransaction(eventId, cardId, {
|
|
194
|
+
imageUrl: null,
|
|
195
|
+
imageMasterUrl: null,
|
|
196
|
+
imageVariants: null,
|
|
197
|
+
imageWidth: null,
|
|
198
|
+
imageHeight: null,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
// Удаление объектов — best-effort и уже после того, как на них никто не
|
|
202
|
+
// ссылается: его отказ попадёт в предупреждения ответа, а не в битые
|
|
203
|
+
// ссылки у зрителя.
|
|
204
|
+
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
205
|
+
return textResult({
|
|
206
|
+
eventId,
|
|
207
|
+
kind,
|
|
208
|
+
imageId: previous.imageId ?? (kind === "card" ? cardId : null),
|
|
209
|
+
deleted: true,
|
|
210
|
+
storageDeletesAttempted: cleanup.attempted,
|
|
211
|
+
storageDeleteWarnings: cleanup.warnings,
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Обёртка над регистрацией инструментов: аудит записей и человеческий ответ
|
|
2
|
+
// на отказ авторизации.
|
|
3
|
+
//
|
|
4
|
+
// Подменяет server.registerTool один раз, поэтому все домены, зарегистрированные
|
|
5
|
+
// ПОСЛЕ вызова, получают обёртку автоматически — включая те, что появятся
|
|
6
|
+
// позже. Читающие инструменты (readOnlyHint) в журнал не пишут: журнал нужен
|
|
7
|
+
// про изменения, а не про просмотры.
|
|
8
|
+
//
|
|
9
|
+
// OkoAuthError превращается в обычный результат, а не в исключение протокола:
|
|
10
|
+
// «ключ не настроен» — это состояние, о котором модели надо рассказать, а не
|
|
11
|
+
// сбой транспорта.
|
|
12
|
+
import { authErrorResult, OkoAuthError } from "../auth.js";
|
|
13
|
+
import { recordToolAudit } from "./audit.js";
|
|
14
|
+
export function installToolInstrumentation(server) {
|
|
15
|
+
const rawRegisterTool = server.registerTool.bind(server);
|
|
16
|
+
server.registerTool = (name, config, handler) => {
|
|
17
|
+
if (typeof name !== "string" || typeof handler !== "function") {
|
|
18
|
+
return rawRegisterTool(name, config, handler);
|
|
19
|
+
}
|
|
20
|
+
const readOnly = Boolean(config && typeof config === "object"
|
|
21
|
+
&& config.annotations?.readOnlyHint === true);
|
|
22
|
+
const wrapped = async (...handlerArgs) => {
|
|
23
|
+
const startedAt = Date.now();
|
|
24
|
+
try {
|
|
25
|
+
const result = await handler(...handlerArgs);
|
|
26
|
+
if (!readOnly)
|
|
27
|
+
await recordToolAudit(name, "success", handlerArgs[0], startedAt);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error instanceof OkoAuthError)
|
|
32
|
+
return authErrorResult(error);
|
|
33
|
+
if (!readOnly)
|
|
34
|
+
await recordToolAudit(name, "error", handlerArgs[0], startedAt, error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
return rawRegisterTool(name, config, wrapped);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Заявки, оставленные зрителями через формы события.
|
|
2
|
+
//
|
|
3
|
+
// Создаёт их только CF45 после проверки согласия и лимитов; отсюда доступны
|
|
4
|
+
// чтение с выгрузкой в CSV и удаление. Это персональные данные людей —
|
|
5
|
+
// выгрузка отдаёт ровно то, что организатор и так видит в аналитике.
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { requireConfirm, serialize, textResult } from "../utils.js";
|
|
8
|
+
import { authed } from "./context.js";
|
|
9
|
+
import { cursorField, deleteLeadOutputSchema, destructiveTool, eventIdField, leadsOutputSchema, limitField, MAX_EXPORT_LIMIT, readOnlyTool, toolDescription } from "./schemas.js";
|
|
10
|
+
import { collection, deleteDoc, doc, documentId, getDoc, getDocs, limit, orderBy, query, startAfter, where } from "firebase/firestore";
|
|
11
|
+
import { csvRows, cursorMeta } from "./formatting.js";
|
|
12
|
+
import { firestore } from "../firebase.js";
|
|
13
|
+
import { readEvent } from "./eventAccess.js";
|
|
14
|
+
export function registerLeadTools(server) {
|
|
15
|
+
server.registerTool("oko_list_leads", {
|
|
16
|
+
title: "List form leads",
|
|
17
|
+
description: toolDescription({
|
|
18
|
+
summary: "Read paginated lead form submissions for an event.",
|
|
19
|
+
args: [
|
|
20
|
+
"eventId: target event.",
|
|
21
|
+
"formId: optional lead form id filter.",
|
|
22
|
+
"csv: return CSV string instead of leads array.",
|
|
23
|
+
"limit: max rows, default 50, max 200.",
|
|
24
|
+
"cursor: lead document id returned as nextCursor by the previous page.",
|
|
25
|
+
],
|
|
26
|
+
returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
27
|
+
examples: ["Use formId to export one form; if Firestore requests an index, create it from the error link and rerun."],
|
|
28
|
+
}),
|
|
29
|
+
inputSchema: {
|
|
30
|
+
eventId: eventIdField,
|
|
31
|
+
formId: z.string().optional().describe("Optional lead form id to filter submissions."),
|
|
32
|
+
csv: z.boolean().default(false).describe("Return CSV string instead of JSON rows."),
|
|
33
|
+
limit: limitField(MAX_EXPORT_LIMIT, 50),
|
|
34
|
+
cursor: cursorField,
|
|
35
|
+
},
|
|
36
|
+
outputSchema: leadsOutputSchema,
|
|
37
|
+
annotations: readOnlyTool,
|
|
38
|
+
}, async ({ eventId, formId, csv, limit: max, cursor }) => {
|
|
39
|
+
const { uid } = await authed(["leads:read"]);
|
|
40
|
+
await readEvent(eventId, uid, "analytics");
|
|
41
|
+
const base = collection(firestore, "events", eventId, "leads");
|
|
42
|
+
const constraints = formId
|
|
43
|
+
? [where("formId", "==", formId), orderBy(documentId()), ...(cursor ? [startAfter(cursor)] : []), limit(max + 1)]
|
|
44
|
+
: [orderBy(documentId()), ...(cursor ? [startAfter(cursor)] : []), limit(max + 1)];
|
|
45
|
+
const snap = await getDocs(query(base, ...constraints));
|
|
46
|
+
const page = snap.docs.slice(0, max);
|
|
47
|
+
const leads = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
48
|
+
const hasMore = snap.docs.length > max;
|
|
49
|
+
const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
|
|
50
|
+
const meta = cursorMeta(leads.length, max, hasMore, nextCursor);
|
|
51
|
+
if (csv) {
|
|
52
|
+
const text = csvRows(["id", "formId", "source", "submittedAt", "valuesJson"], leads.map((lead) => [lead.id, lead.formId, lead.source, lead.submittedAt, JSON.stringify(lead.values ?? {})]));
|
|
53
|
+
return textResult({ csv: text, ...meta });
|
|
54
|
+
}
|
|
55
|
+
return textResult({ leads, ...meta });
|
|
56
|
+
});
|
|
57
|
+
server.registerTool("oko_delete_lead", {
|
|
58
|
+
title: "Delete form lead",
|
|
59
|
+
description: toolDescription({
|
|
60
|
+
summary: "Delete one lead/form submission from an event.",
|
|
61
|
+
args: ["eventId: event id.", "leadId: events/{eventId}/leads document id.", "confirm: must be true."],
|
|
62
|
+
returns: "eventId, leadId, deleted=true, and a small preview of the deleted lead.",
|
|
63
|
+
examples: ["Use after listing leads and confirming the exact submission to remove."],
|
|
64
|
+
notes: ["Destructive. Requires setup:write in addition to leads:read so read-only keys cannot delete leads."],
|
|
65
|
+
}),
|
|
66
|
+
inputSchema: {
|
|
67
|
+
eventId: eventIdField,
|
|
68
|
+
leadId: z.string().min(1).max(160).describe("Lead document id under events/{eventId}/leads."),
|
|
69
|
+
confirm: z.boolean().optional().describe("Required true for lead deletion."),
|
|
70
|
+
},
|
|
71
|
+
outputSchema: deleteLeadOutputSchema,
|
|
72
|
+
annotations: destructiveTool,
|
|
73
|
+
}, async ({ eventId, leadId, confirm }) => {
|
|
74
|
+
const { uid } = await authed(["leads:read", "setup:write"]);
|
|
75
|
+
await readEvent(eventId, uid, "edit");
|
|
76
|
+
const leadRef = doc(firestore, "events", eventId, "leads", leadId);
|
|
77
|
+
const leadSnap = await getDoc(leadRef);
|
|
78
|
+
if (!leadSnap.exists())
|
|
79
|
+
throw new Error("Lead not found.");
|
|
80
|
+
const lead = serialize(leadSnap.data());
|
|
81
|
+
requireConfirm(confirm, "oko_delete_lead", { eventId, leadId, formId: lead.formId, source: lead.source, submittedAt: lead.submittedAt });
|
|
82
|
+
await deleteDoc(leadRef);
|
|
83
|
+
return textResult({ eventId, leadId, deleted: true, leadPreview: lead });
|
|
84
|
+
});
|
|
85
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Жизненный цикл события: корзина, восстановление, дублирование, перенос.
|
|
2
|
+
//
|
|
3
|
+
// Корзина обратима 30 дней, окончательное удаление — нет, поэтому оба
|
|
4
|
+
// разрушающих инструмента требуют явного confirm и показывают цель заранее.
|
|
5
|
+
// Тяжёлая работа (очистка обеих RTDB, Storage, уведомлений) живёт в CF31:
|
|
6
|
+
// клиент такую последовательность довести до конца не может.
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { requireConfirm, safeString, textResult } from "../utils.js";
|
|
9
|
+
import { authed, eventLinks } from "./context.js";
|
|
10
|
+
import { createWebinarOutputSchema, destructiveTool, eventIdField, idempotentWriteTool, toolDescription, unknownRecordSchema, updateOutputSchema, writeTool } from "./schemas.js";
|
|
11
|
+
import { functions } from "../firebase.js";
|
|
12
|
+
import { httpsCallable } from "firebase/functions";
|
|
13
|
+
import { readEvent } from "./eventAccess.js";
|
|
14
|
+
import { serverTimestamp } from "firebase/firestore";
|
|
15
|
+
import { addEventWithRtdbMirror, assertCanCreateInWorkspace } from "./eventCreate.js";
|
|
16
|
+
export function registerLifecycleTools(server) {
|
|
17
|
+
server.registerTool("oko_delete_event", {
|
|
18
|
+
title: "Move event to trash",
|
|
19
|
+
description: toolDescription({
|
|
20
|
+
summary: "Move an OKO event to the 30-day trash. This compatibility alias has the same behavior as oko_trash_event.",
|
|
21
|
+
args: ["eventId: event to move to trash.", "confirm: must be true."],
|
|
22
|
+
returns: "trashed event id and purge deadline in epoch milliseconds.",
|
|
23
|
+
examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
|
|
24
|
+
notes: ["Recoverable for 30 days with oko_restore_event. This does not create an event lifecycle status."],
|
|
25
|
+
}),
|
|
26
|
+
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
|
|
27
|
+
outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
|
|
28
|
+
annotations: destructiveTool,
|
|
29
|
+
}, async ({ eventId, confirm }) => {
|
|
30
|
+
const { uid } = await authed(["events:delete"]);
|
|
31
|
+
const data = await readEvent(eventId, uid, "delete");
|
|
32
|
+
requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
|
|
33
|
+
const trash = httpsCallable(functions, "trashEvent");
|
|
34
|
+
const result = await trash({ eventId });
|
|
35
|
+
return textResult(result.data);
|
|
36
|
+
});
|
|
37
|
+
server.registerTool("oko_trash_event", {
|
|
38
|
+
title: "Move event to trash",
|
|
39
|
+
description: toolDescription({
|
|
40
|
+
summary: "Move an OKO event to the recoverable 30-day trash.",
|
|
41
|
+
args: ["eventId: event to move to trash.", "confirm: must be true."],
|
|
42
|
+
returns: "trashed event id and purge deadline in epoch milliseconds.",
|
|
43
|
+
examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
|
|
44
|
+
notes: ["End any active analytics session first. Restore with oko_restore_event before the purge deadline."],
|
|
45
|
+
}),
|
|
46
|
+
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
|
|
47
|
+
outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
|
|
48
|
+
annotations: destructiveTool,
|
|
49
|
+
}, async ({ eventId, confirm }) => {
|
|
50
|
+
const { uid } = await authed(["events:delete"]);
|
|
51
|
+
const event = await readEvent(eventId, uid, "delete");
|
|
52
|
+
requireConfirm(confirm, "oko_trash_event", { eventId, title: event.title });
|
|
53
|
+
const trash = httpsCallable(functions, "trashEvent");
|
|
54
|
+
return textResult((await trash({ eventId })).data);
|
|
55
|
+
});
|
|
56
|
+
server.registerTool("oko_restore_event", {
|
|
57
|
+
title: "Restore event",
|
|
58
|
+
description: toolDescription({
|
|
59
|
+
summary: "Restore an OKO event from the 30-day trash before its purge deadline.",
|
|
60
|
+
args: ["eventId: trashed event to restore."],
|
|
61
|
+
returns: "restored event id.",
|
|
62
|
+
examples: ["Use when the organizer asks to undo a trash action."],
|
|
63
|
+
notes: ["Idempotent. Fails after the trash retention deadline has expired."],
|
|
64
|
+
}),
|
|
65
|
+
inputSchema: { eventId: eventIdField },
|
|
66
|
+
outputSchema: z.object({ restored: z.string() }).passthrough(),
|
|
67
|
+
annotations: idempotentWriteTool,
|
|
68
|
+
}, async ({ eventId }) => {
|
|
69
|
+
const { uid } = await authed(["events:delete"]);
|
|
70
|
+
await readEvent(eventId, uid, "delete");
|
|
71
|
+
const restore = httpsCallable(functions, "restoreEvent");
|
|
72
|
+
return textResult((await restore({ eventId })).data);
|
|
73
|
+
});
|
|
74
|
+
server.registerTool("oko_delete_event_permanently", {
|
|
75
|
+
title: "Delete event permanently",
|
|
76
|
+
description: toolDescription({
|
|
77
|
+
summary: "Permanently delete a trashed OKO event and all associated Firestore, RTDB, chat, Storage, participant, session, and Telegram data.",
|
|
78
|
+
args: [
|
|
79
|
+
"eventId: trashed event to delete permanently.",
|
|
80
|
+
"confirmTitle: exact current event title, including case and spacing.",
|
|
81
|
+
"confirm: must be true.",
|
|
82
|
+
],
|
|
83
|
+
returns: "deleted event id and cross-store cleanup counts.",
|
|
84
|
+
examples: ["Read the event first, show its exact title, and proceed only after the organizer explicitly types or confirms that exact title."],
|
|
85
|
+
notes: ["Irreversible. The event must already be in trash; use oko_trash_event first."],
|
|
86
|
+
}),
|
|
87
|
+
inputSchema: {
|
|
88
|
+
eventId: eventIdField,
|
|
89
|
+
confirmTitle: z.string().max(500).describe("Exact current event title."),
|
|
90
|
+
confirm: z.boolean().optional().describe("Required true for permanent deletion."),
|
|
91
|
+
},
|
|
92
|
+
outputSchema: z.object({ deleted: z.string(), cleanup: unknownRecordSchema }).passthrough(),
|
|
93
|
+
annotations: destructiveTool,
|
|
94
|
+
}, async ({ eventId, confirmTitle, confirm }) => {
|
|
95
|
+
const { uid } = await authed(["events:delete"]);
|
|
96
|
+
const event = await readEvent(eventId, uid, "delete");
|
|
97
|
+
const title = safeString(event.title);
|
|
98
|
+
if (confirmTitle !== title)
|
|
99
|
+
throw new Error("confirmTitle must exactly match the current event title.");
|
|
100
|
+
requireConfirm(confirm, "oko_delete_event_permanently", { eventId, title });
|
|
101
|
+
const remove = httpsCallable(functions, "deleteEventCompletely");
|
|
102
|
+
return textResult((await remove({ eventId, confirmTitle })).data);
|
|
103
|
+
});
|
|
104
|
+
server.registerTool("oko_duplicate_event", {
|
|
105
|
+
title: "Duplicate event",
|
|
106
|
+
description: toolDescription({
|
|
107
|
+
summary: "Duplicate an event setup into the same or another workspace without analytics/session history.",
|
|
108
|
+
args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
|
|
109
|
+
returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
|
|
110
|
+
examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
|
|
111
|
+
notes: ["Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions or leads."],
|
|
112
|
+
}),
|
|
113
|
+
inputSchema: {
|
|
114
|
+
eventId: eventIdField,
|
|
115
|
+
targetWorkspaceId: z.string().min(1).optional().describe("Target workspace id; omit to use the source workspace or personal workspace."),
|
|
116
|
+
},
|
|
117
|
+
outputSchema: createWebinarOutputSchema,
|
|
118
|
+
annotations: writeTool,
|
|
119
|
+
}, async ({ eventId, targetWorkspaceId }) => {
|
|
120
|
+
const { uid } = await authed(["events:write"]);
|
|
121
|
+
const source = await readEvent(eventId, uid, "read");
|
|
122
|
+
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
123
|
+
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
124
|
+
const newRef = await addEventWithRtdbMirror({
|
|
125
|
+
ownerId: uid,
|
|
126
|
+
workspaceId,
|
|
127
|
+
createdAt: serverTimestamp(),
|
|
128
|
+
title: safeString(source.title, "Untitled event"),
|
|
129
|
+
pinned: false,
|
|
130
|
+
video: source.video ?? { originalUrl: null, embedUrl: null },
|
|
131
|
+
meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
|
|
132
|
+
blocks: source.blocks ?? {},
|
|
133
|
+
...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
|
|
134
|
+
...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
|
|
135
|
+
});
|
|
136
|
+
return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
|
|
137
|
+
});
|
|
138
|
+
server.registerTool("oko_move_event", {
|
|
139
|
+
title: "Move event",
|
|
140
|
+
description: toolDescription({
|
|
141
|
+
summary: "Move an event to another workspace and recompute accessibleBy/editors.",
|
|
142
|
+
args: ["eventId: event to move.", "targetWorkspaceId: target workspace id.", "confirm: must be true because access changes."],
|
|
143
|
+
returns: "eventId, targetWorkspaceId, updated Firestore fields, and RTDB mirror status.",
|
|
144
|
+
examples: ["Use when an event belongs in another workspace. Call oko_list_workspaces first to choose the target."],
|
|
145
|
+
notes: ["Reads target workspace shares from Firestore with permissions; does not use RTDB workspaceMembers."],
|
|
146
|
+
}),
|
|
147
|
+
inputSchema: {
|
|
148
|
+
eventId: eventIdField,
|
|
149
|
+
targetWorkspaceId: z.string().min(1).describe("Target workspace id."),
|
|
150
|
+
confirm: z.boolean().optional().describe("Required true for event move."),
|
|
151
|
+
},
|
|
152
|
+
outputSchema: updateOutputSchema,
|
|
153
|
+
annotations: writeTool,
|
|
154
|
+
}, async ({ eventId, targetWorkspaceId, confirm }) => {
|
|
155
|
+
await authed(["events:write", "workspace:write"]);
|
|
156
|
+
requireConfirm(confirm, "oko_move_event", { eventId, targetWorkspaceId });
|
|
157
|
+
const moveEvent = httpsCallable(functions, "moveEventToWorkspace");
|
|
158
|
+
const result = await moveEvent({ eventId, targetWorkspaceId });
|
|
159
|
+
return textResult(result.data);
|
|
160
|
+
});
|
|
161
|
+
}
|