@okostream/mcp 0.11.0 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/onboarding.js +8 -3
- package/dist/server.js +3 -3
- package/dist/tools/account.js +77 -0
- package/dist/tools/audit.js +44 -0
- package/dist/tools/chat.js +206 -0
- package/dist/tools/context.js +40 -0
- package/dist/tools/eventAccess.js +65 -0
- package/dist/tools/eventCreate.js +37 -0
- package/dist/tools/events.js +437 -0
- package/dist/tools/formatting.js +177 -0
- package/dist/tools/imagePipeline.js +541 -0
- package/dist/tools/images.js +205 -0
- package/dist/tools/instrumentation.js +40 -0
- package/dist/tools/leads.js +85 -0
- package/dist/tools/lifecycle.js +161 -0
- package/dist/tools/live.js +190 -0
- package/dist/tools/notifications.js +88 -0
- package/dist/tools/schemas.js +296 -0
- package/dist/tools/sessions.js +298 -0
- package/dist/tools/setupValidation.js +137 -0
- package/dist/tools/telegram.js +279 -0
- package/dist/tools/workspaceAccess.js +55 -0
- package/dist/tools/workspaces.js +292 -0
- package/dist/tools.js +36 -3489
- package/dist/version.js +1 -1
- package/dist/videoUrl.js +8 -4
- package/package.json +2 -1
package/dist/tools.js
CHANGED
|
@@ -1,3491 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
const IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
|
|
22
|
-
const IMAGE_VARIANT_NAMES = ["thumb_320.webp", "card_800.webp", "hero_1600.webp"];
|
|
23
|
-
const CARD_IMAGE_RATIOS = ["auto", "1:1", "4:3", "3:2", "16:9", "2:3", "3:4"];
|
|
24
|
-
const CARD_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
|
|
25
|
-
const MAX_CARD_ITEMS = 6;
|
|
26
|
-
const MAX_CARD_TITLE_LENGTH = 160;
|
|
27
|
-
const MAX_CARD_TEXT_LENGTH = 500;
|
|
28
|
-
const MAX_CARD_BUTTON_LABEL_LENGTH = 80;
|
|
29
|
-
const MAX_CARD_BUTTON_URL_LENGTH = 1000;
|
|
30
|
-
const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
31
|
-
const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
|
|
32
|
-
const REMOTE_IMAGE_MAX_REDIRECTS = 3;
|
|
33
|
-
const ALLOWED_REMOTE_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
|
34
|
-
const DEFAULT_LIST_LIMIT = 30;
|
|
35
|
-
const MAX_LIST_LIMIT = 100;
|
|
36
|
-
const MAX_EXPORT_LIMIT = 200;
|
|
37
|
-
const ANALYTICS_DEFAULT_LIMIT = 10;
|
|
38
|
-
const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
|
|
39
|
-
const MAX_WORKSPACE_NAME_LENGTH = 120;
|
|
40
|
-
const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
|
|
41
|
-
const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed", "delivery_unknown"];
|
|
42
|
-
const readOnlyTool = {
|
|
43
|
-
readOnlyHint: true,
|
|
44
|
-
destructiveHint: false,
|
|
45
|
-
idempotentHint: true,
|
|
46
|
-
openWorldHint: true,
|
|
47
|
-
};
|
|
48
|
-
const writeTool = {
|
|
49
|
-
readOnlyHint: false,
|
|
50
|
-
destructiveHint: false,
|
|
51
|
-
idempotentHint: false,
|
|
52
|
-
openWorldHint: true,
|
|
53
|
-
};
|
|
54
|
-
const idempotentWriteTool = {
|
|
55
|
-
readOnlyHint: false,
|
|
56
|
-
destructiveHint: false,
|
|
57
|
-
idempotentHint: true,
|
|
58
|
-
openWorldHint: true,
|
|
59
|
-
};
|
|
60
|
-
const destructiveTool = {
|
|
61
|
-
readOnlyHint: false,
|
|
62
|
-
destructiveHint: true,
|
|
63
|
-
idempotentHint: false,
|
|
64
|
-
openWorldHint: true,
|
|
65
|
-
};
|
|
66
|
-
const unknownRecordSchema = z.record(z.unknown());
|
|
67
|
-
const basicEventOutputSchema = z.object({
|
|
68
|
-
eventId: z.string(),
|
|
69
|
-
}).passthrough();
|
|
70
|
-
const updateOutputSchema = z.object({
|
|
71
|
-
id: z.string().optional(),
|
|
72
|
-
eventId: z.string().optional(),
|
|
73
|
-
updated: z.array(z.string()).optional(),
|
|
74
|
-
}).passthrough();
|
|
75
|
-
const eventLinksOutputShape = {
|
|
76
|
-
url: z.string().url(),
|
|
77
|
-
setupUrl: z.string().url(),
|
|
78
|
-
analyticsUrl: z.string().url(),
|
|
79
|
-
};
|
|
80
|
-
const createWebinarOutputSchema = z.object({
|
|
81
|
-
id: z.string(),
|
|
82
|
-
workspaceId: z.string(),
|
|
83
|
-
sourceEventId: z.string().optional(),
|
|
84
|
-
...eventLinksOutputShape,
|
|
85
|
-
}).passthrough();
|
|
86
|
-
const connectionStatusOutputSchema = z.object({
|
|
87
|
-
connected: z.boolean(),
|
|
88
|
-
status: z.enum(OKO_CONNECTION_STATUSES),
|
|
89
|
-
errorCode: z.string().nullable(),
|
|
90
|
-
message: z.string(),
|
|
91
|
-
product: z.object({
|
|
92
|
-
name: z.string(),
|
|
93
|
-
summary: z.string(),
|
|
94
|
-
videoBoundary: z.string(),
|
|
95
|
-
accountRequirement: z.string(),
|
|
96
|
-
accessModel: z.string(),
|
|
97
|
-
}),
|
|
98
|
-
organizerAccountRequired: z.boolean(),
|
|
99
|
-
credentialConfigured: z.boolean(),
|
|
100
|
-
links: z.object({
|
|
101
|
-
website: z.string().url(),
|
|
102
|
-
signIn: z.string().url(),
|
|
103
|
-
installCenter: z.string().url(),
|
|
104
|
-
}),
|
|
105
|
-
nextSteps: z.array(z.object({
|
|
106
|
-
id: z.string(),
|
|
107
|
-
label: z.string(),
|
|
108
|
-
url: z.string().url().optional(),
|
|
109
|
-
command: z.string().optional(),
|
|
110
|
-
})),
|
|
111
|
-
minimumVersion: z.string().optional(),
|
|
112
|
-
uid: z.string().optional(),
|
|
113
|
-
scopes: z.array(z.string()).optional(),
|
|
114
|
-
});
|
|
115
|
-
const cursorMetaSchema = {
|
|
116
|
-
count: z.number().int().nonnegative(),
|
|
117
|
-
limit: z.number().int().positive(),
|
|
118
|
-
hasMore: z.boolean(),
|
|
119
|
-
nextCursor: z.string().nullable(),
|
|
120
|
-
has_more: z.boolean(),
|
|
121
|
-
next_cursor: z.string().nullable(),
|
|
122
|
-
};
|
|
123
|
-
const workspacesOutputSchema = z.object({
|
|
124
|
-
workspaces: z.array(unknownRecordSchema),
|
|
125
|
-
count: z.number().int().nonnegative(),
|
|
126
|
-
}).passthrough();
|
|
127
|
-
const eventsOutputSchema = z.object({
|
|
128
|
-
events: z.array(unknownRecordSchema),
|
|
129
|
-
...cursorMetaSchema,
|
|
130
|
-
}).passthrough();
|
|
131
|
-
const eventOutputSchema = z.object({
|
|
132
|
-
id: z.string(),
|
|
133
|
-
...eventLinksOutputShape,
|
|
134
|
-
}).passthrough();
|
|
135
|
-
const liveStateOutputSchema = z.object({
|
|
136
|
-
eventId: z.string(),
|
|
137
|
-
agg: unknownRecordSchema,
|
|
138
|
-
ctrl: unknownRecordSchema,
|
|
139
|
-
chat: unknownRecordSchema,
|
|
140
|
-
}).passthrough();
|
|
141
|
-
const analyticsOutputSchema = z.object({
|
|
142
|
-
sessions: z.array(unknownRecordSchema),
|
|
143
|
-
...cursorMetaSchema,
|
|
144
|
-
}).passthrough();
|
|
145
|
-
const chatOutputSchema = z.object({
|
|
146
|
-
messages: z.array(unknownRecordSchema),
|
|
147
|
-
...cursorMetaSchema,
|
|
148
|
-
}).passthrough();
|
|
149
|
-
const leadsOutputSchema = z.object({
|
|
150
|
-
leads: z.array(unknownRecordSchema).optional(),
|
|
151
|
-
csv: z.string().optional(),
|
|
152
|
-
...cursorMetaSchema,
|
|
153
|
-
}).passthrough();
|
|
154
|
-
const imageOutputSchema = z.object({
|
|
155
|
-
eventId: z.string(),
|
|
156
|
-
kind: z.enum(IMAGE_KINDS),
|
|
157
|
-
imageId: z.string(),
|
|
158
|
-
storagePath: z.string(),
|
|
159
|
-
masterUrl: z.string().url(),
|
|
160
|
-
variantsPending: z.boolean(),
|
|
161
|
-
sourceBytes: z.number().int().nonnegative(),
|
|
162
|
-
masterBytes: z.number().int().nonnegative(),
|
|
163
|
-
imageWidth: z.number().int().positive(),
|
|
164
|
-
imageHeight: z.number().int().positive(),
|
|
165
|
-
note: z.string(),
|
|
166
|
-
}).passthrough();
|
|
167
|
-
const deleteImageOutputSchema = z.object({
|
|
168
|
-
eventId: z.string(),
|
|
169
|
-
kind: z.enum(IMAGE_KINDS),
|
|
170
|
-
imageId: z.string().nullable(),
|
|
171
|
-
deleted: z.boolean(),
|
|
172
|
-
storageDeletesAttempted: z.number().int().nonnegative(),
|
|
173
|
-
storageDeleteWarnings: z.array(z.string()),
|
|
174
|
-
}).passthrough();
|
|
175
|
-
const imageVariantsOutputSchema = z.object({
|
|
176
|
-
eventId: z.string(),
|
|
177
|
-
kind: z.enum(IMAGE_KINDS),
|
|
178
|
-
cardId: z.string().nullable(),
|
|
179
|
-
imageId: z.string().nullable(),
|
|
180
|
-
imageUrl: z.string().url().nullable(),
|
|
181
|
-
imageMasterUrl: z.string().url().nullable(),
|
|
182
|
-
imageVariants: unknownRecordSchema.nullable(),
|
|
183
|
-
ready: z.boolean(),
|
|
184
|
-
reason: z.string().nullable(),
|
|
185
|
-
elapsedMs: z.number().int().nonnegative(),
|
|
186
|
-
}).passthrough();
|
|
187
|
-
const cardImagesBatchOutputSchema = z.object({
|
|
188
|
-
eventId: z.string(),
|
|
189
|
-
uploads: z.array(unknownRecordSchema),
|
|
190
|
-
variants: z.array(unknownRecordSchema),
|
|
191
|
-
ready: z.boolean(),
|
|
192
|
-
warnings: z.array(z.string()),
|
|
193
|
-
}).passthrough();
|
|
194
|
-
const setupValidationOutputSchema = z.object({
|
|
195
|
-
eventId: z.string(),
|
|
196
|
-
ok: z.boolean(),
|
|
197
|
-
score: z.number().int().min(0).max(100),
|
|
198
|
-
errors: z.array(unknownRecordSchema),
|
|
199
|
-
warnings: z.array(unknownRecordSchema),
|
|
200
|
-
suggestions: z.array(unknownRecordSchema),
|
|
201
|
-
nextActions: z.array(z.string()),
|
|
202
|
-
links: unknownRecordSchema,
|
|
203
|
-
}).passthrough();
|
|
204
|
-
const workspaceAccessOutputSchema = z.object({
|
|
205
|
-
workspaceId: z.string(),
|
|
206
|
-
workspace: unknownRecordSchema,
|
|
207
|
-
members: z.array(unknownRecordSchema),
|
|
208
|
-
invites: z.array(unknownRecordSchema),
|
|
209
|
-
}).passthrough();
|
|
210
|
-
const telegramChannelsOutputSchema = z.object({
|
|
211
|
-
channels: z.array(canonicalTelegramChannelSchema),
|
|
212
|
-
count: z.number().int().nonnegative(),
|
|
213
|
-
}).passthrough();
|
|
214
|
-
const telegramPostsOutputSchema = z.object({
|
|
215
|
-
posts: z.array(unknownRecordSchema),
|
|
216
|
-
...cursorMetaSchema,
|
|
217
|
-
}).passthrough();
|
|
218
|
-
const sessionReportOutputSchema = z.object({
|
|
219
|
-
eventId: z.string(),
|
|
220
|
-
sessionId: z.string(),
|
|
221
|
-
reportUrl: z.string().url(),
|
|
222
|
-
markdown: z.string(),
|
|
223
|
-
session: unknownRecordSchema,
|
|
224
|
-
}).passthrough();
|
|
225
|
-
const profileOutputSchema = z.object({
|
|
226
|
-
uid: z.string(),
|
|
227
|
-
profile: unknownRecordSchema,
|
|
228
|
-
updated: z.array(z.string()),
|
|
229
|
-
}).passthrough();
|
|
230
|
-
const deleteLeadOutputSchema = z.object({
|
|
231
|
-
eventId: z.string(),
|
|
232
|
-
leadId: z.string(),
|
|
233
|
-
deleted: z.boolean(),
|
|
234
|
-
}).passthrough();
|
|
235
|
-
const notificationsOutputSchema = z.object({
|
|
236
|
-
notifications: z.array(unknownRecordSchema),
|
|
237
|
-
unreadCountOnPage: z.number().int().nonnegative(),
|
|
238
|
-
...cursorMetaSchema,
|
|
239
|
-
}).passthrough();
|
|
240
|
-
const notificationsMarkOutputSchema = z.object({
|
|
241
|
-
markedRead: z.number().int().nonnegative(),
|
|
242
|
-
notificationIds: z.array(z.string()),
|
|
243
|
-
}).passthrough();
|
|
244
|
-
const formFieldSchema = z.object({
|
|
245
|
-
id: z.string().min(1).optional(),
|
|
246
|
-
label: z.string().max(120),
|
|
247
|
-
type: z.enum(["text", "tel", "email", "textarea"]).default("text"),
|
|
248
|
-
required: z.boolean().default(false),
|
|
249
|
-
placeholder: z.string().max(160).nullable().optional(),
|
|
250
|
-
}).strict();
|
|
251
|
-
const formSchema = z.object({
|
|
252
|
-
id: z.string().min(1).optional(),
|
|
253
|
-
title: z.string().max(160).nullable().optional(),
|
|
254
|
-
description: z.string().max(1000).nullable().optional(),
|
|
255
|
-
submitLabel: z.string().max(80).nullable().optional(),
|
|
256
|
-
successText: z.string().max(500).nullable().optional(),
|
|
257
|
-
consentText: z.string().max(500).nullable().optional(),
|
|
258
|
-
consentRequired: z.boolean().default(true),
|
|
259
|
-
fields: z.array(formFieldSchema).max(30),
|
|
260
|
-
}).strict();
|
|
261
|
-
const imageSourceSchema = z.object({
|
|
262
|
-
path: z.string().min(1).optional().describe("Local image path on the MCP client machine."),
|
|
263
|
-
url: z.string().url().optional().describe("HTTPS image URL to fetch from the MCP client machine."),
|
|
264
|
-
}).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
|
|
265
|
-
message: "Provide exactly one image source: path or url.",
|
|
266
|
-
});
|
|
267
|
-
const cardIdSchema = z.string()
|
|
268
|
-
.regex(CARD_ID_PATTERN, "Use 1-80 characters: letters, numbers, underscore, or hyphen.")
|
|
269
|
-
.describe("Stable card id. Omit to generate one; keep it to preserve images and analytics continuity.");
|
|
270
|
-
const cardItemInputSchema = z.object({
|
|
271
|
-
id: cardIdSchema.optional(),
|
|
272
|
-
title: z.string().max(MAX_CARD_TITLE_LENGTH).nullable().optional(),
|
|
273
|
-
text: z.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
|
|
274
|
-
buttonLabel: z.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
|
|
275
|
-
buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
|
|
276
|
-
formId: cardIdSchema.nullable().optional(),
|
|
277
|
-
}).strict();
|
|
278
|
-
const profileDisplayNameSchema = z.string().max(40).nullable();
|
|
279
|
-
const timezoneSchema = z.string()
|
|
280
|
-
.min(1)
|
|
281
|
-
.max(100)
|
|
282
|
-
.regex(/^[A-Za-z0-9_+./-]+$/, "Use an IANA timezone such as Europe/Moscow or UTC.");
|
|
283
|
-
const cardsConfigOutputSchema = z.object({
|
|
284
|
-
eventId: z.string(),
|
|
285
|
-
updated: z.array(z.string()),
|
|
286
|
-
cards: z.object({
|
|
287
|
-
enabled: z.boolean(),
|
|
288
|
-
visibility: z.enum(["always", "hidden"]),
|
|
289
|
-
cardImageRatio: z.enum(CARD_IMAGE_RATIOS),
|
|
290
|
-
items: z.array(unknownRecordSchema),
|
|
291
|
-
}).passthrough(),
|
|
292
|
-
generatedCardIds: z.array(z.string()),
|
|
293
|
-
removedCardIds: z.array(z.string()),
|
|
294
|
-
removedCardsWithImages: z.array(z.string()),
|
|
295
|
-
preservedImageCardIds: z.array(z.string()),
|
|
296
|
-
warnings: z.array(z.string()),
|
|
297
|
-
}).passthrough();
|
|
298
|
-
const workspacePermissionsSchema = z.object({
|
|
299
|
-
createEvents: z.boolean().optional(),
|
|
300
|
-
editSetup: z.boolean().optional(),
|
|
301
|
-
deleteEvents: z.boolean().optional(),
|
|
302
|
-
controlLive: z.boolean().optional(),
|
|
303
|
-
moderateChat: z.boolean().optional(),
|
|
304
|
-
startSession: z.boolean().optional(),
|
|
305
|
-
viewAnalytics: z.boolean().optional(),
|
|
306
|
-
downloadReports: z.boolean().optional(),
|
|
307
|
-
sendNotifications: z.boolean().optional(),
|
|
308
|
-
}).strict().default({});
|
|
309
|
-
const WORKSPACE_PERMISSION_KEYS = [
|
|
310
|
-
"createEvents",
|
|
311
|
-
"editSetup",
|
|
312
|
-
"deleteEvents",
|
|
313
|
-
"controlLive",
|
|
314
|
-
"moderateChat",
|
|
315
|
-
"startSession",
|
|
316
|
-
"viewAnalytics",
|
|
317
|
-
"downloadReports",
|
|
318
|
-
"sendNotifications",
|
|
319
|
-
];
|
|
320
|
-
const eventIdField = z.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
|
|
321
|
-
const limitField = (max = MAX_LIST_LIMIT, defaultValue = DEFAULT_LIST_LIMIT) => z.number().int().min(1).max(max).default(defaultValue).describe(`Maximum items to return, 1-${max}.`);
|
|
322
|
-
const cursorField = z.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
|
|
323
|
-
function toolDescription({ summary, args = [], returns, examples = [], notes = [], }) {
|
|
324
|
-
return [
|
|
325
|
-
summary,
|
|
326
|
-
args.length ? `Args:\n${args.map((item) => `- ${item}`).join("\n")}` : "",
|
|
327
|
-
`Returns:\n- ${returns}`,
|
|
328
|
-
examples.length ? `Examples:\n${examples.map((item) => `- ${item}`).join("\n")}` : "",
|
|
329
|
-
notes.length ? `Notes:\n${notes.map((item) => `- ${item}`).join("\n")}` : "",
|
|
330
|
-
].filter(Boolean).join("\n\n");
|
|
331
|
-
}
|
|
332
|
-
function cursorMeta(count, max, hasMore, nextCursor) {
|
|
333
|
-
return {
|
|
334
|
-
count,
|
|
335
|
-
limit: max,
|
|
336
|
-
hasMore,
|
|
337
|
-
nextCursor,
|
|
338
|
-
has_more: hasMore,
|
|
339
|
-
next_cursor: nextCursor,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
function pickPage(items, max) {
|
|
343
|
-
return {
|
|
344
|
-
page: items.slice(0, max),
|
|
345
|
-
hasMore: items.length > max,
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
function randomId() {
|
|
349
|
-
return randomUUID();
|
|
350
|
-
}
|
|
351
|
-
function imageStoragePath(eventId, uid, kind, imageId) {
|
|
352
|
-
return `events/${eventId}/${uid}/images/${kind}/${imageId}/master.webp`;
|
|
353
|
-
}
|
|
354
|
-
function storagePathFromDownloadUrl(downloadUrl) {
|
|
355
|
-
if (!downloadUrl)
|
|
356
|
-
return null;
|
|
357
|
-
const match = downloadUrl.match(/\/o\/([^?]+)/);
|
|
358
|
-
if (!match)
|
|
359
|
-
return null;
|
|
360
|
-
const encodedPath = match[1];
|
|
361
|
-
if (!encodedPath)
|
|
362
|
-
return null;
|
|
363
|
-
try {
|
|
364
|
-
return decodeURIComponent(encodedPath);
|
|
365
|
-
}
|
|
366
|
-
catch {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
function variantPathsFromMasterPath(masterPath) {
|
|
371
|
-
if (!masterPath.endsWith("/master.webp"))
|
|
372
|
-
return [];
|
|
373
|
-
return IMAGE_VARIANT_NAMES.map((name) => masterPath.replace(/master\.webp$/, name));
|
|
374
|
-
}
|
|
375
|
-
function resolveLocalImagePath(inputPath) {
|
|
376
|
-
const expanded = inputPath === "~" || inputPath.startsWith("~/")
|
|
377
|
-
? join(homedir(), inputPath.slice(2))
|
|
378
|
-
: inputPath;
|
|
379
|
-
return isAbsolute(expanded) ? resolve(expanded) : resolve(process.cwd(), expanded);
|
|
380
|
-
}
|
|
381
|
-
function isBlockedHostname(hostname) {
|
|
382
|
-
const host = hostname.toLowerCase();
|
|
383
|
-
return host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local");
|
|
384
|
-
}
|
|
385
|
-
async function resolveRemoteImageAddress(url) {
|
|
386
|
-
if (url.protocol !== "https:") {
|
|
387
|
-
throw new Error("Image URL must use https://.");
|
|
388
|
-
}
|
|
389
|
-
if (url.username || url.password) {
|
|
390
|
-
throw new Error("Image URL must not include embedded credentials.");
|
|
391
|
-
}
|
|
392
|
-
if (isBlockedHostname(url.hostname)) {
|
|
393
|
-
throw new Error("Image URL host is not allowed. Use a public HTTPS image URL.");
|
|
394
|
-
}
|
|
395
|
-
const literalAddress = normalizeIpLiteralHostname(url.hostname);
|
|
396
|
-
const literalFamily = ipLiteralFamily(literalAddress);
|
|
397
|
-
if (literalFamily !== 0) {
|
|
398
|
-
if (!isPublicUnicastAddress(literalAddress)) {
|
|
399
|
-
throw new Error("Image URL points to a private/local IP address, which is not allowed.");
|
|
400
|
-
}
|
|
401
|
-
return { address: literalAddress, family: literalFamily };
|
|
402
|
-
}
|
|
403
|
-
const addresses = await lookup(url.hostname, { all: true });
|
|
404
|
-
if (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address))) {
|
|
405
|
-
throw new Error("Image URL resolves to a non-public IP address, which is not allowed.");
|
|
406
|
-
}
|
|
407
|
-
const selected = addresses[0];
|
|
408
|
-
if (!selected || (selected.family !== 4 && selected.family !== 6)) {
|
|
409
|
-
throw new Error("Image URL host could not be resolved safely.");
|
|
410
|
-
}
|
|
411
|
-
return { address: selected.address, family: selected.family };
|
|
412
|
-
}
|
|
413
|
-
async function responseBufferWithLimit(response, maxBytes) {
|
|
414
|
-
const body = response.body;
|
|
415
|
-
if (!body) {
|
|
416
|
-
const bytes = Buffer.from(await response.arrayBuffer());
|
|
417
|
-
if (bytes.byteLength > maxBytes)
|
|
418
|
-
throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
|
|
419
|
-
return bytes;
|
|
420
|
-
}
|
|
421
|
-
const reader = body.getReader();
|
|
422
|
-
const chunks = [];
|
|
423
|
-
let total = 0;
|
|
424
|
-
try {
|
|
425
|
-
while (true) {
|
|
426
|
-
const { done, value } = await reader.read();
|
|
427
|
-
if (done)
|
|
428
|
-
break;
|
|
429
|
-
const chunk = Buffer.from(value);
|
|
430
|
-
total += chunk.byteLength;
|
|
431
|
-
if (total > maxBytes) {
|
|
432
|
-
throw new Error(`Image exceeds ${Math.floor(maxBytes / 1024 / 1024)}MB limit.`);
|
|
433
|
-
}
|
|
434
|
-
chunks.push(chunk);
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
finally {
|
|
438
|
-
reader.releaseLock();
|
|
439
|
-
}
|
|
440
|
-
return Buffer.concat(chunks, total);
|
|
441
|
-
}
|
|
442
|
-
async function readRemoteImage(rawUrl, redirects = 0) {
|
|
443
|
-
if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
|
|
444
|
-
throw new Error("Image URL has too many redirects.");
|
|
445
|
-
}
|
|
446
|
-
const url = new URL(rawUrl);
|
|
447
|
-
const pinned = await resolveRemoteImageAddress(url);
|
|
448
|
-
const dispatcher = new Agent({
|
|
449
|
-
connect: {
|
|
450
|
-
lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family),
|
|
451
|
-
},
|
|
452
|
-
});
|
|
453
|
-
const controller = new AbortController();
|
|
454
|
-
const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
|
|
455
|
-
try {
|
|
456
|
-
const response = await undiciFetch(url, {
|
|
457
|
-
redirect: "manual",
|
|
458
|
-
signal: controller.signal,
|
|
459
|
-
headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
|
|
460
|
-
dispatcher,
|
|
461
|
-
});
|
|
462
|
-
if (response.status >= 300 && response.status < 400) {
|
|
463
|
-
const location = response.headers.get("location");
|
|
464
|
-
if (!location)
|
|
465
|
-
throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
|
|
466
|
-
return readRemoteImage(new URL(location, url).toString(), redirects + 1);
|
|
467
|
-
}
|
|
468
|
-
if (!response.ok) {
|
|
469
|
-
throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
|
|
470
|
-
}
|
|
471
|
-
const contentType = (response.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase();
|
|
472
|
-
if (!contentType || !contentType.startsWith("image/")) {
|
|
473
|
-
throw new Error("Image URL must return Content-Type image/*.");
|
|
474
|
-
}
|
|
475
|
-
if (!ALLOWED_REMOTE_IMAGE_TYPES.has(contentType)) {
|
|
476
|
-
throw new Error(`Image URL Content-Type ${contentType} is not supported. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
|
|
477
|
-
}
|
|
478
|
-
const contentLength = Number(response.headers.get("content-length") ?? 0);
|
|
479
|
-
if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
|
|
480
|
-
throw new Error("Image URL is larger than 15MB.");
|
|
481
|
-
}
|
|
482
|
-
return responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES);
|
|
483
|
-
}
|
|
484
|
-
catch (err) {
|
|
485
|
-
if (err instanceof Error && err.name === "AbortError") {
|
|
486
|
-
throw new Error("Image URL fetch timed out.");
|
|
487
|
-
}
|
|
488
|
-
throw err;
|
|
489
|
-
}
|
|
490
|
-
finally {
|
|
491
|
-
clearTimeout(timeout);
|
|
492
|
-
await dispatcher.close();
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
async function readLocalImage(inputPath) {
|
|
496
|
-
if (env.imageRoots.length === 0) {
|
|
497
|
-
throw new Error("Local image reads are disabled. Set OKO_IMAGE_ROOTS to an explicit trusted directory and restart the MCP client.");
|
|
498
|
-
}
|
|
499
|
-
const filePath = await realpath(resolveLocalImagePath(inputPath));
|
|
500
|
-
const roots = await Promise.all(env.imageRoots.map(async (root) => {
|
|
501
|
-
try {
|
|
502
|
-
return await realpath(root);
|
|
503
|
-
}
|
|
504
|
-
catch {
|
|
505
|
-
return resolve(root);
|
|
506
|
-
}
|
|
507
|
-
}));
|
|
508
|
-
const allowed = roots.some((root) => {
|
|
509
|
-
const child = relative(root, filePath);
|
|
510
|
-
return child === "" || (!child.startsWith(`..${sep}`) && child !== ".." && !isAbsolute(child));
|
|
511
|
-
});
|
|
512
|
-
if (!allowed) {
|
|
513
|
-
throw new Error("Image path is outside OKO_IMAGE_ROOTS. Add a trusted root to the MCP client environment.");
|
|
514
|
-
}
|
|
515
|
-
const info = await stat(filePath);
|
|
516
|
-
if (!info.isFile())
|
|
517
|
-
throw new Error("Image path must point to a regular file.");
|
|
518
|
-
if (info.size > MAX_SOURCE_IMAGE_BYTES)
|
|
519
|
-
throw new Error("Image file is larger than 15MB.");
|
|
520
|
-
return readFile(filePath);
|
|
521
|
-
}
|
|
522
|
-
async function readImageSource(source) {
|
|
523
|
-
if (source.path)
|
|
524
|
-
return readLocalImage(source.path);
|
|
525
|
-
if (source.url)
|
|
526
|
-
return readRemoteImage(source.url);
|
|
527
|
-
throw new Error("Provide image source path or url.");
|
|
528
|
-
}
|
|
529
|
-
function readImageRef(eventData, kind, cardId) {
|
|
530
|
-
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
531
|
-
if (kind === "promoBanner") {
|
|
532
|
-
const banner = (blocks.promoBanner && typeof blocks.promoBanner === "object" ? blocks.promoBanner : {});
|
|
533
|
-
return {
|
|
534
|
-
imageId: storagePathFromDownloadUrl(safeString(banner.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
535
|
-
imageUrl: safeString(banner.imageUrl) || null,
|
|
536
|
-
imageMasterUrl: safeString(banner.imageMasterUrl) || null,
|
|
537
|
-
imageVariants: readImageVariants(banner.imageVariants),
|
|
538
|
-
};
|
|
539
|
-
}
|
|
540
|
-
if (kind === "videoPlaceholder") {
|
|
541
|
-
const video = (blocks.video && typeof blocks.video === "object" ? blocks.video : {});
|
|
542
|
-
const placeholder = (video.placeholder && typeof video.placeholder === "object" ? video.placeholder : {});
|
|
543
|
-
return {
|
|
544
|
-
imageId: storagePathFromDownloadUrl(safeString(placeholder.imageMasterUrl))?.split("/").at(-2) ?? null,
|
|
545
|
-
imageUrl: safeString(placeholder.imageUrl) || null,
|
|
546
|
-
imageMasterUrl: safeString(placeholder.imageMasterUrl) || null,
|
|
547
|
-
imageVariants: readImageVariants(placeholder.imageVariants),
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
if (!cardId)
|
|
551
|
-
throw new Error("cardId is required when kind is card.");
|
|
552
|
-
const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
553
|
-
const items = Array.isArray(cards.items) ? cards.items : [];
|
|
554
|
-
const item = items.find((raw) => Boolean(raw) && typeof raw === "object" && raw.id === cardId);
|
|
555
|
-
if (!item)
|
|
556
|
-
throw new Error(`Card ${cardId} not found in blocks.cards.items. Create/configure the card before uploading its image.`);
|
|
557
|
-
return {
|
|
558
|
-
imageId: cardId,
|
|
559
|
-
imageUrl: safeString(item.imageUrl) || null,
|
|
560
|
-
imageMasterUrl: safeString(item.imageMasterUrl) || null,
|
|
561
|
-
imageVariants: readImageVariants(item.imageVariants),
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
function cardItemsWithImage(eventData, cardId, patch) {
|
|
565
|
-
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
566
|
-
const cards = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
567
|
-
const items = Array.isArray(cards.items) ? cards.items : [];
|
|
568
|
-
let found = false;
|
|
569
|
-
const next = items.map((raw) => {
|
|
570
|
-
const item = (raw && typeof raw === "object" ? raw : {});
|
|
571
|
-
if (item.id !== cardId)
|
|
572
|
-
return item;
|
|
573
|
-
found = true;
|
|
574
|
-
return { ...item, ...patch };
|
|
575
|
-
});
|
|
576
|
-
if (!found)
|
|
577
|
-
throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
|
|
578
|
-
return next;
|
|
579
|
-
}
|
|
580
|
-
function readImageVariants(value) {
|
|
581
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
582
|
-
return null;
|
|
583
|
-
const data = value;
|
|
584
|
-
if (typeof data.thumbUrl !== "string" || typeof data.cardUrl !== "string" || typeof data.heroUrl !== "string") {
|
|
585
|
-
return null;
|
|
586
|
-
}
|
|
587
|
-
return {
|
|
588
|
-
thumbUrl: data.thumbUrl,
|
|
589
|
-
cardUrl: data.cardUrl,
|
|
590
|
-
heroUrl: data.heroUrl,
|
|
591
|
-
};
|
|
592
|
-
}
|
|
593
|
-
function readCardsConfig(eventData) {
|
|
594
|
-
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
595
|
-
const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
|
|
596
|
-
const rawItems = Array.isArray(raw.items) ? raw.items : [];
|
|
597
|
-
const items = rawItems
|
|
598
|
-
.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
|
|
599
|
-
.map((item) => {
|
|
600
|
-
const fallbackId = randomUUID();
|
|
601
|
-
return {
|
|
602
|
-
id: typeof item.id === "string" && CARD_ID_PATTERN.test(item.id) ? item.id : fallbackId,
|
|
603
|
-
title: safeString(item.title).trim() || null,
|
|
604
|
-
text: safeString(item.text).trim() || null,
|
|
605
|
-
buttonLabel: safeString(item.buttonLabel).trim() || null,
|
|
606
|
-
buttonUrl: safeString(item.buttonUrl).trim() || null,
|
|
607
|
-
imageUrl: safeString(item.imageUrl).trim() || null,
|
|
608
|
-
imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
|
|
609
|
-
imageVariants: readImageVariants(item.imageVariants),
|
|
610
|
-
imageWidth: typeof item.imageWidth === "number" ? item.imageWidth : null,
|
|
611
|
-
imageHeight: typeof item.imageHeight === "number" ? item.imageHeight : null,
|
|
612
|
-
formId: safeString(item.formId).trim() || null,
|
|
613
|
-
};
|
|
614
|
-
});
|
|
615
|
-
const ratio = safeString(raw.cardImageRatio);
|
|
616
|
-
return {
|
|
617
|
-
enabled: raw.enabled === true,
|
|
618
|
-
visibility: raw.visibility === "hidden" ? "hidden" : "always",
|
|
619
|
-
cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
|
|
620
|
-
items,
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
|
-
function normalizeNullableText(value) {
|
|
624
|
-
if (value == null)
|
|
625
|
-
return null;
|
|
626
|
-
return value.trim() || null;
|
|
627
|
-
}
|
|
628
|
-
function normalizeCardButtonUrl(value) {
|
|
629
|
-
const trimmed = normalizeNullableText(value);
|
|
630
|
-
if (!trimmed)
|
|
631
|
-
return null;
|
|
632
|
-
let url;
|
|
633
|
-
try {
|
|
634
|
-
url = new URL(trimmed);
|
|
635
|
-
}
|
|
636
|
-
catch {
|
|
637
|
-
throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
|
|
638
|
-
}
|
|
639
|
-
if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
|
|
640
|
-
throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
|
|
641
|
-
}
|
|
642
|
-
return trimmed;
|
|
643
|
-
}
|
|
644
|
-
function eventFormIds(eventData) {
|
|
645
|
-
const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
|
|
646
|
-
return new Set(rawForms
|
|
647
|
-
.map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "")
|
|
648
|
-
.filter(Boolean));
|
|
649
|
-
}
|
|
650
|
-
function cardHasImage(item) {
|
|
651
|
-
return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
|
|
652
|
-
}
|
|
653
|
-
function buildCardsItems({ inputs, existing, formIds, }) {
|
|
654
|
-
const existingById = new Map(existing.map((item) => [item.id, item]));
|
|
655
|
-
const seen = new Set();
|
|
656
|
-
const generatedCardIds = [];
|
|
657
|
-
const preservedImageCardIds = [];
|
|
658
|
-
const items = inputs.map((input) => {
|
|
659
|
-
const id = input.id ?? randomUUID();
|
|
660
|
-
if (seen.has(id))
|
|
661
|
-
throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
|
|
662
|
-
seen.add(id);
|
|
663
|
-
if (!CARD_ID_PATTERN.test(id))
|
|
664
|
-
throw new Error(`Invalid card id "${id}". Use 1-80 characters: letters, numbers, underscore, or hyphen.`);
|
|
665
|
-
if (!input.id)
|
|
666
|
-
generatedCardIds.push(id);
|
|
667
|
-
const formId = normalizeNullableText(input.formId);
|
|
668
|
-
if (formId && !formIds.has(formId)) {
|
|
669
|
-
throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
|
|
670
|
-
}
|
|
671
|
-
const previous = existingById.get(id);
|
|
672
|
-
if (previous && cardHasImage(previous))
|
|
673
|
-
preservedImageCardIds.push(id);
|
|
674
|
-
return {
|
|
675
|
-
id,
|
|
676
|
-
title: normalizeNullableText(input.title),
|
|
677
|
-
text: normalizeNullableText(input.text),
|
|
678
|
-
buttonLabel: normalizeNullableText(input.buttonLabel),
|
|
679
|
-
buttonUrl: normalizeCardButtonUrl(input.buttonUrl),
|
|
680
|
-
imageUrl: previous?.imageUrl ?? null,
|
|
681
|
-
imageMasterUrl: previous?.imageMasterUrl ?? null,
|
|
682
|
-
imageVariants: previous?.imageVariants ?? null,
|
|
683
|
-
imageWidth: previous?.imageWidth ?? null,
|
|
684
|
-
imageHeight: previous?.imageHeight ?? null,
|
|
685
|
-
formId,
|
|
686
|
-
};
|
|
687
|
-
});
|
|
688
|
-
return { items, generatedCardIds, preservedImageCardIds };
|
|
689
|
-
}
|
|
690
|
-
async function deleteStoragePaths(eventId, paths) {
|
|
691
|
-
const uniquePaths = [...new Set(paths.filter(Boolean))];
|
|
692
|
-
if (uniquePaths.length === 0)
|
|
693
|
-
return [];
|
|
694
|
-
const removeImages = httpsCallable(functions, "deleteMcpImages");
|
|
695
|
-
const result = await removeImages({ eventId, paths: uniquePaths });
|
|
696
|
-
return Array.isArray(result.data.warnings) ? result.data.warnings : [];
|
|
697
|
-
}
|
|
698
|
-
async function deleteStorageUrls(eventId, masterUrl, fallbackUrl) {
|
|
699
|
-
const paths = [];
|
|
700
|
-
const masterPath = storagePathFromDownloadUrl(masterUrl);
|
|
701
|
-
if (masterPath) {
|
|
702
|
-
paths.push(masterPath, ...variantPathsFromMasterPath(masterPath));
|
|
703
|
-
}
|
|
704
|
-
else {
|
|
705
|
-
const fallbackPath = storagePathFromDownloadUrl(fallbackUrl);
|
|
706
|
-
if (fallbackPath)
|
|
707
|
-
paths.push(fallbackPath);
|
|
708
|
-
}
|
|
709
|
-
const warnings = await deleteStoragePaths(eventId, paths);
|
|
710
|
-
return { attempted: [...new Set(paths)].length, warnings };
|
|
711
|
-
}
|
|
712
|
-
async function uploadMasterImage(eventId, kind, cardId, image) {
|
|
713
|
-
const uploadImage = httpsCallable(functions, "uploadMcpImage");
|
|
714
|
-
const result = await uploadImage({
|
|
715
|
-
eventId,
|
|
716
|
-
kind,
|
|
717
|
-
...(cardId ? { cardId } : {}),
|
|
718
|
-
imageBase64: image.toString("base64"),
|
|
719
|
-
});
|
|
720
|
-
return result.data;
|
|
721
|
-
}
|
|
722
|
-
async function updateCardImageInTransaction(eventId, cardId, patch) {
|
|
723
|
-
const eventRef = doc(firestore, "events", eventId);
|
|
724
|
-
await runFirestoreTransaction(firestore, async (transaction) => {
|
|
725
|
-
const snap = await transaction.get(eventRef);
|
|
726
|
-
if (!snap.exists())
|
|
727
|
-
throw new Error("Event not found.");
|
|
728
|
-
const data = snap.data();
|
|
729
|
-
transaction.update(eventRef, {
|
|
730
|
-
"blocks.cards.items": cardItemsWithImage(data, cardId, patch),
|
|
731
|
-
});
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
function imageVariantReason(ref) {
|
|
735
|
-
if (!ref.imageMasterUrl && !ref.imageUrl)
|
|
736
|
-
return "image_missing";
|
|
737
|
-
if (!ref.imageMasterUrl)
|
|
738
|
-
return "master_missing";
|
|
739
|
-
if (!ref.imageVariants)
|
|
740
|
-
return "variants_pending";
|
|
741
|
-
if (!ref.imageUrl)
|
|
742
|
-
return "image_url_pending";
|
|
743
|
-
return null;
|
|
744
|
-
}
|
|
745
|
-
function imageVariantStatus(eventId, eventData, kind, cardId, elapsedMs) {
|
|
746
|
-
const ref = readImageRef(eventData, kind, cardId ?? undefined);
|
|
747
|
-
const reason = imageVariantReason(ref);
|
|
748
|
-
return {
|
|
749
|
-
eventId,
|
|
750
|
-
kind,
|
|
751
|
-
cardId,
|
|
752
|
-
imageId: ref.imageId,
|
|
753
|
-
imageUrl: ref.imageUrl,
|
|
754
|
-
imageMasterUrl: ref.imageMasterUrl,
|
|
755
|
-
imageVariants: ref.imageVariants,
|
|
756
|
-
ready: reason === null,
|
|
757
|
-
reason,
|
|
758
|
-
elapsedMs,
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
function sleep(ms) {
|
|
762
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
763
|
-
}
|
|
764
|
-
async function waitForImageVariantTargets(uid, eventId, targets, timeoutSec) {
|
|
765
|
-
const startedAt = Date.now();
|
|
766
|
-
const deadline = startedAt + timeoutSec * 1000;
|
|
767
|
-
let latest = targets.map((target) => ({
|
|
768
|
-
eventId,
|
|
769
|
-
kind: target.kind,
|
|
770
|
-
cardId: target.cardId,
|
|
771
|
-
imageId: target.cardId,
|
|
772
|
-
imageUrl: null,
|
|
773
|
-
imageMasterUrl: null,
|
|
774
|
-
imageVariants: null,
|
|
775
|
-
ready: false,
|
|
776
|
-
reason: "not_checked",
|
|
777
|
-
elapsedMs: 0,
|
|
778
|
-
}));
|
|
779
|
-
while (true) {
|
|
780
|
-
const elapsedMs = Date.now() - startedAt;
|
|
781
|
-
const event = await readEvent(eventId, uid, "read");
|
|
782
|
-
latest = targets.map((target) => imageVariantStatus(eventId, event, target.kind, target.cardId, elapsedMs));
|
|
783
|
-
if (latest.every((item) => item.ready) || Date.now() >= deadline)
|
|
784
|
-
return latest;
|
|
785
|
-
await sleep(Math.min(2000, Math.max(250, deadline - Date.now())));
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
789
|
-
if (kind === "card" && !cardId)
|
|
790
|
-
throw new Error("cardId is required when kind is card.");
|
|
791
|
-
if (kind !== "card" && cardId)
|
|
792
|
-
throw new Error("cardId is only valid when kind is card.");
|
|
793
|
-
const event = await readEvent(eventId, uid, "edit");
|
|
794
|
-
const previous = readImageRef(event, kind, cardId);
|
|
795
|
-
const sourceBuffer = await readImageSource(source);
|
|
796
|
-
const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
|
|
797
|
-
const deleteWarnings = [];
|
|
798
|
-
let deletesAttempted = 0;
|
|
799
|
-
const expectedCardPath = kind === "card" ? imageStoragePath(eventId, uid, kind, cardId) : null;
|
|
800
|
-
if (expectedCardPath && previousMasterPath === expectedCardPath) {
|
|
801
|
-
const variantPaths = variantPathsFromMasterPath(expectedCardPath);
|
|
802
|
-
deletesAttempted += variantPaths.length;
|
|
803
|
-
deleteWarnings.push(...await deleteStoragePaths(eventId, variantPaths));
|
|
804
|
-
}
|
|
805
|
-
let uploaded = null;
|
|
806
|
-
try {
|
|
807
|
-
uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
|
|
808
|
-
}
|
|
809
|
-
catch (err) {
|
|
810
|
-
if (uploaded) {
|
|
811
|
-
await deleteStoragePaths(eventId, [uploaded.storagePath, ...variantPathsFromMasterPath(uploaded.storagePath)]).catch(() => []);
|
|
812
|
-
}
|
|
813
|
-
throw err;
|
|
814
|
-
}
|
|
815
|
-
if (!uploaded)
|
|
816
|
-
throw new Error("Image upload did not return a result.");
|
|
817
|
-
if (kind !== "card" || (previousMasterPath && previousMasterPath !== uploaded.storagePath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
818
|
-
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
819
|
-
deletesAttempted += cleanup.attempted;
|
|
820
|
-
deleteWarnings.push(...cleanup.warnings);
|
|
821
|
-
}
|
|
822
|
-
return {
|
|
823
|
-
eventId,
|
|
824
|
-
kind,
|
|
825
|
-
imageId: uploaded.imageId,
|
|
826
|
-
storagePath: uploaded.storagePath,
|
|
827
|
-
masterUrl: uploaded.masterUrl,
|
|
828
|
-
variantsPending: true,
|
|
829
|
-
sourceBytes: sourceBuffer.byteLength,
|
|
830
|
-
masterBytes: uploaded.masterBytes,
|
|
831
|
-
imageWidth: uploaded.imageWidth,
|
|
832
|
-
imageHeight: uploaded.imageHeight,
|
|
833
|
-
note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
|
|
834
|
-
storageDeletesAttempted: deletesAttempted,
|
|
835
|
-
storageDeleteWarnings: deleteWarnings,
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
async function authed(scopes) {
|
|
839
|
-
return ensureAuth(scopes);
|
|
840
|
-
}
|
|
841
|
-
function normalizeVideo(videoUrl) {
|
|
842
|
-
const raw = videoUrl?.trim();
|
|
843
|
-
if (!raw)
|
|
844
|
-
return { originalUrl: null, embedUrl: null };
|
|
845
|
-
const effective = extractIframeSrc(raw) ?? raw;
|
|
846
|
-
const detected = detectAndNormalize(effective);
|
|
847
|
-
if (detected.provider !== "unknown" && !detected.normalizedUrl) {
|
|
848
|
-
throw new Error(`Could not extract a valid ${detected.provider} video id from videoUrl.`);
|
|
849
|
-
}
|
|
850
|
-
return { originalUrl: raw, embedUrl: detected.normalizedUrl ?? effective };
|
|
851
|
-
}
|
|
852
|
-
function eventUrl(eventId) {
|
|
853
|
-
return `https://okostream.app/event/${eventId}`;
|
|
854
|
-
}
|
|
855
|
-
function setupUrl(eventId) {
|
|
856
|
-
return `https://okostream.app/setup/${eventId}`;
|
|
857
|
-
}
|
|
858
|
-
function analyticsUrl(eventId) {
|
|
859
|
-
return `https://okostream.app/analytics/${eventId}`;
|
|
860
|
-
}
|
|
861
|
-
function sessionReportUrl(eventId, sessionId) {
|
|
862
|
-
return `${analyticsUrl(eventId)}/report/${sessionId}`;
|
|
863
|
-
}
|
|
864
|
-
function eventLinks(eventId) {
|
|
865
|
-
return {
|
|
866
|
-
url: eventUrl(eventId),
|
|
867
|
-
setupUrl: setupUrl(eventId),
|
|
868
|
-
analyticsUrl: analyticsUrl(eventId),
|
|
869
|
-
};
|
|
870
|
-
}
|
|
871
|
-
const EVENT_ACCESS_FIELD_BY_MODE = {
|
|
872
|
-
read: "accessibleBy",
|
|
873
|
-
edit: "editors",
|
|
874
|
-
delete: "deleters",
|
|
875
|
-
live: "liveControllers",
|
|
876
|
-
session: "sessionManagers",
|
|
877
|
-
analytics: "analyticsViewers",
|
|
878
|
-
report: "reportDownloaders",
|
|
879
|
-
moderate: "chatModerators",
|
|
880
|
-
notify: "notificationSenders",
|
|
881
|
-
};
|
|
882
|
-
function grantAllowsMode(data, mode) {
|
|
883
|
-
const field = EVENT_ACCESS_FIELD_BY_MODE[mode];
|
|
884
|
-
return asRecord(data.capabilities)[field] === true;
|
|
885
|
-
}
|
|
886
|
-
function legacyEventAllowsMode(data, uid, mode) {
|
|
887
|
-
if (data.ownerId === uid)
|
|
888
|
-
return true;
|
|
889
|
-
const fields = mode === "read"
|
|
890
|
-
? ["accessibleBy", "editors"]
|
|
891
|
-
: [EVENT_ACCESS_FIELD_BY_MODE[mode]];
|
|
892
|
-
return fields.some((field) => (Array.isArray(data[field]) && data[field].includes(uid)));
|
|
893
|
-
}
|
|
894
|
-
function isFirestorePermissionDenied(error) {
|
|
895
|
-
if (!error || typeof error !== "object")
|
|
896
|
-
return false;
|
|
897
|
-
const code = error.code;
|
|
898
|
-
return code === "permission-denied"
|
|
899
|
-
|| code === "firestore/permission-denied"
|
|
900
|
-
|| code === "PERMISSION_DENIED";
|
|
901
|
-
}
|
|
902
|
-
async function readEvent(eventId, uid, mode = "read") {
|
|
903
|
-
const [snap, grant] = await Promise.all([
|
|
904
|
-
getDoc(doc(firestore, "events", eventId)),
|
|
905
|
-
getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error) => {
|
|
906
|
-
if (!isFirestorePermissionDenied(error))
|
|
907
|
-
throw error;
|
|
908
|
-
return null;
|
|
909
|
-
}),
|
|
910
|
-
]);
|
|
911
|
-
if (!snap.exists())
|
|
912
|
-
throw new Error("Event not found.");
|
|
913
|
-
const data = snap.data();
|
|
914
|
-
const allowed = grant === null
|
|
915
|
-
? legacyEventAllowsMode(data, uid, mode)
|
|
916
|
-
: data.ownerId === uid || (grant.exists() && grantAllowsMode(grant.data(), mode));
|
|
917
|
-
if (!allowed) {
|
|
918
|
-
throw new Error(`Event does not grant ${mode} access to this organizer.`);
|
|
919
|
-
}
|
|
920
|
-
if (data.trashedAt && mode !== "read" && mode !== "delete") {
|
|
921
|
-
throw new Error("Event is in trash. Restore it before making changes or live actions.");
|
|
922
|
-
}
|
|
923
|
-
return data;
|
|
924
|
-
}
|
|
925
|
-
function asRecord(value) {
|
|
926
|
-
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
927
|
-
}
|
|
928
|
-
function validateEventSetup(eventId, event) {
|
|
929
|
-
const errors = [];
|
|
930
|
-
const warnings = [];
|
|
931
|
-
const suggestions = [];
|
|
932
|
-
const add = (target, code, path, message) => {
|
|
933
|
-
target.push({ code, path, message });
|
|
934
|
-
};
|
|
935
|
-
const blocks = asRecord(event.blocks);
|
|
936
|
-
const forms = Array.isArray(event.forms) ? event.forms.filter((item) => item && typeof item === "object") : [];
|
|
937
|
-
const formIds = eventFormIds(event);
|
|
938
|
-
const title = safeString(event.title).trim();
|
|
939
|
-
if (!title)
|
|
940
|
-
add(errors, "title_missing", "title", "Event title is required.");
|
|
941
|
-
const video = asRecord(event.video);
|
|
942
|
-
if (!safeString(video.embedUrl).trim()) {
|
|
943
|
-
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.");
|
|
944
|
-
}
|
|
945
|
-
const meta = asRecord(event.meta);
|
|
946
|
-
const eventDateMs = toMillis(meta.eventDate);
|
|
947
|
-
if (eventDateMs == null) {
|
|
948
|
-
add(suggestions, "date_missing", "meta.eventDate", "Add the webinar date/time if it is known.");
|
|
949
|
-
}
|
|
950
|
-
else if (eventDateMs < Date.now() - 60 * 60 * 1000) {
|
|
951
|
-
add(warnings, "date_in_past", "meta.eventDate", "Configured webinar date appears to be in the past.");
|
|
952
|
-
}
|
|
953
|
-
const duplicateFormIds = new Set();
|
|
954
|
-
const seenFormIds = new Set();
|
|
955
|
-
forms.forEach((form, index) => {
|
|
956
|
-
const id = safeString(form.id).trim();
|
|
957
|
-
if (!id)
|
|
958
|
-
add(errors, "form_id_missing", `forms[${index}].id`, "Every form must have a stable id.");
|
|
959
|
-
else if (seenFormIds.has(id))
|
|
960
|
-
duplicateFormIds.add(id);
|
|
961
|
-
else
|
|
962
|
-
seenFormIds.add(id);
|
|
963
|
-
const fields = Array.isArray(form.fields) ? form.fields : [];
|
|
964
|
-
if (fields.length === 0)
|
|
965
|
-
add(warnings, "form_fields_missing", `forms[${index}].fields`, "Lead form has no fields.");
|
|
966
|
-
});
|
|
967
|
-
for (const id of duplicateFormIds)
|
|
968
|
-
add(errors, "form_id_duplicate", "forms", `Duplicate form id: ${id}.`);
|
|
969
|
-
const checkFormBinding = (path, formId) => {
|
|
970
|
-
const id = safeString(formId).trim();
|
|
971
|
-
if (id && !formIds.has(id))
|
|
972
|
-
add(errors, "form_binding_missing", path, `Referenced formId "${id}" does not exist in event.forms.`);
|
|
973
|
-
};
|
|
974
|
-
const promo = asRecord(blocks.promoBanner);
|
|
975
|
-
if (promo.enabled === true) {
|
|
976
|
-
checkFormBinding("blocks.promoBanner.formId", promo.formId);
|
|
977
|
-
if (!safeString(promo.link).trim() && !safeString(promo.formId).trim()) {
|
|
978
|
-
add(suggestions, "promo_target_missing", "blocks.promoBanner", "Enabled promo banner has no link or form binding.");
|
|
979
|
-
}
|
|
980
|
-
if (!safeString(promo.imageUrl).trim()) {
|
|
981
|
-
add(warnings, "promo_image_missing", "blocks.promoBanner.imageUrl", "Enabled promo banner has no image.");
|
|
982
|
-
}
|
|
983
|
-
else if (safeString(promo.imageMasterUrl).trim() && !readImageVariants(promo.imageVariants)) {
|
|
984
|
-
add(warnings, "promo_variants_pending", "blocks.promoBanner.imageVariants", "Promo banner image variants are not ready yet.");
|
|
985
|
-
}
|
|
986
|
-
}
|
|
987
|
-
const cta = asRecord(blocks.primaryCta);
|
|
988
|
-
if (cta.enabled === true) {
|
|
989
|
-
checkFormBinding("blocks.primaryCta.formId", cta.formId);
|
|
990
|
-
if (!safeString(cta.title).trim() && !safeString(cta.text).trim()) {
|
|
991
|
-
add(suggestions, "cta_copy_missing", "blocks.primaryCta", "Enabled CTA has no title or text.");
|
|
992
|
-
}
|
|
993
|
-
if (!safeString(cta.buttonLabel).trim()) {
|
|
994
|
-
add(warnings, "cta_button_missing", "blocks.primaryCta.buttonLabel", "Enabled CTA has no button label.");
|
|
995
|
-
}
|
|
996
|
-
if (safeString(cta.buttonLabel).trim() && !safeString(cta.buttonUrl).trim() && !safeString(cta.formId).trim()) {
|
|
997
|
-
add(warnings, "cta_target_missing", "blocks.primaryCta", "CTA button has no URL or form binding.");
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
const videoPlaceholder = asRecord(asRecord(blocks.video).placeholder);
|
|
1001
|
-
if (videoPlaceholder.enabled === true && videoPlaceholder.mode === "image") {
|
|
1002
|
-
if (!safeString(videoPlaceholder.imageUrl).trim()) {
|
|
1003
|
-
add(warnings, "placeholder_image_missing", "blocks.video.placeholder.imageUrl", "Image placeholder mode is enabled but no image is configured.");
|
|
1004
|
-
}
|
|
1005
|
-
else if (safeString(videoPlaceholder.imageMasterUrl).trim() && !readImageVariants(videoPlaceholder.imageVariants)) {
|
|
1006
|
-
add(warnings, "placeholder_variants_pending", "blocks.video.placeholder.imageVariants", "Video placeholder image variants are not ready yet.");
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1009
|
-
const cards = readCardsConfig(event);
|
|
1010
|
-
if (cards.enabled) {
|
|
1011
|
-
if (cards.items.length === 0)
|
|
1012
|
-
add(warnings, "cards_empty", "blocks.cards.items", "Cards block is enabled but has no cards.");
|
|
1013
|
-
const cardIds = new Set();
|
|
1014
|
-
cards.items.forEach((card, index) => {
|
|
1015
|
-
const path = `blocks.cards.items[${index}]`;
|
|
1016
|
-
if (cardIds.has(card.id))
|
|
1017
|
-
add(errors, "card_id_duplicate", `${path}.id`, `Duplicate card id: ${card.id}.`);
|
|
1018
|
-
cardIds.add(card.id);
|
|
1019
|
-
if (!card.title && !card.text)
|
|
1020
|
-
add(suggestions, "card_copy_missing", path, "Card has no title or text.");
|
|
1021
|
-
if (card.buttonLabel && !card.buttonUrl && !card.formId)
|
|
1022
|
-
add(warnings, "card_target_missing", path, "Card button has no URL or form binding.");
|
|
1023
|
-
if (card.formId && !formIds.has(card.formId))
|
|
1024
|
-
add(errors, "card_form_missing", `${path}.formId`, `Referenced formId "${card.formId}" does not exist.`);
|
|
1025
|
-
if (card.imageMasterUrl && !card.imageVariants)
|
|
1026
|
-
add(warnings, "card_variants_pending", `${path}.imageVariants`, "Card image variants are not ready yet.");
|
|
1027
|
-
});
|
|
1028
|
-
}
|
|
1029
|
-
const telegram = asRecord(event.telegram);
|
|
1030
|
-
const telegramChannels = Array.isArray(telegram.channelIds) ? telegram.channelIds : [];
|
|
1031
|
-
if (telegram.enabled === true && telegramChannels.length === 0 && typeof telegram.channelId !== "number") {
|
|
1032
|
-
add(warnings, "telegram_channel_missing", "telegram.channelIds", "Telegram is enabled but no channel is configured.");
|
|
1033
|
-
}
|
|
1034
|
-
const nextActions = [
|
|
1035
|
-
...errors.map((item) => `Fix error: ${item.message}`),
|
|
1036
|
-
...warnings.slice(0, 5).map((item) => `Review warning: ${item.message}`),
|
|
1037
|
-
];
|
|
1038
|
-
if (nextActions.length === 0)
|
|
1039
|
-
nextActions.push("No blocking setup issues found.");
|
|
1040
|
-
const penalty = errors.length * 25 + warnings.length * 8 + suggestions.length * 2;
|
|
1041
|
-
return {
|
|
1042
|
-
eventId,
|
|
1043
|
-
ok: errors.length === 0,
|
|
1044
|
-
score: Math.max(0, Math.min(100, 100 - penalty)),
|
|
1045
|
-
errors,
|
|
1046
|
-
warnings,
|
|
1047
|
-
suggestions,
|
|
1048
|
-
nextActions,
|
|
1049
|
-
links: eventLinks(eventId),
|
|
1050
|
-
};
|
|
1051
|
-
}
|
|
1052
|
-
function initialEventAccess(uid) {
|
|
1053
|
-
return {
|
|
1054
|
-
accessibleBy: [uid],
|
|
1055
|
-
editors: [uid],
|
|
1056
|
-
deleters: [uid],
|
|
1057
|
-
liveControllers: [uid],
|
|
1058
|
-
chatModerators: [uid],
|
|
1059
|
-
sessionManagers: [uid],
|
|
1060
|
-
analyticsViewers: [uid],
|
|
1061
|
-
reportDownloaders: [uid],
|
|
1062
|
-
notificationSenders: [uid],
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
async function addEventWithRtdbMirror(eventData) {
|
|
1066
|
-
const eventRef = await addDoc(collection(firestore, "events"), eventData);
|
|
1067
|
-
try {
|
|
1068
|
-
const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
|
|
1069
|
-
await ensureRuntime({ eventId: eventRef.id });
|
|
1070
|
-
}
|
|
1071
|
-
catch (error) {
|
|
1072
|
-
console.error(`[OKO MCP] RTDB mirror deferred to onEventCreated for event ${eventRef.id}:`, error);
|
|
1073
|
-
}
|
|
1074
|
-
return eventRef;
|
|
1075
|
-
}
|
|
1076
|
-
async function assertCanCreateInWorkspace(uid, workspaceId) {
|
|
1077
|
-
if (workspaceId === uid)
|
|
1078
|
-
return;
|
|
1079
|
-
const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
1080
|
-
if (!wsSnap.exists())
|
|
1081
|
-
throw new Error("Workspace not found or not readable.");
|
|
1082
|
-
const wsOwnerId = safeString(wsSnap.data().ownerId);
|
|
1083
|
-
if (wsOwnerId !== uid) {
|
|
1084
|
-
const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
|
|
1085
|
-
const perms = (shareSnap.data()?.permissions ?? {});
|
|
1086
|
-
if (!shareSnap.exists() || perms.createEvents !== true) {
|
|
1087
|
-
throw new Error("This organizer lacks createEvents permission in the workspace.");
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
async function readWorkspaceForOwner(workspaceId, uid) {
|
|
1092
|
-
const snap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
1093
|
-
if (!snap.exists())
|
|
1094
|
-
throw new Error("Workspace not found.");
|
|
1095
|
-
const data = snap.data();
|
|
1096
|
-
if (data.ownerId !== uid)
|
|
1097
|
-
throw new Error("Only the workspace owner can manage this workspace.");
|
|
1098
|
-
return data;
|
|
1099
|
-
}
|
|
1100
|
-
function normalizeWorkspacePermissions(value) {
|
|
1101
|
-
const out = {};
|
|
1102
|
-
for (const key of WORKSPACE_PERMISSION_KEYS) {
|
|
1103
|
-
if (value[key] === true)
|
|
1104
|
-
out[key] = true;
|
|
1105
|
-
else if (value[key] === false)
|
|
1106
|
-
out[key] = false;
|
|
1107
|
-
}
|
|
1108
|
-
return out;
|
|
1109
|
-
}
|
|
1110
|
-
function randomInviteCode() {
|
|
1111
|
-
return randomBytes(8).toString("hex").toUpperCase();
|
|
1112
|
-
}
|
|
1113
|
-
async function listVisibleWorkspaces(uid, includeShared) {
|
|
1114
|
-
const out = [];
|
|
1115
|
-
const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
1116
|
-
ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
|
|
1117
|
-
if (includeShared) {
|
|
1118
|
-
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
1119
|
-
const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
|
|
1120
|
-
for (const workspaceId of sharedIds) {
|
|
1121
|
-
if (out.some((row) => row.id === workspaceId))
|
|
1122
|
-
continue;
|
|
1123
|
-
const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
1124
|
-
const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
|
|
1125
|
-
if (wsSnap.exists())
|
|
1126
|
-
out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
return out;
|
|
1130
|
-
}
|
|
1131
|
-
async function getChatRole(eventId, uid) {
|
|
1132
|
-
const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
|
|
1133
|
-
return typeof snap.val() === "string" ? snap.val() : "viewer";
|
|
1134
|
-
}
|
|
1135
|
-
function csvRows(headers, rows) {
|
|
1136
|
-
return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
|
|
1137
|
-
}
|
|
1138
|
-
function toMillis(value) {
|
|
1139
|
-
if (typeof value === "number" && Number.isFinite(value))
|
|
1140
|
-
return value;
|
|
1141
|
-
if (value && typeof value === "object" && typeof value.toMillis === "function") {
|
|
1142
|
-
return value.toMillis();
|
|
1143
|
-
}
|
|
1144
|
-
return null;
|
|
1145
|
-
}
|
|
1146
|
-
function parseTimestampCursor(cursor) {
|
|
1147
|
-
const millis = Number(cursor);
|
|
1148
|
-
if (Number.isFinite(millis) && millis > 0)
|
|
1149
|
-
return Timestamp.fromMillis(millis);
|
|
1150
|
-
const parsed = Date.parse(cursor);
|
|
1151
|
-
if (Number.isFinite(parsed))
|
|
1152
|
-
return Timestamp.fromMillis(parsed);
|
|
1153
|
-
throw new Error("Invalid cursor. Use the nextCursor value returned by the previous page.");
|
|
1154
|
-
}
|
|
1155
|
-
function timestampCursorFrom(value) {
|
|
1156
|
-
const millis = toMillis(value);
|
|
1157
|
-
return millis == null ? null : String(millis);
|
|
1158
|
-
}
|
|
1159
|
-
function parseIsoBound(value, label) {
|
|
1160
|
-
if (value === undefined)
|
|
1161
|
-
return null;
|
|
1162
|
-
const parsed = Date.parse(value);
|
|
1163
|
-
if (!Number.isFinite(parsed)) {
|
|
1164
|
-
throw new Error(`Invalid ${label}: expected an ISO timestamp such as 2026-08-28T00:00:00+03:00.`);
|
|
1165
|
-
}
|
|
1166
|
-
return Timestamp.fromMillis(parsed);
|
|
1167
|
-
}
|
|
1168
|
-
// Компактная карточка события из полного (сериализованного) документа —
|
|
1169
|
-
// той же формы, что grant-проекция в eventAccess.
|
|
1170
|
-
function compactEventFromFullDoc(event) {
|
|
1171
|
-
const meta = (event.meta && typeof event.meta === "object" ? event.meta : {});
|
|
1172
|
-
const telegram = (event.telegram && typeof event.telegram === "object" ? event.telegram : {});
|
|
1173
|
-
return {
|
|
1174
|
-
id: event.id,
|
|
1175
|
-
ownerId: event.ownerId ?? null,
|
|
1176
|
-
workspaceId: event.workspaceId ?? null,
|
|
1177
|
-
title: event.title ?? null,
|
|
1178
|
-
createdAt: event.createdAt ?? null,
|
|
1179
|
-
eventDate: meta.eventDate ?? null,
|
|
1180
|
-
speakerName: meta.speakerName ?? null,
|
|
1181
|
-
pinned: event.pinned === true,
|
|
1182
|
-
telegramEnabled: telegram.enabled === true,
|
|
1183
|
-
trashed: Boolean(event.trashedAt),
|
|
1184
|
-
trashedAt: event.trashedAt ?? null,
|
|
1185
|
-
activeAnalyticsSessionId: event.activeAnalyticsSessionId ?? null,
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
function compactAnalyticsSession(raw, options) {
|
|
1189
|
-
const session = serialize(raw);
|
|
1190
|
-
const timeline = Array.isArray(session.timeline) ? session.timeline : null;
|
|
1191
|
-
if (timeline) {
|
|
1192
|
-
session.timelineBucketCount = timeline.length;
|
|
1193
|
-
if (options.includeTimeline) {
|
|
1194
|
-
const page = timeline.slice(-options.timelineLimit);
|
|
1195
|
-
session.timeline = page;
|
|
1196
|
-
session.timelineTruncated = timeline.length > page.length;
|
|
1197
|
-
session.timelineReturned = page.length;
|
|
1198
|
-
}
|
|
1199
|
-
else {
|
|
1200
|
-
delete session.timeline;
|
|
1201
|
-
session.timelineOmitted = true;
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
if (!options.includeSnapshots) {
|
|
1205
|
-
if (session.baselineSnapshot !== undefined) {
|
|
1206
|
-
delete session.baselineSnapshot;
|
|
1207
|
-
session.baselineSnapshotOmitted = true;
|
|
1208
|
-
}
|
|
1209
|
-
if (session.finalSnapshot !== undefined) {
|
|
1210
|
-
delete session.finalSnapshot;
|
|
1211
|
-
session.finalSnapshotOmitted = true;
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
delete session[["n", "moEvaluation"].join("")];
|
|
1215
|
-
return session;
|
|
1216
|
-
}
|
|
1217
|
-
function numberMetric(value) {
|
|
1218
|
-
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1219
|
-
}
|
|
1220
|
-
function formatSeconds(value) {
|
|
1221
|
-
const seconds = numberMetric(value);
|
|
1222
|
-
if (seconds == null)
|
|
1223
|
-
return "n/a";
|
|
1224
|
-
const total = Math.max(0, Math.round(seconds));
|
|
1225
|
-
const h = Math.floor(total / 3600);
|
|
1226
|
-
const m = Math.floor((total % 3600) / 60);
|
|
1227
|
-
const s = total % 60;
|
|
1228
|
-
if (h > 0)
|
|
1229
|
-
return `${h}h ${String(m).padStart(2, "0")}m ${String(s).padStart(2, "0")}s`;
|
|
1230
|
-
return `${m}m ${String(s).padStart(2, "0")}s`;
|
|
1231
|
-
}
|
|
1232
|
-
function formatDateTime(value) {
|
|
1233
|
-
const millis = toMillis(value);
|
|
1234
|
-
if (millis == null)
|
|
1235
|
-
return "n/a";
|
|
1236
|
-
return new Date(millis).toISOString();
|
|
1237
|
-
}
|
|
1238
|
-
function markdownMetric(label, value, suffix = "") {
|
|
1239
|
-
const metric = value === null || value === undefined || value === "" ? "n/a" : String(value);
|
|
1240
|
-
return `- ${label}: ${metric}${suffix}`;
|
|
1241
|
-
}
|
|
1242
|
-
function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
|
|
1243
|
-
const lines = [
|
|
1244
|
-
`# OKO session report`,
|
|
1245
|
-
"",
|
|
1246
|
-
`Event: ${safeString(event.title, eventId)}`,
|
|
1247
|
-
`Event URL: ${eventUrl(eventId)}`,
|
|
1248
|
-
`Report URL: ${sessionReportUrl(eventId, sessionId)}`,
|
|
1249
|
-
`Session: ${sessionId}`,
|
|
1250
|
-
"",
|
|
1251
|
-
"## Timing",
|
|
1252
|
-
markdownMetric("Started", formatDateTime(session.startedAt)),
|
|
1253
|
-
markdownMetric("Ended", formatDateTime(session.endedAt)),
|
|
1254
|
-
markdownMetric("Duration", formatSeconds(session.durationSec)),
|
|
1255
|
-
"",
|
|
1256
|
-
"## Audience",
|
|
1257
|
-
markdownMetric("Peak viewers", session.peakViewers),
|
|
1258
|
-
markdownMetric("Unique visitors", session.uniqueVisitors),
|
|
1259
|
-
markdownMetric("Views", session.viewCount),
|
|
1260
|
-
markdownMetric("Average watch time", formatSeconds(session.avgTimeSec)),
|
|
1261
|
-
markdownMetric("Median watch time", formatSeconds(session.medianTimeSec)),
|
|
1262
|
-
"",
|
|
1263
|
-
"## Engagement",
|
|
1264
|
-
markdownMetric("Chat messages", session.chatMessages),
|
|
1265
|
-
markdownMetric("Banner impressions", session.bannerImpressions),
|
|
1266
|
-
markdownMetric("Banner clicks", session.bannerClicks),
|
|
1267
|
-
markdownMetric("Banner CTR", session.bannerCtr, session.bannerCtr == null ? "" : "%"),
|
|
1268
|
-
markdownMetric("CTA clicks", session.ctaClicks),
|
|
1269
|
-
markdownMetric("Cards clicks", session.cardsClicks),
|
|
1270
|
-
];
|
|
1271
|
-
return lines.join("\n");
|
|
1272
|
-
}
|
|
1273
|
-
const MCP_AUDIT_TARGET_KEYS = new Set([
|
|
1274
|
-
"eventId", "workspaceId", "targetWorkspaceId", "sessionId", "postId",
|
|
1275
|
-
"messageId", "leadId", "participantUid", "uid", "channelId", "cardId", "imageId",
|
|
1276
|
-
]);
|
|
1277
|
-
function auditTargets(args) {
|
|
1278
|
-
if (!args || typeof args !== "object" || Array.isArray(args))
|
|
1279
|
-
return {};
|
|
1280
|
-
const targets = {};
|
|
1281
|
-
for (const [key, value] of Object.entries(args)) {
|
|
1282
|
-
if (!MCP_AUDIT_TARGET_KEYS.has(key))
|
|
1283
|
-
continue;
|
|
1284
|
-
if (typeof value === "string")
|
|
1285
|
-
targets[key] = value.slice(0, 160);
|
|
1286
|
-
else if (typeof value === "number" || typeof value === "boolean" || value === null)
|
|
1287
|
-
targets[key] = value;
|
|
1288
|
-
}
|
|
1289
|
-
return targets;
|
|
1290
|
-
}
|
|
1291
|
-
async function recordToolAudit(toolName, outcome, args, startedAt, error) {
|
|
1292
|
-
try {
|
|
1293
|
-
const audit = httpsCallable(functions, "recordMcpToolAudit");
|
|
1294
|
-
const errorCode = error && typeof error === "object" && "code" in error
|
|
1295
|
-
? String(error.code ?? "error")
|
|
1296
|
-
: error instanceof Error ? error.name : null;
|
|
1297
|
-
await audit({
|
|
1298
|
-
toolName,
|
|
1299
|
-
outcome,
|
|
1300
|
-
targets: auditTargets(args),
|
|
1301
|
-
durationMs: Date.now() - startedAt,
|
|
1302
|
-
errorCode,
|
|
1303
|
-
});
|
|
1304
|
-
}
|
|
1305
|
-
catch (auditError) {
|
|
1306
|
-
console.error(`[oko-mcp] audit failed for ${toolName}:`, auditError instanceof Error ? auditError.message : String(auditError));
|
|
1307
|
-
}
|
|
1308
|
-
}
|
|
1
|
+
// Точка входа инструментов OKO MCP.
|
|
2
|
+
//
|
|
3
|
+
// Здесь нет ни одного обработчика — только порядок сборки: обёртка аудита,
|
|
4
|
+
// затем одиннадцать доменов. Порядок вызовов задаёт порядок в tools/list,
|
|
5
|
+
// поэтому он сохранён с дораборного файла.
|
|
6
|
+
//
|
|
7
|
+
// Инвентарь (63 инструмента) и области доступа описаны в public-contract.json;
|
|
8
|
+
// его сверяет protocol.test, поднимая настоящий сервер.
|
|
9
|
+
import { registerAccountTools } from "./tools/account.js";
|
|
10
|
+
import { registerChatTools } from "./tools/chat.js";
|
|
11
|
+
import { registerEventTools } from "./tools/events.js";
|
|
12
|
+
import { registerImageTools } from "./tools/images.js";
|
|
13
|
+
import { installToolInstrumentation } from "./tools/instrumentation.js";
|
|
14
|
+
import { registerLeadTools } from "./tools/leads.js";
|
|
15
|
+
import { registerLifecycleTools } from "./tools/lifecycle.js";
|
|
16
|
+
import { registerLiveTools } from "./tools/live.js";
|
|
17
|
+
import { registerNotificationTools } from "./tools/notifications.js";
|
|
18
|
+
import { registerSessionTools } from "./tools/sessions.js";
|
|
19
|
+
import { registerTelegramTools } from "./tools/telegram.js";
|
|
20
|
+
import { registerWorkspaceTools } from "./tools/workspaces.js";
|
|
1309
21
|
export function registerTools(server) {
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
if (error instanceof OkoAuthError)
|
|
1327
|
-
return authErrorResult(error);
|
|
1328
|
-
if (!readOnly)
|
|
1329
|
-
await recordToolAudit(name, "error", handlerArgs[0], startedAt, error);
|
|
1330
|
-
throw error;
|
|
1331
|
-
}
|
|
1332
|
-
};
|
|
1333
|
-
return rawRegisterTool(name, config, wrapped);
|
|
1334
|
-
};
|
|
1335
|
-
server.registerTool("oko_connection_status", {
|
|
1336
|
-
title: "Check OKO connection",
|
|
1337
|
-
description: toolDescription({
|
|
1338
|
-
summary: "Check whether this MCP process is connected to an active OKO organizer account.",
|
|
1339
|
-
returns: "A factual connection status, product boundary, and setup links. This tool works without OKO_API_KEY and never changes data.",
|
|
1340
|
-
examples: [
|
|
1341
|
-
"Use when an OKO request is made and the organizer connection state is unknown.",
|
|
1342
|
-
"Use after installing the package without a key to explain the exact next setup step.",
|
|
1343
|
-
],
|
|
1344
|
-
notes: ["This tool does not create accounts, invitations, or API keys."],
|
|
1345
|
-
}),
|
|
1346
|
-
outputSchema: connectionStatusOutputSchema,
|
|
1347
|
-
annotations: readOnlyTool,
|
|
1348
|
-
}, async () => textResult(await getConnectionStatus()));
|
|
1349
|
-
server.registerTool("oko_whoami", {
|
|
1350
|
-
title: "Who am I",
|
|
1351
|
-
description: toolDescription({
|
|
1352
|
-
summary: "Return the authenticated OKO organizer uid and granted MCP scopes.",
|
|
1353
|
-
returns: "uid plus the exact scopes granted by OKO API key exchange. Use this before write workflows to check permissions.",
|
|
1354
|
-
examples: ['Use when starting a session: call first, then choose tools allowed by "scopes".'],
|
|
1355
|
-
}),
|
|
1356
|
-
outputSchema: {
|
|
1357
|
-
uid: z.string(),
|
|
1358
|
-
scopes: z.array(z.string()),
|
|
1359
|
-
},
|
|
1360
|
-
annotations: readOnlyTool,
|
|
1361
|
-
}, async () => {
|
|
1362
|
-
const auth = await authed([]);
|
|
1363
|
-
return textResult({ uid: auth.uid, scopes: currentScopes() });
|
|
1364
|
-
});
|
|
1365
|
-
server.registerTool("oko_update_profile", {
|
|
1366
|
-
title: "Update profile",
|
|
1367
|
-
description: toolDescription({
|
|
1368
|
-
summary: "Update the authenticated organizer profile fields used by OKO UI.",
|
|
1369
|
-
args: [
|
|
1370
|
-
"displayName: optional display name, max 40 chars; null clears it.",
|
|
1371
|
-
"timezone: optional IANA timezone such as Europe/Moscow or UTC.",
|
|
1372
|
-
],
|
|
1373
|
-
returns: "uid, updated field names, and the resulting profile snapshot.",
|
|
1374
|
-
examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
|
|
1375
|
-
notes: ["Edits the organizer profile that owns this key. Requires workspace:write: the key holder is not necessarily the organizer, so a read-only key must not rewrite the owner's name, avatar or timezone."],
|
|
1376
|
-
}),
|
|
1377
|
-
inputSchema: {
|
|
1378
|
-
displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
|
|
1379
|
-
timezone: timezoneSchema.optional().describe("IANA timezone, for example Europe/Moscow or UTC."),
|
|
1380
|
-
},
|
|
1381
|
-
outputSchema: profileOutputSchema,
|
|
1382
|
-
annotations: idempotentWriteTool,
|
|
1383
|
-
}, async ({ displayName, timezone }) => {
|
|
1384
|
-
const { uid } = await authed(["workspace:write"]);
|
|
1385
|
-
const patch = { updatedAt: serverTimestamp() };
|
|
1386
|
-
if (displayName !== undefined)
|
|
1387
|
-
patch.displayName = displayName?.trim() || null;
|
|
1388
|
-
if (timezone !== undefined)
|
|
1389
|
-
patch.timezone = timezone.trim();
|
|
1390
|
-
const updated = Object.keys(patch).filter((key) => key !== "updatedAt");
|
|
1391
|
-
if (updated.length === 0)
|
|
1392
|
-
throw new Error("Provide displayName and/or timezone.");
|
|
1393
|
-
const profileRef = doc(firestore, "userProfiles", uid);
|
|
1394
|
-
await setDoc(profileRef, patch, { merge: true });
|
|
1395
|
-
const profileSnap = await getDoc(profileRef);
|
|
1396
|
-
return textResult({ uid, updated, profile: profileSnap.exists() ? serialize(profileSnap.data()) : {} });
|
|
1397
|
-
});
|
|
1398
|
-
server.registerTool("oko_list_workspaces", {
|
|
1399
|
-
title: "List workspaces",
|
|
1400
|
-
description: toolDescription({
|
|
1401
|
-
summary: "List OKO workspaces visible to the authenticated organizer.",
|
|
1402
|
-
args: ["includeShared: include workspaces shared with this organizer, default true."],
|
|
1403
|
-
returns: "workspaces array with id, ownership flag, and permissions for shared workspaces, plus count.",
|
|
1404
|
-
examples: ["Use before oko_create_webinar when the user wants to create a webinar in a shared workspace."],
|
|
1405
|
-
}),
|
|
1406
|
-
inputSchema: { includeShared: z.boolean().default(true).describe("Include workspaces shared with this organizer.") },
|
|
1407
|
-
outputSchema: workspacesOutputSchema,
|
|
1408
|
-
annotations: readOnlyTool,
|
|
1409
|
-
}, async ({ includeShared }) => {
|
|
1410
|
-
const { uid } = await authed(["workspace:read"]);
|
|
1411
|
-
const out = await listVisibleWorkspaces(uid, includeShared);
|
|
1412
|
-
return textResult({ workspaces: out, count: out.length });
|
|
1413
|
-
});
|
|
1414
|
-
server.registerTool("oko_create_workspace", {
|
|
1415
|
-
title: "Create workspace",
|
|
1416
|
-
description: toolDescription({
|
|
1417
|
-
summary: "Create a shared OKO workspace owned by the authenticated organizer.",
|
|
1418
|
-
args: ["name: workspace display name."],
|
|
1419
|
-
returns: "workspaceId, name, and created=true.",
|
|
1420
|
-
examples: ["Use when the organizer wants to create a new team/workspace for events."],
|
|
1421
|
-
}),
|
|
1422
|
-
inputSchema: { name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("Workspace name.") },
|
|
1423
|
-
outputSchema: z.object({ workspaceId: z.string(), created: z.boolean() }).passthrough(),
|
|
1424
|
-
annotations: writeTool,
|
|
1425
|
-
}, async ({ name }) => {
|
|
1426
|
-
const { uid } = await authed(["workspace:write"]);
|
|
1427
|
-
const workspaceRef = await addDoc(collection(firestore, "workspaces"), {
|
|
1428
|
-
name: name.trim(),
|
|
1429
|
-
ownerId: uid,
|
|
1430
|
-
personal: false,
|
|
1431
|
-
createdAt: serverTimestamp(),
|
|
1432
|
-
});
|
|
1433
|
-
return textResult({ workspaceId: workspaceRef.id, name: name.trim(), created: true });
|
|
1434
|
-
});
|
|
1435
|
-
server.registerTool("oko_update_workspace", {
|
|
1436
|
-
title: "Update workspace",
|
|
1437
|
-
description: toolDescription({
|
|
1438
|
-
summary: "Rename a workspace owned by the authenticated organizer.",
|
|
1439
|
-
args: ["workspaceId: workspace to update.", "name: new workspace display name."],
|
|
1440
|
-
returns: "workspaceId and updated fields.",
|
|
1441
|
-
examples: ["Use for workspace rename requests."],
|
|
1442
|
-
}),
|
|
1443
|
-
inputSchema: { workspaceId: z.string().min(1), name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("New workspace name.") },
|
|
1444
|
-
outputSchema: updateOutputSchema,
|
|
1445
|
-
annotations: idempotentWriteTool,
|
|
1446
|
-
}, async ({ workspaceId, name }) => {
|
|
1447
|
-
const { uid } = await authed(["workspace:write"]);
|
|
1448
|
-
await readWorkspaceForOwner(workspaceId, uid);
|
|
1449
|
-
await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name.trim() });
|
|
1450
|
-
return textResult({ workspaceId, updated: ["name"] });
|
|
1451
|
-
});
|
|
1452
|
-
server.registerTool("oko_delete_workspace", {
|
|
1453
|
-
title: "Delete workspace",
|
|
1454
|
-
description: toolDescription({
|
|
1455
|
-
summary: "Delete an empty non-personal workspace owned by the authenticated organizer.",
|
|
1456
|
-
args: ["workspaceId: workspace to delete.", "confirm: must be true."],
|
|
1457
|
-
returns: "workspaceId and deleted=true.",
|
|
1458
|
-
examples: ["Use after listing workspace access and confirming the workspace is empty."],
|
|
1459
|
-
notes: ["Personal workspaces cannot be deleted. This tool refuses to delete workspaces that still contain events; move/delete events first."],
|
|
1460
|
-
}),
|
|
1461
|
-
inputSchema: { workspaceId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for workspace deletion.") },
|
|
1462
|
-
outputSchema: z.object({ workspaceId: z.string(), deleted: z.boolean() }).passthrough(),
|
|
1463
|
-
annotations: destructiveTool,
|
|
1464
|
-
}, async ({ workspaceId, confirm }) => {
|
|
1465
|
-
const { uid } = await authed(["workspace:write"]);
|
|
1466
|
-
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1467
|
-
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
1468
|
-
if (workspace.personal === true)
|
|
1469
|
-
throw new Error("Personal workspace cannot be deleted.");
|
|
1470
|
-
const eventsSnap = await getDocs(query(collection(firestore, "events"), where("workspaceId", "==", workspaceId), limit(1)));
|
|
1471
|
-
if (!eventsSnap.empty)
|
|
1472
|
-
throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
|
|
1473
|
-
await deleteDoc(doc(firestore, "workspaces", workspaceId));
|
|
1474
|
-
return textResult({ workspaceId, deleted: true });
|
|
1475
|
-
});
|
|
1476
|
-
server.registerTool("oko_list_workspace_access", {
|
|
1477
|
-
title: "List workspace access",
|
|
1478
|
-
description: toolDescription({
|
|
1479
|
-
summary: "List workspace members/shares and pending invites.",
|
|
1480
|
-
args: ["workspaceId: workspace owned by the authenticated organizer."],
|
|
1481
|
-
returns: "workspace document, members array, invites array.",
|
|
1482
|
-
examples: ["Use before changing permissions, removing a member, or revoking an invite."],
|
|
1483
|
-
}),
|
|
1484
|
-
inputSchema: { workspaceId: z.string().min(1) },
|
|
1485
|
-
outputSchema: workspaceAccessOutputSchema,
|
|
1486
|
-
annotations: readOnlyTool,
|
|
1487
|
-
}, async ({ workspaceId }) => {
|
|
1488
|
-
const { uid } = await authed(["workspace:read"]);
|
|
1489
|
-
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1490
|
-
const [sharesSnap, invitesSnap] = await Promise.all([
|
|
1491
|
-
getDocs(collection(firestore, "workspaces", workspaceId, "shares")),
|
|
1492
|
-
getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId))),
|
|
1493
|
-
]);
|
|
1494
|
-
const members = [];
|
|
1495
|
-
for (const share of sharesSnap.docs) {
|
|
1496
|
-
// Правила разрешают читать чужой профиль только владельцу платформы,
|
|
1497
|
-
// поэтому на любом рабочем пространстве с участниками это чтение
|
|
1498
|
-
// заканчивается отказом. Без обработки инструмент падал целиком вместо
|
|
1499
|
-
// того, чтобы вернуть состав команды без необязательных профилей.
|
|
1500
|
-
const profile = await getDoc(doc(firestore, "userProfiles", share.id))
|
|
1501
|
-
.then((snap) => (snap.exists() ? serialize(snap.data()) : null))
|
|
1502
|
-
.catch(() => null);
|
|
1503
|
-
members.push({
|
|
1504
|
-
uid: share.id,
|
|
1505
|
-
...serialize(share.data()),
|
|
1506
|
-
profile,
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
|
|
1510
|
-
return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites });
|
|
1511
|
-
});
|
|
1512
|
-
server.registerTool("oko_update_workspace_member", {
|
|
1513
|
-
title: "Update workspace member",
|
|
1514
|
-
description: toolDescription({
|
|
1515
|
-
summary: "Create or update a workspace share/member permissions document.",
|
|
1516
|
-
args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
|
|
1517
|
-
returns: "workspaceId, uid, and updated=true.",
|
|
1518
|
-
examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
|
|
1519
|
-
notes: ["Changing a share materializes lightweight event grants and updates RTDB workspace/chat mirrors."],
|
|
1520
|
-
}),
|
|
1521
|
-
inputSchema: {
|
|
1522
|
-
workspaceId: z.string().min(1),
|
|
1523
|
-
uid: z.string().min(1).describe("Member Firebase uid."),
|
|
1524
|
-
email: z.string().email().optional().describe("Optional member email cache."),
|
|
1525
|
-
comment: z.string().max(500).default(""),
|
|
1526
|
-
permissions: workspacePermissionsSchema,
|
|
1527
|
-
confirm: z.boolean().optional().describe("Required true for share updates."),
|
|
1528
|
-
},
|
|
1529
|
-
outputSchema: updateOutputSchema,
|
|
1530
|
-
annotations: writeTool,
|
|
1531
|
-
}, async (args) => {
|
|
1532
|
-
const { uid } = await authed(["workspace:write"]);
|
|
1533
|
-
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
1534
|
-
requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
|
|
1535
|
-
await setDoc(doc(firestore, "workspaces", args.workspaceId, "shares", args.uid), {
|
|
1536
|
-
email: args.email ?? "",
|
|
1537
|
-
comment: args.comment.trim(),
|
|
1538
|
-
permissions: normalizeWorkspacePermissions(args.permissions),
|
|
1539
|
-
sharedAt: serverTimestamp(),
|
|
1540
|
-
sharedBy: uid,
|
|
1541
|
-
}, { merge: true });
|
|
1542
|
-
return textResult({ workspaceId: args.workspaceId, uid: args.uid, updated: true });
|
|
1543
|
-
});
|
|
1544
|
-
server.registerTool("oko_remove_workspace_member", {
|
|
1545
|
-
title: "Remove workspace member",
|
|
1546
|
-
description: toolDescription({
|
|
1547
|
-
summary: "Remove a member/share from a workspace.",
|
|
1548
|
-
args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
|
|
1549
|
-
returns: "workspaceId, uid, removed=true.",
|
|
1550
|
-
examples: ["Use after listing workspace access and confirming the exact uid."],
|
|
1551
|
-
notes: ["Removing a share revokes its event grants and RTDB workspace/chat mirrors."],
|
|
1552
|
-
}),
|
|
1553
|
-
inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
|
|
1554
|
-
outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
|
|
1555
|
-
annotations: destructiveTool,
|
|
1556
|
-
}, async ({ workspaceId, uid: targetUid, confirm }) => {
|
|
1557
|
-
const { uid } = await authed(["workspace:write"]);
|
|
1558
|
-
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1559
|
-
requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
|
|
1560
|
-
await deleteDoc(doc(firestore, "workspaces", workspaceId, "shares", targetUid));
|
|
1561
|
-
return textResult({ workspaceId, uid: targetUid, removed: true });
|
|
1562
|
-
});
|
|
1563
|
-
server.registerTool("oko_list_workspace_invites", {
|
|
1564
|
-
title: "List workspace invites",
|
|
1565
|
-
description: toolDescription({
|
|
1566
|
-
summary: "List workspace invite links for a workspace owned by the organizer.",
|
|
1567
|
-
args: ["workspaceId: workspace owned by the organizer."],
|
|
1568
|
-
returns: "invites array with code, claim status, permissions, and inviteUrl.",
|
|
1569
|
-
examples: ["Use before revoking or sharing an invite code."],
|
|
1570
|
-
}),
|
|
1571
|
-
inputSchema: { workspaceId: z.string().min(1) },
|
|
1572
|
-
outputSchema: z.object({ workspaceId: z.string(), invites: z.array(unknownRecordSchema), count: z.number().int().nonnegative() }).passthrough(),
|
|
1573
|
-
annotations: readOnlyTool,
|
|
1574
|
-
}, async ({ workspaceId }) => {
|
|
1575
|
-
const { uid } = await authed(["workspace:read"]);
|
|
1576
|
-
await readWorkspaceForOwner(workspaceId, uid);
|
|
1577
|
-
const snap = await getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)));
|
|
1578
|
-
const invites = snap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()), inviteUrl: `https://okostream.app/invite/${invite.id}` }));
|
|
1579
|
-
return textResult({ workspaceId, invites, count: invites.length });
|
|
1580
|
-
});
|
|
1581
|
-
server.registerTool("oko_revoke_workspace_invite", {
|
|
1582
|
-
title: "Revoke workspace invite",
|
|
1583
|
-
description: toolDescription({
|
|
1584
|
-
summary: "Delete a pending workspace invite code.",
|
|
1585
|
-
args: ["code: invite code.", "confirm: must be true."],
|
|
1586
|
-
returns: "code and revoked=true.",
|
|
1587
|
-
examples: ["Use after confirming the exact invite code."],
|
|
1588
|
-
}),
|
|
1589
|
-
inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH), confirm: z.boolean().optional().describe("Required true for invite revocation.") },
|
|
1590
|
-
outputSchema: z.object({ code: z.string(), revoked: z.boolean() }).passthrough(),
|
|
1591
|
-
annotations: destructiveTool,
|
|
1592
|
-
}, async ({ code, confirm }) => {
|
|
1593
|
-
const { uid } = await authed(["workspace:write"]);
|
|
1594
|
-
const normalizedCode = code.trim().toUpperCase();
|
|
1595
|
-
const inviteSnap = await getDoc(doc(firestore, "workspaceInvites", normalizedCode));
|
|
1596
|
-
if (!inviteSnap.exists())
|
|
1597
|
-
throw new Error("Workspace invite not found.");
|
|
1598
|
-
const invite = inviteSnap.data();
|
|
1599
|
-
if (invite.createdBy !== uid)
|
|
1600
|
-
throw new Error("Only the invite creator/workspace owner can revoke this invite.");
|
|
1601
|
-
requireConfirm(confirm, "oko_revoke_workspace_invite", { code: normalizedCode, workspaceId: invite.workspaceId, claimed: invite.claimed });
|
|
1602
|
-
await deleteDoc(doc(firestore, "workspaceInvites", normalizedCode));
|
|
1603
|
-
return textResult({ code: normalizedCode, revoked: true });
|
|
1604
|
-
});
|
|
1605
|
-
server.registerTool("oko_claim_workspace_invite", {
|
|
1606
|
-
title: "Claim workspace invite",
|
|
1607
|
-
description: toolDescription({
|
|
1608
|
-
summary: "Accept a workspace invite code for the authenticated organizer.",
|
|
1609
|
-
args: ["code: invite code from /invite/{code}.", "confirm: must be true."],
|
|
1610
|
-
returns: "success plus workspace id/name returned by OKO CF19.",
|
|
1611
|
-
examples: ["Use when the organizer wants to join a workspace from an invite code."],
|
|
1612
|
-
}),
|
|
1613
|
-
inputSchema: {
|
|
1614
|
-
code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH),
|
|
1615
|
-
confirm: z.boolean().optional().describe("Required true to join the workspace."),
|
|
1616
|
-
},
|
|
1617
|
-
outputSchema: z.object({
|
|
1618
|
-
success: z.boolean(),
|
|
1619
|
-
workspace: z.object({ id: z.string(), name: z.string() }).passthrough(),
|
|
1620
|
-
}).passthrough(),
|
|
1621
|
-
annotations: writeTool,
|
|
1622
|
-
}, async ({ code, confirm }) => {
|
|
1623
|
-
await authed(["workspace:write"]);
|
|
1624
|
-
requireConfirm(confirm, "oko_claim_workspace_invite", { code: code.trim().toUpperCase() });
|
|
1625
|
-
const claimInvite = httpsCallable(functions, "claimWorkspaceInvite");
|
|
1626
|
-
const result = await claimInvite({ code: code.trim().toUpperCase() });
|
|
1627
|
-
return textResult({ ...serialize(result.data) });
|
|
1628
|
-
});
|
|
1629
|
-
server.registerTool("oko_list_my_events", {
|
|
1630
|
-
title: "List my events",
|
|
1631
|
-
description: toolDescription({
|
|
1632
|
-
summary: "List OKO events visible to the authenticated organizer.",
|
|
1633
|
-
args: [
|
|
1634
|
-
"limit: maximum events to return, default 30, max 100.",
|
|
1635
|
-
"cursor: pagination cursor returned as nextCursor by the previous page; reuse it with the same filters.",
|
|
1636
|
-
"workspaceId: optional workspace filter.",
|
|
1637
|
-
'detail: "compact" (default) returns the light card projection; "full" returns complete event documents.',
|
|
1638
|
-
"from/to: optional ISO timestamps bounding meta.eventDate; sorting switches to eventDate descending and undated events are excluded.",
|
|
1639
|
-
],
|
|
1640
|
-
returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
1641
|
-
examples: [
|
|
1642
|
-
"Use limit=10 first; if hasMore is true, call again with cursor=nextCursor.",
|
|
1643
|
-
'For "today\'s webinar" pass from/to covering the day; use detail="full" only when block contents are needed.',
|
|
1644
|
-
],
|
|
1645
|
-
}),
|
|
1646
|
-
inputSchema: {
|
|
1647
|
-
limit: limitField(),
|
|
1648
|
-
cursor: cursorField,
|
|
1649
|
-
workspaceId: z.string().optional().describe("Optional workspace id to filter returned events."),
|
|
1650
|
-
detail: z.enum(["compact", "full"]).default("compact").describe('Response shape: "compact" card projection (default) or "full" event documents.'),
|
|
1651
|
-
from: z.string().optional().describe("Include only events with meta.eventDate at or after this ISO timestamp."),
|
|
1652
|
-
to: z.string().optional().describe("Include only events with meta.eventDate at or before this ISO timestamp."),
|
|
1653
|
-
},
|
|
1654
|
-
outputSchema: eventsOutputSchema,
|
|
1655
|
-
annotations: readOnlyTool,
|
|
1656
|
-
}, async (args) => {
|
|
1657
|
-
const { uid } = await authed(["events:read"]);
|
|
1658
|
-
const fromTs = parseIsoBound(args.from, "from");
|
|
1659
|
-
const toTs = parseIsoBound(args.to, "to");
|
|
1660
|
-
const dateFiltered = fromTs !== null || toTs !== null;
|
|
1661
|
-
const orderField = dateFiltered ? "eventDate" : "createdAt";
|
|
1662
|
-
const constraints = [
|
|
1663
|
-
// При датном фильтре workspaceId отсеивается по загруженной странице:
|
|
1664
|
-
// составного индекса grants(workspaceId+eventDate) нет намеренно.
|
|
1665
|
-
...(!dateFiltered && args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
|
|
1666
|
-
...(fromTs ? [where("eventDate", ">=", fromTs)] : []),
|
|
1667
|
-
...(toTs ? [where("eventDate", "<=", toTs)] : []),
|
|
1668
|
-
orderBy(orderField, "desc"),
|
|
1669
|
-
limit(args.limit + 1),
|
|
1670
|
-
];
|
|
1671
|
-
if (args.cursor)
|
|
1672
|
-
constraints.splice(constraints.length - 1, 0, startAfter(parseTimestampCursor(args.cursor)));
|
|
1673
|
-
const snap = await getDocs(query(collection(firestore, "eventAccess", uid, "grants"), ...constraints)).catch((error) => {
|
|
1674
|
-
if (!isFirestorePermissionDenied(error))
|
|
1675
|
-
throw error;
|
|
1676
|
-
return null;
|
|
1677
|
-
});
|
|
1678
|
-
if (snap !== null) {
|
|
1679
|
-
const { page, hasMore } = pickPage(snap.docs, args.limit);
|
|
1680
|
-
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data()[orderField]) : null;
|
|
1681
|
-
const pageItems = dateFiltered && args.workspaceId
|
|
1682
|
-
? page.filter((item) => item.data().workspaceId === args.workspaceId)
|
|
1683
|
-
: page;
|
|
1684
|
-
let events;
|
|
1685
|
-
if (args.detail === "full") {
|
|
1686
|
-
const eventDocuments = await Promise.all(pageItems.map((item) => (getDoc(doc(firestore, "events", item.id)))));
|
|
1687
|
-
events = eventDocuments.flatMap((item) => (item.exists()
|
|
1688
|
-
? [{ id: item.id, ...serialize(item.data()) }]
|
|
1689
|
-
: []));
|
|
1690
|
-
}
|
|
1691
|
-
else {
|
|
1692
|
-
// Компактный режим отдаёт grant-проекцию как есть — без чтения
|
|
1693
|
-
// полных документов событий.
|
|
1694
|
-
events = pageItems.map((item) => {
|
|
1695
|
-
const data = serialize(item.data());
|
|
1696
|
-
delete data.schemaVersion;
|
|
1697
|
-
delete data.uid;
|
|
1698
|
-
delete data.eventId;
|
|
1699
|
-
return { id: item.id, ...data };
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
|
|
1703
|
-
}
|
|
1704
|
-
const legacyConstraints = [
|
|
1705
|
-
where("accessibleBy", "array-contains", uid),
|
|
1706
|
-
orderBy("createdAt", "desc"),
|
|
1707
|
-
limit(args.limit + 1),
|
|
1708
|
-
];
|
|
1709
|
-
if (args.cursor) {
|
|
1710
|
-
legacyConstraints.splice(legacyConstraints.length - 1, 0, startAfter(parseTimestampCursor(args.cursor)));
|
|
1711
|
-
}
|
|
1712
|
-
const legacySnap = await getDocs(query(collection(firestore, "events"), ...legacyConstraints));
|
|
1713
|
-
const { page, hasMore } = pickPage(legacySnap.docs, args.limit);
|
|
1714
|
-
const fromMs = fromTs ? fromTs.toMillis() : null;
|
|
1715
|
-
const toMs = toTs ? toTs.toMillis() : null;
|
|
1716
|
-
const events = page
|
|
1717
|
-
.map((item) => ({
|
|
1718
|
-
id: item.id,
|
|
1719
|
-
...serialize(item.data()),
|
|
1720
|
-
}))
|
|
1721
|
-
.filter((event) => !args.workspaceId || event.workspaceId === args.workspaceId)
|
|
1722
|
-
.filter((event) => {
|
|
1723
|
-
if (fromMs === null && toMs === null)
|
|
1724
|
-
return true;
|
|
1725
|
-
const meta = (event.meta && typeof event.meta === "object" ? event.meta : {});
|
|
1726
|
-
const ms = typeof meta.eventDate === "string" ? Date.parse(meta.eventDate) : NaN;
|
|
1727
|
-
if (!Number.isFinite(ms))
|
|
1728
|
-
return false;
|
|
1729
|
-
return (fromMs === null || ms >= fromMs) && (toMs === null || ms <= toMs);
|
|
1730
|
-
})
|
|
1731
|
-
.map((event) => (args.detail === "full" ? event : compactEventFromFullDoc(event)));
|
|
1732
|
-
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
|
|
1733
|
-
return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
|
|
1734
|
-
});
|
|
1735
|
-
server.registerTool("oko_get_event", {
|
|
1736
|
-
title: "Get event",
|
|
1737
|
-
description: toolDescription({
|
|
1738
|
-
summary: "Read one OKO event visible to the organizer.",
|
|
1739
|
-
args: ["eventId: OKO event document id."],
|
|
1740
|
-
returns: "full event document fields plus id and public event URL.",
|
|
1741
|
-
examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
|
|
1742
|
-
}),
|
|
1743
|
-
inputSchema: { eventId: eventIdField },
|
|
1744
|
-
outputSchema: eventOutputSchema,
|
|
1745
|
-
annotations: readOnlyTool,
|
|
1746
|
-
}, async ({ eventId }) => {
|
|
1747
|
-
const { uid } = await authed(["events:read"]);
|
|
1748
|
-
const data = await readEvent(eventId, uid, "read");
|
|
1749
|
-
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId) });
|
|
1750
|
-
});
|
|
1751
|
-
server.registerTool("oko_validate_event_setup", {
|
|
1752
|
-
title: "Validate event setup",
|
|
1753
|
-
description: toolDescription({
|
|
1754
|
-
summary: "Run a read-only setup readiness lint for one OKO event.",
|
|
1755
|
-
args: ["eventId: OKO event document id."],
|
|
1756
|
-
returns: "ok flag, score, errors, warnings, suggestions, next actions, and useful links.",
|
|
1757
|
-
examples: [
|
|
1758
|
-
"Use after creating/configuring a webinar and before telling the organizer it is ready.",
|
|
1759
|
-
"Use before live-session prep to catch missing video, pending image variants, and invalid form bindings.",
|
|
1760
|
-
],
|
|
1761
|
-
notes: ["This tool does not modify the event and does not start/end analytics sessions."],
|
|
1762
|
-
}),
|
|
1763
|
-
inputSchema: { eventId: eventIdField },
|
|
1764
|
-
outputSchema: setupValidationOutputSchema,
|
|
1765
|
-
annotations: readOnlyTool,
|
|
1766
|
-
}, async ({ eventId }) => {
|
|
1767
|
-
const { uid } = await authed(["events:read"]);
|
|
1768
|
-
const data = await readEvent(eventId, uid, "read");
|
|
1769
|
-
return textResult(validateEventSetup(eventId, data));
|
|
1770
|
-
});
|
|
1771
|
-
server.registerTool("oko_create_webinar", {
|
|
1772
|
-
title: "Create webinar",
|
|
1773
|
-
description: toolDescription({
|
|
1774
|
-
summary: "Create an OKO webinar and its required RTDB owner/workspace mirror.",
|
|
1775
|
-
args: [
|
|
1776
|
-
"title: webinar title.",
|
|
1777
|
-
"workspaceId: optional workspace id; omitted means personal workspace.",
|
|
1778
|
-
"videoUrl: optional iframe/source/player URL; normalized by OKO.",
|
|
1779
|
-
"eventDate: optional ISO datetime.",
|
|
1780
|
-
"chatEnabled: whether the chat block is enabled at creation.",
|
|
1781
|
-
],
|
|
1782
|
-
returns: "new event id, public event URL, and workspaceId.",
|
|
1783
|
-
examples: ["Use for a complete create flow, then call oko_configure_blocks and oko_get_event."],
|
|
1784
|
-
notes: ["This creates an event page; OKO has no draft/live/ended event status."],
|
|
1785
|
-
}),
|
|
1786
|
-
inputSchema: {
|
|
1787
|
-
title: z.string().min(1).max(160).describe("Webinar title shown on the public event page."),
|
|
1788
|
-
workspaceId: z.string().optional().describe("Workspace id; omit for the organizer's personal workspace."),
|
|
1789
|
-
videoUrl: z.string().nullable().optional().describe("Optional source/player/iframe URL to normalize."),
|
|
1790
|
-
eventDate: z.string().datetime().nullable().optional().describe("Optional webinar datetime in ISO format."),
|
|
1791
|
-
speakerName: z.string().max(160).nullable().optional().describe("Optional speaker display name."),
|
|
1792
|
-
description: z.string().max(3000).nullable().optional().describe("Optional event description."),
|
|
1793
|
-
chatEnabled: z.boolean().default(false).describe("Whether the chat block starts enabled."),
|
|
1794
|
-
},
|
|
1795
|
-
outputSchema: createWebinarOutputSchema,
|
|
1796
|
-
annotations: writeTool,
|
|
1797
|
-
}, async (args) => {
|
|
1798
|
-
const { uid } = await authed(["events:write"]);
|
|
1799
|
-
const workspaceId = args.workspaceId ?? uid;
|
|
1800
|
-
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
1801
|
-
const video = normalizeVideo(args.videoUrl);
|
|
1802
|
-
const docRef = await addEventWithRtdbMirror({
|
|
1803
|
-
ownerId: uid,
|
|
1804
|
-
workspaceId,
|
|
1805
|
-
...initialEventAccess(uid),
|
|
1806
|
-
createdAt: serverTimestamp(),
|
|
1807
|
-
title: args.title.trim(),
|
|
1808
|
-
pinned: false,
|
|
1809
|
-
video,
|
|
1810
|
-
meta: {
|
|
1811
|
-
eventDate: args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null,
|
|
1812
|
-
speakerName: args.speakerName?.trim() || null,
|
|
1813
|
-
description: args.description?.trim() || null,
|
|
1814
|
-
},
|
|
1815
|
-
blocks: {
|
|
1816
|
-
chat: { enabled: args.chatEnabled, visibility: "always" },
|
|
1817
|
-
promoBanner: { enabled: false, text: "", link: "", visibility: "always" },
|
|
1818
|
-
},
|
|
1819
|
-
});
|
|
1820
|
-
return textResult({ id: docRef.id, ...eventLinks(docRef.id), workspaceId });
|
|
1821
|
-
});
|
|
1822
|
-
server.registerTool("oko_update_event", {
|
|
1823
|
-
title: "Update event metadata",
|
|
1824
|
-
description: toolDescription({
|
|
1825
|
-
summary: "Update safe top-level event/setup metadata fields.",
|
|
1826
|
-
args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned, sessionMode, durationMinutes."],
|
|
1827
|
-
returns: "event id and the list of Firestore fields updated.",
|
|
1828
|
-
examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for blocks and forms."],
|
|
1829
|
-
notes: [
|
|
1830
|
-
"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.",
|
|
1831
|
-
],
|
|
1832
|
-
}),
|
|
1833
|
-
inputSchema: {
|
|
1834
|
-
eventId: eventIdField,
|
|
1835
|
-
title: z.string().min(1).max(160).optional().describe("New webinar title."),
|
|
1836
|
-
videoUrl: z.string().nullable().optional().describe("New source/player/iframe URL, or null to clear."),
|
|
1837
|
-
eventDate: z.string().datetime().nullable().optional().describe("New ISO datetime, or null to clear."),
|
|
1838
|
-
speakerName: z.string().max(160).nullable().optional().describe("New speaker display name, or null to clear."),
|
|
1839
|
-
description: z.string().max(3000).nullable().optional().describe("New description, or null to clear."),
|
|
1840
|
-
pinned: z.boolean().optional().describe("Whether this event is pinned in the dashboard."),
|
|
1841
|
-
sessionMode: z.enum(["manual", "schedule"]).optional().describe("Analytics session mode: manual Start/End buttons, or automatic start/end on schedule at eventDate."),
|
|
1842
|
-
durationMinutes: z.number().int().min(15).max(1440).optional().describe("Event duration in minutes (15-1440) used by schedule mode to end the session."),
|
|
1843
|
-
},
|
|
1844
|
-
outputSchema: updateOutputSchema,
|
|
1845
|
-
annotations: writeTool,
|
|
1846
|
-
}, async (args) => {
|
|
1847
|
-
const requiredScopes = new Set();
|
|
1848
|
-
if (args.title !== undefined || args.pinned !== undefined)
|
|
1849
|
-
requiredScopes.add("events:write");
|
|
1850
|
-
if (args.videoUrl !== undefined
|
|
1851
|
-
|| args.eventDate !== undefined
|
|
1852
|
-
|| args.speakerName !== undefined
|
|
1853
|
-
|| args.description !== undefined
|
|
1854
|
-
|| args.sessionMode !== undefined
|
|
1855
|
-
|| args.durationMinutes !== undefined) {
|
|
1856
|
-
requiredScopes.add("setup:write");
|
|
1857
|
-
}
|
|
1858
|
-
if (requiredScopes.size === 0)
|
|
1859
|
-
throw new Error("Provide at least one field to update.");
|
|
1860
|
-
const { uid } = await authed([...requiredScopes]);
|
|
1861
|
-
await readEvent(args.eventId, uid, "edit");
|
|
1862
|
-
const patch = {};
|
|
1863
|
-
if (args.title !== undefined)
|
|
1864
|
-
patch.title = args.title.trim();
|
|
1865
|
-
if (args.videoUrl !== undefined) {
|
|
1866
|
-
const video = normalizeVideo(args.videoUrl);
|
|
1867
|
-
patch["video.originalUrl"] = video.originalUrl;
|
|
1868
|
-
patch["video.embedUrl"] = video.embedUrl;
|
|
1869
|
-
}
|
|
1870
|
-
if (args.eventDate !== undefined)
|
|
1871
|
-
patch["meta.eventDate"] = args.eventDate ? Timestamp.fromDate(new Date(args.eventDate)) : null;
|
|
1872
|
-
if (args.speakerName !== undefined)
|
|
1873
|
-
patch["meta.speakerName"] = args.speakerName?.trim() || null;
|
|
1874
|
-
if (args.description !== undefined)
|
|
1875
|
-
patch["meta.description"] = args.description?.trim() || null;
|
|
1876
|
-
if (args.pinned !== undefined)
|
|
1877
|
-
patch.pinned = args.pinned;
|
|
1878
|
-
if (args.sessionMode !== undefined)
|
|
1879
|
-
patch.analyticsSessionMode = args.sessionMode;
|
|
1880
|
-
if (args.durationMinutes !== undefined)
|
|
1881
|
-
patch["meta.durationMinutes"] = args.durationMinutes;
|
|
1882
|
-
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
1883
|
-
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
1884
|
-
});
|
|
1885
|
-
server.registerTool("oko_configure_blocks", {
|
|
1886
|
-
title: "Configure event blocks",
|
|
1887
|
-
description: toolDescription({
|
|
1888
|
-
summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
|
|
1889
|
-
args: [
|
|
1890
|
-
"eventId: event to edit.",
|
|
1891
|
-
"chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
|
|
1892
|
-
"forms: replaces configured lead forms when provided.",
|
|
1893
|
-
"telegramChannelIds: replaces configured Telegram channels when provided.",
|
|
1894
|
-
],
|
|
1895
|
-
returns: "event id and the list of Firestore fields updated.",
|
|
1896
|
-
examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
|
|
1897
|
-
notes: [
|
|
1898
|
-
"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.",
|
|
1899
|
-
"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.",
|
|
1900
|
-
],
|
|
1901
|
-
}),
|
|
1902
|
-
inputSchema: {
|
|
1903
|
-
eventId: eventIdField,
|
|
1904
|
-
chat: z.object({ enabled: z.boolean().optional(), visibility: z.enum(["always", "hidden"]).optional() }).optional(),
|
|
1905
|
-
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(),
|
|
1906
|
-
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(),
|
|
1907
|
-
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(),
|
|
1908
|
-
forms: z.array(formSchema).max(20).optional(),
|
|
1909
|
-
telegramChannelIds: z.array(z.number().int()).max(20).optional(),
|
|
1910
|
-
},
|
|
1911
|
-
outputSchema: updateOutputSchema,
|
|
1912
|
-
annotations: writeTool,
|
|
1913
|
-
}, async (args) => {
|
|
1914
|
-
const { uid } = await authed(["setup:write"]);
|
|
1915
|
-
await readEvent(args.eventId, uid, "edit");
|
|
1916
|
-
const patch = {};
|
|
1917
|
-
if (args.chat?.enabled !== undefined)
|
|
1918
|
-
patch["blocks.chat.enabled"] = args.chat.enabled;
|
|
1919
|
-
if (args.chat?.visibility !== undefined)
|
|
1920
|
-
patch["blocks.chat.visibility"] = args.chat.visibility;
|
|
1921
|
-
if (args.promoBanner?.enabled !== undefined)
|
|
1922
|
-
patch["blocks.promoBanner.enabled"] = args.promoBanner.enabled;
|
|
1923
|
-
if (args.promoBanner?.link !== undefined)
|
|
1924
|
-
patch["blocks.promoBanner.link"] = args.promoBanner.link;
|
|
1925
|
-
if (args.promoBanner?.visibility !== undefined)
|
|
1926
|
-
patch["blocks.promoBanner.visibility"] = args.promoBanner.visibility;
|
|
1927
|
-
if (args.promoBanner?.formId !== undefined)
|
|
1928
|
-
patch["blocks.promoBanner.formId"] = args.promoBanner.formId;
|
|
1929
|
-
if (args.primaryCta?.enabled !== undefined)
|
|
1930
|
-
patch["blocks.primaryCta.enabled"] = args.primaryCta.enabled;
|
|
1931
|
-
if (args.primaryCta?.visibility !== undefined)
|
|
1932
|
-
patch["blocks.primaryCta.visibility"] = args.primaryCta.visibility;
|
|
1933
|
-
if (args.primaryCta?.title !== undefined)
|
|
1934
|
-
patch["blocks.primaryCta.title"] = args.primaryCta.title?.trim() || null;
|
|
1935
|
-
if (args.primaryCta?.text !== undefined)
|
|
1936
|
-
patch["blocks.primaryCta.text"] = args.primaryCta.text?.trim() || null;
|
|
1937
|
-
if (args.primaryCta?.buttonLabel !== undefined)
|
|
1938
|
-
patch["blocks.primaryCta.buttonLabel"] = args.primaryCta.buttonLabel?.trim() || null;
|
|
1939
|
-
if (args.primaryCta?.buttonUrl !== undefined)
|
|
1940
|
-
patch["blocks.primaryCta.buttonUrl"] = args.primaryCta.buttonUrl?.trim() || null;
|
|
1941
|
-
if (args.primaryCta?.formId !== undefined)
|
|
1942
|
-
patch["blocks.primaryCta.formId"] = args.primaryCta.formId;
|
|
1943
|
-
if (args.videoPlaceholder?.enabled !== undefined)
|
|
1944
|
-
patch["blocks.video.placeholder.enabled"] = args.videoPlaceholder.enabled;
|
|
1945
|
-
if (args.videoPlaceholder?.mode !== undefined)
|
|
1946
|
-
patch["blocks.video.placeholder.mode"] = args.videoPlaceholder.mode;
|
|
1947
|
-
if (args.videoPlaceholder?.title !== undefined)
|
|
1948
|
-
patch["blocks.video.placeholder.title"] = args.videoPlaceholder.title?.trim() || null;
|
|
1949
|
-
if (args.videoPlaceholder?.subtitle !== undefined)
|
|
1950
|
-
patch["blocks.video.placeholder.subtitle"] = args.videoPlaceholder.subtitle?.trim() || null;
|
|
1951
|
-
if (args.videoPlaceholder?.showTimer !== undefined)
|
|
1952
|
-
patch["blocks.video.placeholder.showTimer"] = args.videoPlaceholder.showTimer;
|
|
1953
|
-
if (args.forms !== undefined)
|
|
1954
|
-
patch.forms = args.forms.map((form) => ({
|
|
1955
|
-
id: form.id ?? randomId(),
|
|
1956
|
-
type: "lead",
|
|
1957
|
-
title: form.title?.trim() || null,
|
|
1958
|
-
description: form.description?.trim() || null,
|
|
1959
|
-
submitLabel: form.submitLabel?.trim() || null,
|
|
1960
|
-
successText: form.successText?.trim() || null,
|
|
1961
|
-
consentText: form.consentText?.trim() || null,
|
|
1962
|
-
consentRequired: form.consentRequired !== false,
|
|
1963
|
-
fields: form.fields.map((field) => ({
|
|
1964
|
-
id: field.id ?? randomId(),
|
|
1965
|
-
label: field.label.trim(),
|
|
1966
|
-
type: field.type,
|
|
1967
|
-
required: field.required === true,
|
|
1968
|
-
placeholder: field.placeholder?.trim() || null,
|
|
1969
|
-
})),
|
|
1970
|
-
}));
|
|
1971
|
-
if (args.telegramChannelIds !== undefined) {
|
|
1972
|
-
patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
|
|
1973
|
-
patch["telegram.channelIds"] = args.telegramChannelIds;
|
|
1974
|
-
patch["telegram.channelId"] = args.telegramChannelIds[0] ?? null;
|
|
1975
|
-
}
|
|
1976
|
-
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
1977
|
-
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
1978
|
-
});
|
|
1979
|
-
server.registerTool("oko_configure_cards", {
|
|
1980
|
-
title: "Configure event cards",
|
|
1981
|
-
description: toolDescription({
|
|
1982
|
-
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.",
|
|
1983
|
-
args: [
|
|
1984
|
-
"eventId: event to edit.",
|
|
1985
|
-
"enabled: optional Cards block enabled flag.",
|
|
1986
|
-
"visibility: optional default visibility, always or hidden.",
|
|
1987
|
-
"cardImageRatio: optional image ratio, one of auto, 1:1, 4:3, 3:2, 16:9, 2:3, 3:4.",
|
|
1988
|
-
"items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId.",
|
|
1989
|
-
],
|
|
1990
|
-
returns: "event id, updated Firestore fields, normalized cards config, generated card ids, preserved image ids, removed card ids, and warnings.",
|
|
1991
|
-
examples: [
|
|
1992
|
-
"Use after oko_create_webinar to create cards before uploading per-card images.",
|
|
1993
|
-
"Call oko_get_event first and pass the full desired items array when editing existing cards.",
|
|
1994
|
-
"Use formId instead of buttonUrl when a card button should open a lead form.",
|
|
1995
|
-
],
|
|
1996
|
-
notes: [
|
|
1997
|
-
"This tool replaces blocks.cards.items when items is provided; omitted text fields become null.",
|
|
1998
|
-
"Existing card images are preserved only when the same card id remains in the replacement list.",
|
|
1999
|
-
"buttonUrl allows http(s), mailto, or tel URLs; javascript/data URLs are rejected.",
|
|
2000
|
-
"If a removed card had an image, Storage cleanup is not automatic; this matches the web Setup behavior.",
|
|
2001
|
-
],
|
|
2002
|
-
}),
|
|
2003
|
-
inputSchema: {
|
|
2004
|
-
eventId: eventIdField,
|
|
2005
|
-
enabled: z.boolean().optional().describe("Whether the Cards block is enabled in setup."),
|
|
2006
|
-
visibility: z.enum(["always", "hidden"]).optional().describe("Default visibility when no live override exists."),
|
|
2007
|
-
cardImageRatio: z.enum(CARD_IMAGE_RATIOS).optional().describe("Image aspect ratio for all cards."),
|
|
2008
|
-
items: z.array(cardItemInputSchema).max(MAX_CARD_ITEMS).optional().describe("Full replacement list, max 6 cards. Omit to update only block settings."),
|
|
2009
|
-
},
|
|
2010
|
-
outputSchema: cardsConfigOutputSchema,
|
|
2011
|
-
annotations: writeTool,
|
|
2012
|
-
}, async (args) => {
|
|
2013
|
-
const { uid } = await authed(["setup:write"]);
|
|
2014
|
-
const event = await readEvent(args.eventId, uid, "edit");
|
|
2015
|
-
const currentCards = readCardsConfig(event);
|
|
2016
|
-
const patch = {};
|
|
2017
|
-
const finalCards = {
|
|
2018
|
-
enabled: args.enabled ?? currentCards.enabled,
|
|
2019
|
-
visibility: args.visibility ?? currentCards.visibility,
|
|
2020
|
-
cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
|
|
2021
|
-
items: currentCards.items,
|
|
2022
|
-
};
|
|
2023
|
-
const generatedCardIds = [];
|
|
2024
|
-
const preservedImageCardIds = [];
|
|
2025
|
-
let removedCardIds = [];
|
|
2026
|
-
let removedCardsWithImages = [];
|
|
2027
|
-
if (args.enabled !== undefined)
|
|
2028
|
-
patch["blocks.cards.enabled"] = args.enabled;
|
|
2029
|
-
if (args.visibility !== undefined)
|
|
2030
|
-
patch["blocks.cards.visibility"] = args.visibility;
|
|
2031
|
-
if (args.cardImageRatio !== undefined)
|
|
2032
|
-
patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
|
|
2033
|
-
if (args.items !== undefined) {
|
|
2034
|
-
const built = buildCardsItems({
|
|
2035
|
-
inputs: args.items,
|
|
2036
|
-
existing: currentCards.items,
|
|
2037
|
-
formIds: eventFormIds(event),
|
|
2038
|
-
});
|
|
2039
|
-
finalCards.items = built.items;
|
|
2040
|
-
generatedCardIds.push(...built.generatedCardIds);
|
|
2041
|
-
preservedImageCardIds.push(...built.preservedImageCardIds);
|
|
2042
|
-
const nextIds = new Set(built.items.map((item) => item.id));
|
|
2043
|
-
const removed = currentCards.items.filter((item) => !nextIds.has(item.id));
|
|
2044
|
-
removedCardIds = removed.map((item) => item.id);
|
|
2045
|
-
removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
|
|
2046
|
-
patch["blocks.cards.items"] = built.items;
|
|
2047
|
-
}
|
|
2048
|
-
if (Object.keys(patch).length > 0) {
|
|
2049
|
-
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
2050
|
-
}
|
|
2051
|
-
const warnings = removedCardsWithImages.length > 0
|
|
2052
|
-
? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
|
|
2053
|
-
: [];
|
|
2054
|
-
return textResult({
|
|
2055
|
-
eventId: args.eventId,
|
|
2056
|
-
updated: Object.keys(patch),
|
|
2057
|
-
cards: finalCards,
|
|
2058
|
-
generatedCardIds,
|
|
2059
|
-
removedCardIds,
|
|
2060
|
-
removedCardsWithImages,
|
|
2061
|
-
preservedImageCardIds,
|
|
2062
|
-
warnings,
|
|
2063
|
-
});
|
|
2064
|
-
});
|
|
2065
|
-
server.registerTool("oko_upload_image", {
|
|
2066
|
-
title: "Upload event image",
|
|
2067
|
-
description: toolDescription({
|
|
2068
|
-
summary: "Upload and compress an image for an OKO setup block, then write the master URL back to Firestore.",
|
|
2069
|
-
args: [
|
|
2070
|
-
"eventId: event to edit.",
|
|
2071
|
-
"kind: promoBanner, videoPlaceholder, or card.",
|
|
2072
|
-
"cardId: required when kind=card; must match an existing blocks.cards.items id.",
|
|
2073
|
-
"source: exactly one of local path or HTTPS image URL.",
|
|
2074
|
-
"confirm: must be true because the public event image may be replaced.",
|
|
2075
|
-
],
|
|
2076
|
-
returns: "eventId, kind, imageId, Storage path, masterUrl, byte counts, and a note that CF-generated variants are pending.",
|
|
2077
|
-
examples: [
|
|
2078
|
-
'Use kind="promoBanner" with source.url to set a webinar banner from an image link.',
|
|
2079
|
-
'Use kind="card" with cardId after the card item already exists.',
|
|
2080
|
-
],
|
|
2081
|
-
notes: [
|
|
2082
|
-
"Uploading an image does not enable its block: toggle the block independently via oko_configure_blocks, in either order.",
|
|
2083
|
-
"The MCP server reads local paths on the organizer machine.",
|
|
2084
|
-
"Remote URLs must be public HTTPS images, max 15MB; private/local hosts are blocked.",
|
|
2085
|
-
"For card images, imageId is the cardId so OKO CF4 can update the correct item.",
|
|
2086
|
-
],
|
|
2087
|
-
}),
|
|
2088
|
-
inputSchema: {
|
|
2089
|
-
eventId: eventIdField,
|
|
2090
|
-
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
2091
|
-
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
2092
|
-
source: imageSourceSchema.describe("Image source: exactly one of path or url."),
|
|
2093
|
-
confirm: z.boolean().optional().describe("Required true for image upload/replacement."),
|
|
2094
|
-
},
|
|
2095
|
-
outputSchema: imageOutputSchema,
|
|
2096
|
-
annotations: writeTool,
|
|
2097
|
-
}, async ({ eventId, kind, cardId, source, confirm }) => {
|
|
2098
|
-
const { uid } = await authed(["setup:write"]);
|
|
2099
|
-
requireConfirm(confirm, "oko_upload_image", { eventId, kind, cardId: cardId ?? null, sourceType: source.path ? "local" : "https" });
|
|
2100
|
-
return textResult(await uploadEventImageForUser(uid, eventId, kind, cardId, source));
|
|
2101
|
-
});
|
|
2102
|
-
server.registerTool("oko_upload_card_images_batch", {
|
|
2103
|
-
title: "Upload card images batch",
|
|
2104
|
-
description: toolDescription({
|
|
2105
|
-
summary: "Upload images for multiple existing cards, preserving card metadata with Firestore transactions and waiting for generated variants.",
|
|
2106
|
-
args: [
|
|
2107
|
-
"eventId: event to edit.",
|
|
2108
|
-
"images: 1-6 card image uploads. Each cardId must already exist in blocks.cards.items.",
|
|
2109
|
-
"timeoutSec: max wait for all CF-generated image variants, default 60 seconds.",
|
|
2110
|
-
"confirm: must be true because existing card images may be replaced.",
|
|
2111
|
-
],
|
|
2112
|
-
returns: "eventId, per-card upload details, per-card variant readiness, ready flag, and warnings.",
|
|
2113
|
-
examples: [
|
|
2114
|
-
"Use after oko_configure_cards has returned stable card ids.",
|
|
2115
|
-
"Use for webinar cards where an agent generated several local images and needs to attach all of them.",
|
|
2116
|
-
],
|
|
2117
|
-
notes: [
|
|
2118
|
-
"This is a workflow tool for cards only. Use oko_upload_image for promoBanner or videoPlaceholder.",
|
|
2119
|
-
"The tool does not create cards; it only attaches images to existing cards.",
|
|
2120
|
-
],
|
|
2121
|
-
}),
|
|
2122
|
-
inputSchema: {
|
|
2123
|
-
eventId: eventIdField,
|
|
2124
|
-
images: z.array(z.object({
|
|
2125
|
-
cardId: cardIdSchema.describe("Existing card id."),
|
|
2126
|
-
source: imageSourceSchema.describe("Image source for this card."),
|
|
2127
|
-
}).strict()).min(1).max(MAX_CARD_ITEMS).describe("Card images to upload."),
|
|
2128
|
-
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait for all generated variants."),
|
|
2129
|
-
confirm: z.boolean().optional().describe("Required true for batch image upload/replacement."),
|
|
2130
|
-
},
|
|
2131
|
-
outputSchema: cardImagesBatchOutputSchema,
|
|
2132
|
-
annotations: writeTool,
|
|
2133
|
-
}, async ({ eventId, images, timeoutSec, confirm }) => {
|
|
2134
|
-
const { uid } = await authed(["setup:write"]);
|
|
2135
|
-
requireConfirm(confirm, "oko_upload_card_images_batch", { eventId, cardIds: images.map((image) => image.cardId).join(",") });
|
|
2136
|
-
const uploads = [];
|
|
2137
|
-
for (const image of images) {
|
|
2138
|
-
uploads.push(await uploadEventImageForUser(uid, eventId, "card", image.cardId, image.source));
|
|
2139
|
-
}
|
|
2140
|
-
const variants = await waitForImageVariantTargets(uid, eventId, images.map((image) => ({ kind: "card", cardId: image.cardId })), timeoutSec);
|
|
2141
|
-
const warnings = variants
|
|
2142
|
-
.filter((item) => !item.ready)
|
|
2143
|
-
.map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`);
|
|
2144
|
-
return textResult({
|
|
2145
|
-
eventId,
|
|
2146
|
-
uploads,
|
|
2147
|
-
variants,
|
|
2148
|
-
ready: variants.every((item) => item.ready),
|
|
2149
|
-
warnings,
|
|
2150
|
-
});
|
|
2151
|
-
});
|
|
2152
|
-
server.registerTool("oko_wait_for_image_variants", {
|
|
2153
|
-
title: "Wait for image variants",
|
|
2154
|
-
description: toolDescription({
|
|
2155
|
-
summary: "Poll an event image slot until Cloud Function generated thumb/card/hero variants are visible in Firestore.",
|
|
2156
|
-
args: [
|
|
2157
|
-
"eventId: event to inspect.",
|
|
2158
|
-
"kind: promoBanner, videoPlaceholder, or card.",
|
|
2159
|
-
"cardId: required when kind=card.",
|
|
2160
|
-
"timeoutSec: max wait, default 60 seconds.",
|
|
2161
|
-
],
|
|
2162
|
-
returns: "image URLs, imageVariants, ready flag, pending reason, and elapsed time.",
|
|
2163
|
-
examples: ["Use after oko_upload_image to avoid checking the UI before image variants are ready."],
|
|
2164
|
-
notes: ["Read-only. It does not trigger variant generation; it only waits for CF4's Firestore update."],
|
|
2165
|
-
}),
|
|
2166
|
-
inputSchema: {
|
|
2167
|
-
eventId: eventIdField,
|
|
2168
|
-
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
2169
|
-
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
2170
|
-
timeoutSec: z.number().int().min(1).max(120).default(60).describe("Maximum seconds to wait."),
|
|
2171
|
-
},
|
|
2172
|
-
outputSchema: imageVariantsOutputSchema,
|
|
2173
|
-
annotations: readOnlyTool,
|
|
2174
|
-
}, async ({ eventId, kind, cardId, timeoutSec }) => {
|
|
2175
|
-
const { uid } = await authed(["events:read"]);
|
|
2176
|
-
if (kind === "card" && !cardId)
|
|
2177
|
-
throw new Error("cardId is required when kind is card.");
|
|
2178
|
-
if (kind !== "card" && cardId)
|
|
2179
|
-
throw new Error("cardId is only valid when kind is card.");
|
|
2180
|
-
const statuses = await waitForImageVariantTargets(uid, eventId, [{ kind, cardId: cardId ?? null }], timeoutSec);
|
|
2181
|
-
const status = statuses[0];
|
|
2182
|
-
if (!status)
|
|
2183
|
-
throw new Error("Image variant status was not produced.");
|
|
2184
|
-
return textResult(status);
|
|
2185
|
-
});
|
|
2186
|
-
server.registerTool("oko_delete_image", {
|
|
2187
|
-
title: "Delete event image",
|
|
2188
|
-
description: toolDescription({
|
|
2189
|
-
summary: "Delete an OKO setup block image from Storage and clear the corresponding Firestore image fields.",
|
|
2190
|
-
args: [
|
|
2191
|
-
"eventId: event to edit.",
|
|
2192
|
-
"kind: promoBanner, videoPlaceholder, or card.",
|
|
2193
|
-
"cardId: required when kind=card.",
|
|
2194
|
-
"confirm: must be true.",
|
|
2195
|
-
],
|
|
2196
|
-
returns: "eventId, kind, deleted=true, and best-effort Storage cleanup warnings if any.",
|
|
2197
|
-
examples: ["Use only after confirming the exact event and image slot with the user."],
|
|
2198
|
-
notes: ["Destructive. Storage cleanup mirrors the web Setup screen: failed object deletes do not block clearing Firestore fields."],
|
|
2199
|
-
}),
|
|
2200
|
-
inputSchema: {
|
|
2201
|
-
eventId: eventIdField,
|
|
2202
|
-
kind: z.enum(IMAGE_KINDS).describe("Target image slot."),
|
|
2203
|
-
cardId: z.string().min(1).optional().describe("Required when kind=card."),
|
|
2204
|
-
confirm: z.boolean().optional().describe("Required true for image deletion."),
|
|
2205
|
-
},
|
|
2206
|
-
outputSchema: deleteImageOutputSchema,
|
|
2207
|
-
annotations: destructiveTool,
|
|
2208
|
-
}, async ({ eventId, kind, cardId, confirm }) => {
|
|
2209
|
-
const { uid } = await authed(["setup:write"]);
|
|
2210
|
-
if (kind === "card" && !cardId)
|
|
2211
|
-
throw new Error("cardId is required when kind is card.");
|
|
2212
|
-
if (kind !== "card" && cardId)
|
|
2213
|
-
throw new Error("cardId is only valid when kind is card.");
|
|
2214
|
-
const event = await readEvent(eventId, uid, "edit");
|
|
2215
|
-
const previous = readImageRef(event, kind, cardId);
|
|
2216
|
-
requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
|
|
2217
|
-
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
2218
|
-
if (kind === "promoBanner") {
|
|
2219
|
-
await updateDoc(doc(firestore, "events", eventId), {
|
|
2220
|
-
"blocks.promoBanner.imageMasterUrl": deleteField(),
|
|
2221
|
-
"blocks.promoBanner.imageUrl": deleteField(),
|
|
2222
|
-
"blocks.promoBanner.imageVariants": deleteField(),
|
|
2223
|
-
"blocks.promoBanner.imageWidth": deleteField(),
|
|
2224
|
-
"blocks.promoBanner.imageHeight": deleteField(),
|
|
2225
|
-
});
|
|
2226
|
-
}
|
|
2227
|
-
else if (kind === "videoPlaceholder") {
|
|
2228
|
-
await updateDoc(doc(firestore, "events", eventId), {
|
|
2229
|
-
"blocks.video.placeholder.imageMasterUrl": deleteField(),
|
|
2230
|
-
"blocks.video.placeholder.imageUrl": deleteField(),
|
|
2231
|
-
"blocks.video.placeholder.imageVariants": deleteField(),
|
|
2232
|
-
"blocks.video.placeholder.imageWidth": deleteField(),
|
|
2233
|
-
"blocks.video.placeholder.imageHeight": deleteField(),
|
|
2234
|
-
});
|
|
2235
|
-
}
|
|
2236
|
-
else {
|
|
2237
|
-
await updateCardImageInTransaction(eventId, cardId, {
|
|
2238
|
-
imageUrl: null,
|
|
2239
|
-
imageMasterUrl: null,
|
|
2240
|
-
imageVariants: null,
|
|
2241
|
-
imageWidth: null,
|
|
2242
|
-
imageHeight: null,
|
|
2243
|
-
});
|
|
2244
|
-
}
|
|
2245
|
-
return textResult({
|
|
2246
|
-
eventId,
|
|
2247
|
-
kind,
|
|
2248
|
-
imageId: previous.imageId ?? (kind === "card" ? cardId : null),
|
|
2249
|
-
deleted: true,
|
|
2250
|
-
storageDeletesAttempted: cleanup.attempted,
|
|
2251
|
-
storageDeleteWarnings: cleanup.warnings,
|
|
2252
|
-
});
|
|
2253
|
-
});
|
|
2254
|
-
server.registerTool("oko_delete_event", {
|
|
2255
|
-
title: "Move event to trash",
|
|
2256
|
-
description: toolDescription({
|
|
2257
|
-
summary: "Move an OKO event to the 30-day trash. This compatibility alias has the same behavior as oko_trash_event.",
|
|
2258
|
-
args: ["eventId: event to move to trash.", "confirm: must be true."],
|
|
2259
|
-
returns: "trashed event id and purge deadline in epoch milliseconds.",
|
|
2260
|
-
examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
|
|
2261
|
-
notes: ["Recoverable for 30 days with oko_restore_event. This does not create an event lifecycle status."],
|
|
2262
|
-
}),
|
|
2263
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
|
|
2264
|
-
outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
|
|
2265
|
-
annotations: destructiveTool,
|
|
2266
|
-
}, async ({ eventId, confirm }) => {
|
|
2267
|
-
const { uid } = await authed(["events:delete"]);
|
|
2268
|
-
const data = await readEvent(eventId, uid, "delete");
|
|
2269
|
-
requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
|
|
2270
|
-
const trash = httpsCallable(functions, "trashEvent");
|
|
2271
|
-
const result = await trash({ eventId });
|
|
2272
|
-
return textResult(result.data);
|
|
2273
|
-
});
|
|
2274
|
-
server.registerTool("oko_trash_event", {
|
|
2275
|
-
title: "Move event to trash",
|
|
2276
|
-
description: toolDescription({
|
|
2277
|
-
summary: "Move an OKO event to the recoverable 30-day trash.",
|
|
2278
|
-
args: ["eventId: event to move to trash.", "confirm: must be true."],
|
|
2279
|
-
returns: "trashed event id and purge deadline in epoch milliseconds.",
|
|
2280
|
-
examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
|
|
2281
|
-
notes: ["End any active analytics session first. Restore with oko_restore_event before the purge deadline."],
|
|
2282
|
-
}),
|
|
2283
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
|
|
2284
|
-
outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
|
|
2285
|
-
annotations: destructiveTool,
|
|
2286
|
-
}, async ({ eventId, confirm }) => {
|
|
2287
|
-
const { uid } = await authed(["events:delete"]);
|
|
2288
|
-
const event = await readEvent(eventId, uid, "delete");
|
|
2289
|
-
requireConfirm(confirm, "oko_trash_event", { eventId, title: event.title });
|
|
2290
|
-
const trash = httpsCallable(functions, "trashEvent");
|
|
2291
|
-
return textResult((await trash({ eventId })).data);
|
|
2292
|
-
});
|
|
2293
|
-
server.registerTool("oko_restore_event", {
|
|
2294
|
-
title: "Restore event",
|
|
2295
|
-
description: toolDescription({
|
|
2296
|
-
summary: "Restore an OKO event from the 30-day trash before its purge deadline.",
|
|
2297
|
-
args: ["eventId: trashed event to restore."],
|
|
2298
|
-
returns: "restored event id.",
|
|
2299
|
-
examples: ["Use when the organizer asks to undo a trash action."],
|
|
2300
|
-
notes: ["Idempotent. Fails after the trash retention deadline has expired."],
|
|
2301
|
-
}),
|
|
2302
|
-
inputSchema: { eventId: eventIdField },
|
|
2303
|
-
outputSchema: z.object({ restored: z.string() }).passthrough(),
|
|
2304
|
-
annotations: idempotentWriteTool,
|
|
2305
|
-
}, async ({ eventId }) => {
|
|
2306
|
-
const { uid } = await authed(["events:delete"]);
|
|
2307
|
-
await readEvent(eventId, uid, "delete");
|
|
2308
|
-
const restore = httpsCallable(functions, "restoreEvent");
|
|
2309
|
-
return textResult((await restore({ eventId })).data);
|
|
2310
|
-
});
|
|
2311
|
-
server.registerTool("oko_delete_event_permanently", {
|
|
2312
|
-
title: "Delete event permanently",
|
|
2313
|
-
description: toolDescription({
|
|
2314
|
-
summary: "Permanently delete a trashed OKO event and all associated Firestore, RTDB, chat, Storage, participant, session, and Telegram data.",
|
|
2315
|
-
args: [
|
|
2316
|
-
"eventId: trashed event to delete permanently.",
|
|
2317
|
-
"confirmTitle: exact current event title, including case and spacing.",
|
|
2318
|
-
"confirm: must be true.",
|
|
2319
|
-
],
|
|
2320
|
-
returns: "deleted event id and cross-store cleanup counts.",
|
|
2321
|
-
examples: ["Read the event first, show its exact title, and proceed only after the organizer explicitly types or confirms that exact title."],
|
|
2322
|
-
notes: ["Irreversible. The event must already be in trash; use oko_trash_event first."],
|
|
2323
|
-
}),
|
|
2324
|
-
inputSchema: {
|
|
2325
|
-
eventId: eventIdField,
|
|
2326
|
-
confirmTitle: z.string().max(500).describe("Exact current event title."),
|
|
2327
|
-
confirm: z.boolean().optional().describe("Required true for permanent deletion."),
|
|
2328
|
-
},
|
|
2329
|
-
outputSchema: z.object({ deleted: z.string(), cleanup: unknownRecordSchema }).passthrough(),
|
|
2330
|
-
annotations: destructiveTool,
|
|
2331
|
-
}, async ({ eventId, confirmTitle, confirm }) => {
|
|
2332
|
-
const { uid } = await authed(["events:delete"]);
|
|
2333
|
-
const event = await readEvent(eventId, uid, "delete");
|
|
2334
|
-
const title = safeString(event.title);
|
|
2335
|
-
if (confirmTitle !== title)
|
|
2336
|
-
throw new Error("confirmTitle must exactly match the current event title.");
|
|
2337
|
-
requireConfirm(confirm, "oko_delete_event_permanently", { eventId, title });
|
|
2338
|
-
const remove = httpsCallable(functions, "deleteEventCompletely");
|
|
2339
|
-
return textResult((await remove({ eventId, confirmTitle })).data);
|
|
2340
|
-
});
|
|
2341
|
-
server.registerTool("oko_duplicate_event", {
|
|
2342
|
-
title: "Duplicate event",
|
|
2343
|
-
description: toolDescription({
|
|
2344
|
-
summary: "Duplicate an event setup into the same or another workspace without analytics/session history.",
|
|
2345
|
-
args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
|
|
2346
|
-
returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
|
|
2347
|
-
examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
|
|
2348
|
-
notes: ["Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions or leads."],
|
|
2349
|
-
}),
|
|
2350
|
-
inputSchema: {
|
|
2351
|
-
eventId: eventIdField,
|
|
2352
|
-
targetWorkspaceId: z.string().min(1).optional().describe("Target workspace id; omit to use the source workspace or personal workspace."),
|
|
2353
|
-
},
|
|
2354
|
-
outputSchema: createWebinarOutputSchema,
|
|
2355
|
-
annotations: writeTool,
|
|
2356
|
-
}, async ({ eventId, targetWorkspaceId }) => {
|
|
2357
|
-
const { uid } = await authed(["events:write"]);
|
|
2358
|
-
const source = await readEvent(eventId, uid, "read");
|
|
2359
|
-
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
2360
|
-
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
2361
|
-
const newRef = await addEventWithRtdbMirror({
|
|
2362
|
-
ownerId: uid,
|
|
2363
|
-
workspaceId,
|
|
2364
|
-
...initialEventAccess(uid),
|
|
2365
|
-
createdAt: serverTimestamp(),
|
|
2366
|
-
title: safeString(source.title, "Untitled event"),
|
|
2367
|
-
pinned: false,
|
|
2368
|
-
video: source.video ?? { originalUrl: null, embedUrl: null },
|
|
2369
|
-
meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
|
|
2370
|
-
blocks: source.blocks ?? {},
|
|
2371
|
-
...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
|
|
2372
|
-
...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
|
|
2373
|
-
});
|
|
2374
|
-
return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
|
|
2375
|
-
});
|
|
2376
|
-
server.registerTool("oko_move_event", {
|
|
2377
|
-
title: "Move event",
|
|
2378
|
-
description: toolDescription({
|
|
2379
|
-
summary: "Move an event to another workspace and recompute accessibleBy/editors.",
|
|
2380
|
-
args: ["eventId: event to move.", "targetWorkspaceId: target workspace id.", "confirm: must be true because access changes."],
|
|
2381
|
-
returns: "eventId, targetWorkspaceId, updated Firestore fields, and RTDB mirror status.",
|
|
2382
|
-
examples: ["Use when an event belongs in another workspace. Call oko_list_workspaces first to choose the target."],
|
|
2383
|
-
notes: ["Reads target workspace shares from Firestore with permissions; does not use RTDB workspaceMembers."],
|
|
2384
|
-
}),
|
|
2385
|
-
inputSchema: {
|
|
2386
|
-
eventId: eventIdField,
|
|
2387
|
-
targetWorkspaceId: z.string().min(1).describe("Target workspace id."),
|
|
2388
|
-
confirm: z.boolean().optional().describe("Required true for event move."),
|
|
2389
|
-
},
|
|
2390
|
-
outputSchema: updateOutputSchema,
|
|
2391
|
-
annotations: writeTool,
|
|
2392
|
-
}, async ({ eventId, targetWorkspaceId, confirm }) => {
|
|
2393
|
-
await authed(["events:write", "workspace:write"]);
|
|
2394
|
-
requireConfirm(confirm, "oko_move_event", { eventId, targetWorkspaceId });
|
|
2395
|
-
const moveEvent = httpsCallable(functions, "moveEventToWorkspace");
|
|
2396
|
-
const result = await moveEvent({ eventId, targetWorkspaceId });
|
|
2397
|
-
return textResult(result.data);
|
|
2398
|
-
});
|
|
2399
|
-
server.registerTool("oko_get_live_state", {
|
|
2400
|
-
title: "Get live state",
|
|
2401
|
-
description: toolDescription({
|
|
2402
|
-
summary: "Read main RTDB live controls, aggregate analytics, and chat settings for an event.",
|
|
2403
|
-
args: ["eventId: event to inspect."],
|
|
2404
|
-
returns: "eventId, agg metrics, ctrl live overrides, and chat settings.",
|
|
2405
|
-
examples: ["Use during a webinar before changing live block visibility or starting/ending sessions."],
|
|
2406
|
-
}),
|
|
2407
|
-
inputSchema: { eventId: eventIdField },
|
|
2408
|
-
outputSchema: liveStateOutputSchema,
|
|
2409
|
-
annotations: readOnlyTool,
|
|
2410
|
-
}, async ({ eventId }) => {
|
|
2411
|
-
const { uid } = await authed(["analytics:read"]);
|
|
2412
|
-
await readEvent(eventId, uid, "analytics");
|
|
2413
|
-
const [aggSnap, ctrlSnap, chatSettingsSnap] = await Promise.all([
|
|
2414
|
-
get(ref(rtdb, `events/${eventId}/agg`)),
|
|
2415
|
-
get(ref(rtdb, `events/${eventId}/ctrl`)),
|
|
2416
|
-
get(ref(chatRtdb, `settings/${eventId}`)),
|
|
2417
|
-
]);
|
|
2418
|
-
return textResult({ eventId, agg: aggSnap.val() ?? {}, ctrl: ctrlSnap.val() ?? {}, chat: chatSettingsSnap.val() ?? {} });
|
|
2419
|
-
});
|
|
2420
|
-
server.registerTool("oko_set_block_visibility", {
|
|
2421
|
-
title: "Set live block visibility",
|
|
2422
|
-
description: toolDescription({
|
|
2423
|
-
summary: "Set or reset a live RTDB visibility override for a public event block.",
|
|
2424
|
-
args: ["eventId: event to control.", "block: chat, banner, cta, cards, or placeholder.", "visible: true/false, or null to reset override.", "confirm: must be true."],
|
|
2425
|
-
returns: "eventId, block, and effective override value.",
|
|
2426
|
-
examples: ["Use visible=false to hide the CTA live; use visible=null to return to setup defaults."],
|
|
2427
|
-
notes: [
|
|
2428
|
-
"block=chat with visible=false PAUSES message input: viewers keep seeing the chat history but cannot send. It does not hide the chat panel.",
|
|
2429
|
-
],
|
|
2430
|
-
}),
|
|
2431
|
-
inputSchema: {
|
|
2432
|
-
eventId: eventIdField,
|
|
2433
|
-
block: z.enum(["chat", "banner", "cta", "cards", "placeholder"]).describe("Public block whose live override should change."),
|
|
2434
|
-
visible: boolOrNull.describe("true/false to force visibility, null to reset override."),
|
|
2435
|
-
confirm: z.boolean().optional().describe("Required true for live visibility change."),
|
|
2436
|
-
},
|
|
2437
|
-
outputSchema: basicEventOutputSchema,
|
|
2438
|
-
annotations: idempotentWriteTool,
|
|
2439
|
-
}, async ({ eventId, block, visible, confirm }) => {
|
|
2440
|
-
const { uid } = await authed(["live:control"]);
|
|
2441
|
-
await readEvent(eventId, uid, "live");
|
|
2442
|
-
requireConfirm(confirm, "oko_set_block_visibility", { eventId, block, visible });
|
|
2443
|
-
const paths = {
|
|
2444
|
-
chat: "chatVisible",
|
|
2445
|
-
banner: "bannerVisible",
|
|
2446
|
-
cta: "ctaVisible",
|
|
2447
|
-
cards: "cardsVisible",
|
|
2448
|
-
placeholder: "placeholderVisible",
|
|
2449
|
-
};
|
|
2450
|
-
const target = ref(rtdb, `events/${eventId}/ctrl/${paths[block]}`);
|
|
2451
|
-
if (visible === null)
|
|
2452
|
-
await remove(target);
|
|
2453
|
-
else
|
|
2454
|
-
await set(target, visible);
|
|
2455
|
-
return textResult({ eventId, block, visible });
|
|
2456
|
-
});
|
|
2457
|
-
server.registerTool("oko_refresh_viewers", {
|
|
2458
|
-
title: "Refresh viewers",
|
|
2459
|
-
description: toolDescription({
|
|
2460
|
-
summary: "Trigger a live viewer refresh tick for an event.",
|
|
2461
|
-
args: ["eventId: event whose viewers should refresh.", "confirm: must be true."],
|
|
2462
|
-
returns: "eventId and refreshed=true.",
|
|
2463
|
-
examples: ["Use after a setup/live change if viewers should reload state immediately."],
|
|
2464
|
-
}),
|
|
2465
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to refresh connected viewers.") },
|
|
2466
|
-
outputSchema: basicEventOutputSchema,
|
|
2467
|
-
annotations: writeTool,
|
|
2468
|
-
}, async ({ eventId, confirm }) => {
|
|
2469
|
-
const { uid } = await authed(["live:control"]);
|
|
2470
|
-
await readEvent(eventId, uid, "live");
|
|
2471
|
-
requireConfirm(confirm, "oko_refresh_viewers", { eventId });
|
|
2472
|
-
await set(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), Date.now());
|
|
2473
|
-
return textResult({ eventId, refreshed: true });
|
|
2474
|
-
});
|
|
2475
|
-
server.registerTool("oko_set_live_badge", {
|
|
2476
|
-
title: "Set live badge",
|
|
2477
|
-
description: toolDescription({
|
|
2478
|
-
summary: "Control the public 'Live' badge on the event page.",
|
|
2479
|
-
args: [
|
|
2480
|
-
"eventId: event to control.",
|
|
2481
|
-
"mode: session (follows the analytics session, product default), manual (badge shown now for durationMinutes), schedule (shown around meta.eventDate), hidden (never shown).",
|
|
2482
|
-
"durationMinutes: badge window for manual/schedule, 15-1440 minutes, default 120.",
|
|
2483
|
-
"confirm: must be true.",
|
|
2484
|
-
],
|
|
2485
|
-
returns: "eventId, mode, durationMinutes.",
|
|
2486
|
-
examples: ['Use mode="manual" during an unscheduled stream; return to mode="session" afterwards.'],
|
|
2487
|
-
notes: ["The badge is a page indicator only; it never starts or stops video or analytics."],
|
|
2488
|
-
}),
|
|
2489
|
-
inputSchema: {
|
|
2490
|
-
eventId: eventIdField,
|
|
2491
|
-
mode: z.enum(["session", "manual", "schedule", "hidden"]).describe("Live badge mode."),
|
|
2492
|
-
durationMinutes: z.number().int().min(15).max(1440).optional().describe("Badge window in minutes for manual/schedule modes; default 120."),
|
|
2493
|
-
confirm: z.boolean().optional().describe("Required true to change the public live badge."),
|
|
2494
|
-
},
|
|
2495
|
-
outputSchema: basicEventOutputSchema,
|
|
2496
|
-
annotations: idempotentWriteTool,
|
|
2497
|
-
}, async ({ eventId, mode, durationMinutes, confirm }) => {
|
|
2498
|
-
const { uid } = await authed(["live:control"]);
|
|
2499
|
-
await readEvent(eventId, uid, "live");
|
|
2500
|
-
requireConfirm(confirm, "oko_set_live_badge", { eventId, mode, durationMinutes: durationMinutes ?? null });
|
|
2501
|
-
const value = { mode, durationMinutes: durationMinutes ?? 120 };
|
|
2502
|
-
await set(ref(rtdb, `events/${eventId}/ctrl/liveBadge`), value);
|
|
2503
|
-
return textResult({ eventId, ...value });
|
|
2504
|
-
});
|
|
2505
|
-
server.registerTool("oko_set_chat_mode", {
|
|
2506
|
-
title: "Set chat mode",
|
|
2507
|
-
description: toolDescription({
|
|
2508
|
-
summary: "Set who can send chat messages on the public event page.",
|
|
2509
|
-
args: [
|
|
2510
|
-
"eventId: event to control.",
|
|
2511
|
-
"mode: public (anyone), auth (signed-in viewers only), owner (organizers only), off (input closed, history stays visible), or null to reset to the product default (public).",
|
|
2512
|
-
"confirm: must be true.",
|
|
2513
|
-
],
|
|
2514
|
-
returns: "eventId and the effective chat mode.",
|
|
2515
|
-
examples: ['Use mode="off" to pause a chat storm; use mode=null or "public" to reopen.'],
|
|
2516
|
-
notes: [
|
|
2517
|
-
'This is the full replacement for the legacy pause via oko_set_block_visibility(block="chat"): prefer oko_set_chat_mode.',
|
|
2518
|
-
"History always stays visible to viewers; mode only gates sending.",
|
|
2519
|
-
],
|
|
2520
|
-
}),
|
|
2521
|
-
inputSchema: {
|
|
2522
|
-
eventId: eventIdField,
|
|
2523
|
-
mode: z.enum(["public", "auth", "owner", "off"]).nullable().describe("Chat mode; null resets the override to the default."),
|
|
2524
|
-
confirm: z.boolean().optional().describe("Required true to change the live chat mode."),
|
|
2525
|
-
},
|
|
2526
|
-
outputSchema: basicEventOutputSchema,
|
|
2527
|
-
annotations: idempotentWriteTool,
|
|
2528
|
-
}, async ({ eventId, mode, confirm }) => {
|
|
2529
|
-
const { uid } = await authed(["live:control"]);
|
|
2530
|
-
await readEvent(eventId, uid, "live");
|
|
2531
|
-
requireConfirm(confirm, "oko_set_chat_mode", { eventId, mode });
|
|
2532
|
-
const target = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
|
|
2533
|
-
if (mode === null)
|
|
2534
|
-
await remove(target);
|
|
2535
|
-
else
|
|
2536
|
-
await set(target, mode);
|
|
2537
|
-
return textResult({ eventId, mode });
|
|
2538
|
-
});
|
|
2539
|
-
server.registerTool("oko_force_viewer_reload", {
|
|
2540
|
-
title: "Force viewer reload",
|
|
2541
|
-
description: toolDescription({
|
|
2542
|
-
summary: "Force a hard page reload for every connected viewer (emergency recovery).",
|
|
2543
|
-
args: ["eventId: event whose viewer pages must reload.", "confirm: must be true."],
|
|
2544
|
-
returns: "eventId and reloaded=true.",
|
|
2545
|
-
examples: ["Use only when viewer pages are stuck (broken bundle or state) and oko_refresh_viewers did not help."],
|
|
2546
|
-
notes: [
|
|
2547
|
-
"Server enforces a 120-second cooldown per event; a repeated call returns the seconds left.",
|
|
2548
|
-
"Viewers reload with up to 4 seconds of jitter. Prefer oko_refresh_viewers for ordinary content updates.",
|
|
2549
|
-
],
|
|
2550
|
-
}),
|
|
2551
|
-
inputSchema: {
|
|
2552
|
-
eventId: eventIdField,
|
|
2553
|
-
confirm: z.boolean().optional().describe("Required true to hard-reload all viewer pages."),
|
|
2554
|
-
},
|
|
2555
|
-
outputSchema: basicEventOutputSchema,
|
|
2556
|
-
annotations: writeTool,
|
|
2557
|
-
}, async ({ eventId, confirm }) => {
|
|
2558
|
-
const { uid } = await authed(["live:control"]);
|
|
2559
|
-
await readEvent(eventId, uid, "live");
|
|
2560
|
-
requireConfirm(confirm, "oko_force_viewer_reload", { eventId });
|
|
2561
|
-
const forceReload = httpsCallable(functions, "forceViewerReload");
|
|
2562
|
-
try {
|
|
2563
|
-
await forceReload({ eventId });
|
|
2564
|
-
}
|
|
2565
|
-
catch (error) {
|
|
2566
|
-
const details = error.details;
|
|
2567
|
-
if (typeof details?.retryInSec === "number") {
|
|
2568
|
-
throw new Error(`Force reload is cooling down: retry in ${details.retryInSec} seconds.`);
|
|
2569
|
-
}
|
|
2570
|
-
throw error;
|
|
2571
|
-
}
|
|
2572
|
-
return textResult({ eventId, reloaded: true });
|
|
2573
|
-
});
|
|
2574
|
-
server.registerTool("oko_start_session", {
|
|
2575
|
-
title: "Start analytics session",
|
|
2576
|
-
description: toolDescription({
|
|
2577
|
-
summary: "Start an analytics session using the same Firestore/RTDB pointers as the dashboard.",
|
|
2578
|
-
args: ["eventId: event whose analytics session should start.", "confirm: must be true."],
|
|
2579
|
-
returns: "eventId, new sessionId, and started=true.",
|
|
2580
|
-
examples: ["Use when the organizer wants analytics tracking to begin; it does not start the video stream."],
|
|
2581
|
-
notes: ["OKO Start/End controls analytics sessions only, not stream publishing."],
|
|
2582
|
-
}),
|
|
2583
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to start analytics collection session.") },
|
|
2584
|
-
outputSchema: basicEventOutputSchema,
|
|
2585
|
-
annotations: writeTool,
|
|
2586
|
-
}, async ({ eventId, confirm }) => {
|
|
2587
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2588
|
-
const event = await readEvent(eventId, uid, "session");
|
|
2589
|
-
requireConfirm(confirm, "oko_start_session", { eventId, title: event.title ?? null });
|
|
2590
|
-
if (event.activeAnalyticsSessionId)
|
|
2591
|
-
throw new Error("An analytics session is already active.");
|
|
2592
|
-
const start = httpsCallable(functions, "startAnalyticsSessionV3");
|
|
2593
|
-
const result = await start({ eventId });
|
|
2594
|
-
return textResult({ started: true, ...result.data });
|
|
2595
|
-
});
|
|
2596
|
-
server.registerTool("oko_end_session", {
|
|
2597
|
-
title: "End analytics session",
|
|
2598
|
-
description: toolDescription({
|
|
2599
|
-
summary: "End the active analytics session and persist the session snapshot.",
|
|
2600
|
-
args: ["eventId: event with an active analytics session.", "confirm: must be true."],
|
|
2601
|
-
returns: "eventId, sessionId, and ended=true.",
|
|
2602
|
-
examples: ["Use after the webinar analytics window is over and the user confirms the exact event."],
|
|
2603
|
-
notes: ["Destructive for the active session pointer, but it does not stop the video stream."],
|
|
2604
|
-
}),
|
|
2605
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true for ending the active session.") },
|
|
2606
|
-
outputSchema: basicEventOutputSchema,
|
|
2607
|
-
annotations: destructiveTool,
|
|
2608
|
-
}, async ({ eventId, confirm }) => {
|
|
2609
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2610
|
-
const event = await readEvent(eventId, uid, "session");
|
|
2611
|
-
const sessionId = safeString(event.activeAnalyticsSessionId);
|
|
2612
|
-
if (!sessionId)
|
|
2613
|
-
throw new Error("No active analytics session.");
|
|
2614
|
-
requireConfirm(confirm, "oko_end_session", { eventId, sessionId });
|
|
2615
|
-
const end = httpsCallable(functions, "endAnalyticsSessionV3");
|
|
2616
|
-
const result = await end({ eventId, sessionId });
|
|
2617
|
-
return textResult(result.data);
|
|
2618
|
-
});
|
|
2619
|
-
server.registerTool("oko_fix_orphan_session", {
|
|
2620
|
-
title: "Fix orphan analytics session",
|
|
2621
|
-
description: toolDescription({
|
|
2622
|
-
summary: "Clear stuck active-session pointers when an analytics session cannot be ended normally.",
|
|
2623
|
-
args: [
|
|
2624
|
-
"eventId: event with stuck session pointers.",
|
|
2625
|
-
"deleteRtdbSessionData: optional, also delete events/{eventId}/sessions/{sessionId}; default false.",
|
|
2626
|
-
"confirm: must be true.",
|
|
2627
|
-
],
|
|
2628
|
-
returns: "eventId, cleared sessionId if found, and cleaned paths.",
|
|
2629
|
-
examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
|
|
2630
|
-
notes: ["This does not stop the video stream; OKO Start/End controls analytics sessions only."],
|
|
2631
|
-
}),
|
|
2632
|
-
inputSchema: {
|
|
2633
|
-
eventId: eventIdField,
|
|
2634
|
-
deleteRtdbSessionData: z.boolean().default(false).describe("Also delete RTDB session data for the stuck session."),
|
|
2635
|
-
confirm: z.boolean().optional().describe("Required true for clearing session pointers."),
|
|
2636
|
-
},
|
|
2637
|
-
outputSchema: basicEventOutputSchema,
|
|
2638
|
-
annotations: destructiveTool,
|
|
2639
|
-
}, async ({ eventId, deleteRtdbSessionData, confirm }) => {
|
|
2640
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2641
|
-
const event = await readEvent(eventId, uid, "session");
|
|
2642
|
-
const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
|
|
2643
|
-
const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
|
|
2644
|
-
requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
|
|
2645
|
-
const cleanup = [
|
|
2646
|
-
{
|
|
2647
|
-
path: "events.activeAnalyticsSessionId",
|
|
2648
|
-
promise: updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
|
|
2649
|
-
},
|
|
2650
|
-
{
|
|
2651
|
-
path: "ctrl/activeSessionId",
|
|
2652
|
-
promise: remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
2653
|
-
},
|
|
2654
|
-
];
|
|
2655
|
-
if (sessionId) {
|
|
2656
|
-
cleanup.push({
|
|
2657
|
-
path: `activeSessions/${sessionId}`,
|
|
2658
|
-
promise: remove(ref(rtdb, `activeSessions/${sessionId}`)),
|
|
2659
|
-
});
|
|
2660
|
-
}
|
|
2661
|
-
if (sessionId && deleteRtdbSessionData) {
|
|
2662
|
-
cleanup.push({
|
|
2663
|
-
path: `events/${eventId}/sessions/${sessionId}`,
|
|
2664
|
-
promise: remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
|
|
2665
|
-
});
|
|
2666
|
-
}
|
|
2667
|
-
const settled = await Promise.allSettled(cleanup.map((operation) => operation.promise));
|
|
2668
|
-
const attempts = settled.map((result, index) => {
|
|
2669
|
-
const operation = cleanup[index];
|
|
2670
|
-
if (!operation)
|
|
2671
|
-
throw new Error("Session repair result has no matching cleanup operation.");
|
|
2672
|
-
return {
|
|
2673
|
-
path: operation.path,
|
|
2674
|
-
status: result.status,
|
|
2675
|
-
...(result.status === "rejected" ? {
|
|
2676
|
-
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
|
2677
|
-
} : {}),
|
|
2678
|
-
};
|
|
2679
|
-
});
|
|
2680
|
-
let firestorePointer = null;
|
|
2681
|
-
let rtdbPointer = null;
|
|
2682
|
-
let verificationError;
|
|
2683
|
-
try {
|
|
2684
|
-
const [eventAfter, ctrlAfter] = await Promise.all([
|
|
2685
|
-
getDoc(doc(firestore, "events", eventId)),
|
|
2686
|
-
get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
2687
|
-
]);
|
|
2688
|
-
firestorePointer = eventAfter.exists()
|
|
2689
|
-
? safeString(eventAfter.data().activeAnalyticsSessionId) || null
|
|
2690
|
-
: null;
|
|
2691
|
-
rtdbPointer = safeString(ctrlAfter.val()) || null;
|
|
2692
|
-
}
|
|
2693
|
-
catch (error) {
|
|
2694
|
-
verificationError = error instanceof Error ? error.message : String(error);
|
|
2695
|
-
}
|
|
2696
|
-
return textResult(summarizeSessionRepair({
|
|
2697
|
-
eventId,
|
|
2698
|
-
sessionId: sessionId || null,
|
|
2699
|
-
deleteRtdbSessionData,
|
|
2700
|
-
attempts,
|
|
2701
|
-
firestorePointer,
|
|
2702
|
-
rtdbPointer,
|
|
2703
|
-
verificationError,
|
|
2704
|
-
}));
|
|
2705
|
-
});
|
|
2706
|
-
server.registerTool("oko_delete_session", {
|
|
2707
|
-
title: "Delete analytics session",
|
|
2708
|
-
description: toolDescription({
|
|
2709
|
-
summary: "Delete an analytics session document and optionally its RTDB session data.",
|
|
2710
|
-
args: [
|
|
2711
|
-
"eventId: event id.",
|
|
2712
|
-
"sessionId: analytics session id.",
|
|
2713
|
-
"deleteRtdbData: best-effort delete of events/{eventId}/sessions/{sessionId}; default false.",
|
|
2714
|
-
"confirm: must be true.",
|
|
2715
|
-
],
|
|
2716
|
-
returns: "eventId, sessionId, deleted=true, and cleaned paths.",
|
|
2717
|
-
examples: ["Use only after confirming the exact session id with the organizer."],
|
|
2718
|
-
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."],
|
|
2719
|
-
}),
|
|
2720
|
-
inputSchema: {
|
|
2721
|
-
eventId: eventIdField,
|
|
2722
|
-
sessionId: z.string().min(1).describe("analyticsSessions document id."),
|
|
2723
|
-
deleteRtdbData: z.boolean().default(false).describe("Best-effort delete RTDB session data for this session."),
|
|
2724
|
-
confirm: z.boolean().optional().describe("Required true for session deletion."),
|
|
2725
|
-
},
|
|
2726
|
-
outputSchema: basicEventOutputSchema,
|
|
2727
|
-
annotations: destructiveTool,
|
|
2728
|
-
}, async ({ eventId, sessionId, deleteRtdbData, confirm }) => {
|
|
2729
|
-
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2730
|
-
const event = await readEvent(eventId, uid, "session");
|
|
2731
|
-
requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
|
|
2732
|
-
const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
|
|
2733
|
-
const cleanup = [];
|
|
2734
|
-
if (isActive) {
|
|
2735
|
-
cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
|
|
2736
|
-
cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
|
|
2737
|
-
cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
|
|
2738
|
-
}
|
|
2739
|
-
cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
|
|
2740
|
-
const results = await Promise.allSettled(cleanup);
|
|
2741
|
-
const warnings = results
|
|
2742
|
-
.filter((result) => result.status === "rejected")
|
|
2743
|
-
.map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
2744
|
-
if (warnings.length > 0)
|
|
2745
|
-
throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
|
|
2746
|
-
const rtdbCleanupWarnings = [];
|
|
2747
|
-
if (deleteRtdbData) {
|
|
2748
|
-
try {
|
|
2749
|
-
await remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`));
|
|
2750
|
-
}
|
|
2751
|
-
catch (err) {
|
|
2752
|
-
rtdbCleanupWarnings.push(err instanceof Error ? err.message : String(err));
|
|
2753
|
-
}
|
|
2754
|
-
}
|
|
2755
|
-
return textResult({ eventId, sessionId, deleted: true, wasActive: isActive, deleteRtdbData, rtdbCleanupWarnings });
|
|
2756
|
-
});
|
|
2757
|
-
server.registerTool("oko_get_analytics", {
|
|
2758
|
-
title: "Get analytics sessions",
|
|
2759
|
-
description: toolDescription({
|
|
2760
|
-
summary: "Read paginated analytics session summaries for an event.",
|
|
2761
|
-
args: [
|
|
2762
|
-
"eventId: event to inspect.",
|
|
2763
|
-
"limit: max sessions to return, default 10, max 50.",
|
|
2764
|
-
"cursor: timestamp cursor returned as nextCursor by the previous page.",
|
|
2765
|
-
"includeTimeline: include recent timeline buckets; default false to keep context small.",
|
|
2766
|
-
"timelineLimit: max timeline buckets per session when includeTimeline=true.",
|
|
2767
|
-
"includeSnapshots: include baseline/final snapshots; default false.",
|
|
2768
|
-
],
|
|
2769
|
-
returns: "sessions plus count, limit, hasMore/has_more, nextCursor/next_cursor. Heavy timeline/snapshot fields are omitted unless requested.",
|
|
2770
|
-
examples: ["Use default options for dashboard-like summaries; request includeTimeline only for chart/debug tasks."],
|
|
2771
|
-
}),
|
|
2772
|
-
inputSchema: {
|
|
2773
|
-
eventId: eventIdField,
|
|
2774
|
-
limit: limitField(50, ANALYTICS_DEFAULT_LIMIT),
|
|
2775
|
-
cursor: cursorField,
|
|
2776
|
-
includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets for each session."),
|
|
2777
|
-
timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets per session when includeTimeline=true."),
|
|
2778
|
-
includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
|
|
2779
|
-
},
|
|
2780
|
-
outputSchema: analyticsOutputSchema,
|
|
2781
|
-
annotations: readOnlyTool,
|
|
2782
|
-
}, async ({ eventId, limit: max, cursor, includeTimeline, timelineLimit, includeSnapshots }) => {
|
|
2783
|
-
const { uid } = await authed(["analytics:read"]);
|
|
2784
|
-
await readEvent(eventId, uid, "analytics");
|
|
2785
|
-
const constraints = [
|
|
2786
|
-
orderBy("startedAt", "desc"),
|
|
2787
|
-
limit(max + 1),
|
|
2788
|
-
];
|
|
2789
|
-
if (cursor)
|
|
2790
|
-
constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
|
|
2791
|
-
const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), ...constraints));
|
|
2792
|
-
const { page, hasMore } = pickPage(snap.docs, max);
|
|
2793
|
-
const sessions = page.map((item) => compactAnalyticsSession({ id: item.id, ...item.data() }, { includeTimeline, timelineLimit, includeSnapshots }));
|
|
2794
|
-
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
|
|
2795
|
-
return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots disabled." });
|
|
2796
|
-
});
|
|
2797
|
-
server.registerTool("oko_get_session_report", {
|
|
2798
|
-
title: "Get session report",
|
|
2799
|
-
description: toolDescription({
|
|
2800
|
-
summary: "Build a ready-to-share report for one analytics session, with compact structured data and markdown.",
|
|
2801
|
-
args: [
|
|
2802
|
-
"eventId: event to inspect.",
|
|
2803
|
-
"sessionId: optional analytics session id; omitted means latest session.",
|
|
2804
|
-
"includeTimeline: include recent timeline buckets; default false.",
|
|
2805
|
-
"timelineLimit: max timeline buckets when includeTimeline=true.",
|
|
2806
|
-
"includeSnapshots: include baseline/final snapshots; default false.",
|
|
2807
|
-
],
|
|
2808
|
-
returns: "event/session ids, report URL, markdown summary, event summary, and compact session object.",
|
|
2809
|
-
examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
|
|
2810
|
-
}),
|
|
2811
|
-
inputSchema: {
|
|
2812
|
-
eventId: eventIdField,
|
|
2813
|
-
sessionId: z.string().min(1).optional().describe("analyticsSessions document id. Omit to use the latest session."),
|
|
2814
|
-
includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets."),
|
|
2815
|
-
timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets when includeTimeline=true."),
|
|
2816
|
-
includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
|
|
2817
|
-
},
|
|
2818
|
-
outputSchema: sessionReportOutputSchema,
|
|
2819
|
-
annotations: readOnlyTool,
|
|
2820
|
-
}, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots }) => {
|
|
2821
|
-
const { uid } = await authed(["analytics:read"]);
|
|
2822
|
-
const event = await readEvent(eventId, uid, "report");
|
|
2823
|
-
let resolvedSessionId = sessionId;
|
|
2824
|
-
let sessionData = null;
|
|
2825
|
-
if (resolvedSessionId) {
|
|
2826
|
-
const sessionSnap = await getDoc(doc(firestore, "events", eventId, "analyticsSessions", resolvedSessionId));
|
|
2827
|
-
if (!sessionSnap.exists())
|
|
2828
|
-
throw new Error("Analytics session not found.");
|
|
2829
|
-
sessionData = sessionSnap.data();
|
|
2830
|
-
}
|
|
2831
|
-
else {
|
|
2832
|
-
const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), orderBy("startedAt", "desc"), limit(1)));
|
|
2833
|
-
const first = snap.docs[0];
|
|
2834
|
-
if (!first)
|
|
2835
|
-
throw new Error("No analytics sessions found for this event.");
|
|
2836
|
-
resolvedSessionId = first.id;
|
|
2837
|
-
sessionData = first.data();
|
|
2838
|
-
}
|
|
2839
|
-
const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots });
|
|
2840
|
-
const markdown = sessionReportMarkdown({ eventId, event, sessionId: resolvedSessionId, session });
|
|
2841
|
-
return textResult({
|
|
2842
|
-
eventId,
|
|
2843
|
-
sessionId: resolvedSessionId,
|
|
2844
|
-
event: {
|
|
2845
|
-
id: eventId,
|
|
2846
|
-
title: safeString(event.title, eventId),
|
|
2847
|
-
...eventLinks(eventId),
|
|
2848
|
-
},
|
|
2849
|
-
reportUrl: sessionReportUrl(eventId, resolvedSessionId),
|
|
2850
|
-
markdown,
|
|
2851
|
-
session,
|
|
2852
|
-
}, { truncationHint: "Keep includeTimeline/includeSnapshots disabled or lower timelineLimit." });
|
|
2853
|
-
});
|
|
2854
|
-
server.registerTool("oko_list_recent_chat", {
|
|
2855
|
-
title: "List recent chat",
|
|
2856
|
-
description: toolDescription({
|
|
2857
|
-
summary: "Read recent chat messages from chat RTDB with cursor pagination.",
|
|
2858
|
-
args: [
|
|
2859
|
-
"eventId: event chat to inspect.",
|
|
2860
|
-
"limit: max messages to return, default 50, max 100.",
|
|
2861
|
-
"cursor: message id returned as nextCursor by the previous page to read older messages.",
|
|
2862
|
-
],
|
|
2863
|
-
returns: "messages array sorted newest-first plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
2864
|
-
examples: ["Use limit=20 first; if hasMore is true, call again with cursor=nextCursor for older chat."],
|
|
2865
|
-
}),
|
|
2866
|
-
inputSchema: { eventId: eventIdField, limit: limitField(100, 50), cursor: cursorField },
|
|
2867
|
-
outputSchema: chatOutputSchema,
|
|
2868
|
-
annotations: readOnlyTool,
|
|
2869
|
-
}, async ({ eventId, limit: max, cursor }) => {
|
|
2870
|
-
const { uid } = await authed(["analytics:read"]);
|
|
2871
|
-
await readEvent(eventId, uid, "read");
|
|
2872
|
-
const constraints = cursor
|
|
2873
|
-
? [orderByKey(), endBefore(cursor), limitToLast(max + 1)]
|
|
2874
|
-
: [orderByKey(), limitToLast(max + 1)];
|
|
2875
|
-
const snap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), ...constraints));
|
|
2876
|
-
const entries = Object.entries((snap.val() ?? {}))
|
|
2877
|
-
.sort(([a], [b]) => b.localeCompare(a));
|
|
2878
|
-
const { page, hasMore } = pickPage(entries, max);
|
|
2879
|
-
const messages = page.map(([id, message]) => ({ id, ...serialize(message) }));
|
|
2880
|
-
const nextCursor = hasMore ? messages.at(-1)?.id ?? null : null;
|
|
2881
|
-
return textResult({ messages, ...cursorMeta(messages.length, max, hasMore, nextCursor) });
|
|
2882
|
-
});
|
|
2883
|
-
server.registerTool("oko_send_chat_message", {
|
|
2884
|
-
title: "Send chat message",
|
|
2885
|
-
description: toolDescription({
|
|
2886
|
-
summary: "Send a chat message as the authenticated organizer/moderator.",
|
|
2887
|
-
args: ["eventId: target event chat.", "text: message text, max 500 chars.", "name: display name, default OKO MCP.", "confirm: must be true."],
|
|
2888
|
-
returns: "eventId, new messageId, and sent=true.",
|
|
2889
|
-
examples: ["Use for organizer announcements in chat after user confirms the exact text."],
|
|
2890
|
-
}),
|
|
2891
|
-
inputSchema: {
|
|
2892
|
-
eventId: eventIdField,
|
|
2893
|
-
text: z.string().min(1).max(500).describe("Message text."),
|
|
2894
|
-
name: z.string().min(1).max(24).default("OKO MCP").describe("Display name."),
|
|
2895
|
-
confirm: z.boolean().optional().describe("Required true to post to live chat."),
|
|
2896
|
-
},
|
|
2897
|
-
outputSchema: basicEventOutputSchema,
|
|
2898
|
-
annotations: writeTool,
|
|
2899
|
-
}, async ({ eventId, text, name, confirm }) => {
|
|
2900
|
-
const { uid } = await authed(["chat:write"]);
|
|
2901
|
-
await readEvent(eventId, uid, "read");
|
|
2902
|
-
requireConfirm(confirm, "oko_send_chat_message", { eventId, name, text });
|
|
2903
|
-
const role = await getChatRole(eventId, uid);
|
|
2904
|
-
const msgRef = push(ref(chatRtdb, `messages/${eventId}`));
|
|
2905
|
-
if (!msgRef.key)
|
|
2906
|
-
throw new Error("Could not allocate chat message id");
|
|
2907
|
-
const timestamp = rtdbServerTimestamp();
|
|
2908
|
-
await update(ref(chatRtdb), {
|
|
2909
|
-
[`messages/${eventId}/${msgRef.key}`]: { text, uid, name, role, ts: timestamp },
|
|
2910
|
-
[`rate_limits/${eventId}/${uid}`]: { ts: timestamp, messageId: msgRef.key },
|
|
2911
|
-
});
|
|
2912
|
-
await runTransaction(ref(rtdb, `events/${eventId}/agg/chatMessages`), (n) => (n ?? 0) + 1)
|
|
2913
|
-
.catch(() => undefined);
|
|
2914
|
-
return textResult({ eventId, messageId: msgRef.key, sent: true });
|
|
2915
|
-
});
|
|
2916
|
-
server.registerTool("oko_delete_chat_message", {
|
|
2917
|
-
title: "Delete chat message",
|
|
2918
|
-
description: toolDescription({
|
|
2919
|
-
summary: "Soft-delete a chat message through moderation metadata.",
|
|
2920
|
-
args: ["eventId: target event.", "messageId: chat RTDB message id.", "confirm: must be true."],
|
|
2921
|
-
returns: "eventId, messageId, and deleted=true.",
|
|
2922
|
-
examples: ["Use only after showing the message text/id and receiving explicit confirmation."],
|
|
2923
|
-
}),
|
|
2924
|
-
inputSchema: { eventId: eventIdField, messageId: z.string().min(1).describe("Chat RTDB message id."), confirm: z.boolean().optional().describe("Required true for deletion.") },
|
|
2925
|
-
outputSchema: basicEventOutputSchema,
|
|
2926
|
-
annotations: destructiveTool,
|
|
2927
|
-
}, async ({ eventId, messageId, confirm }) => {
|
|
2928
|
-
const { uid } = await authed(["chat:moderate"]);
|
|
2929
|
-
await readEvent(eventId, uid, "moderate");
|
|
2930
|
-
requireConfirm(confirm, "oko_delete_chat_message", { eventId, messageId });
|
|
2931
|
-
await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: rtdbServerTimestamp(), ts: Date.now(), by: uid });
|
|
2932
|
-
return textResult({ eventId, messageId, deleted: true });
|
|
2933
|
-
});
|
|
2934
|
-
server.registerTool("oko_mute_user", {
|
|
2935
|
-
title: "Mute chat user",
|
|
2936
|
-
description: toolDescription({
|
|
2937
|
-
summary: "Mute a chat user for a bounded time window.",
|
|
2938
|
-
args: ["eventId: target event.", "uid: Firebase uid to mute.", "minutes: mute duration, 1-1440, default 5.", "confirm: must be true."],
|
|
2939
|
-
returns: "eventId, targetUid, and mutedMinutes.",
|
|
2940
|
-
examples: ["Use after reading chat messages and confirming the exact uid with the user."],
|
|
2941
|
-
}),
|
|
2942
|
-
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to mute."), minutes: z.number().int().min(1).max(1440).default(5).describe("Mute duration in minutes."), confirm: z.boolean().optional().describe("Required true for mute.") },
|
|
2943
|
-
outputSchema: basicEventOutputSchema,
|
|
2944
|
-
annotations: destructiveTool,
|
|
2945
|
-
}, async ({ eventId, uid: targetUid, minutes, confirm }) => {
|
|
2946
|
-
const actor = await authed(["chat:moderate"]);
|
|
2947
|
-
await readEvent(eventId, actor.uid, "moderate");
|
|
2948
|
-
requireConfirm(confirm, "oko_mute_user", { eventId, targetUid, minutes });
|
|
2949
|
-
await set(ref(chatRtdb, `mutes/${eventId}/${targetUid}`), { until: Date.now() + minutes * 60_000, by: actor.uid, reason: "manual" });
|
|
2950
|
-
return textResult({ eventId, targetUid, mutedMinutes: minutes });
|
|
2951
|
-
});
|
|
2952
|
-
server.registerTool("oko_ban_user", {
|
|
2953
|
-
title: "Ban chat user",
|
|
2954
|
-
description: toolDescription({
|
|
2955
|
-
summary: "Ban a chat user and soft-delete up to 500 recent messages from that uid.",
|
|
2956
|
-
args: ["eventId: target event.", "uid: Firebase uid to ban.", "confirm: must be true."],
|
|
2957
|
-
returns: "eventId, targetUid, banned=true, and deletedMessages count.",
|
|
2958
|
-
examples: ["Use only after explicit user confirmation; prefer mute for temporary moderation."],
|
|
2959
|
-
}),
|
|
2960
|
-
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to ban."), confirm: z.boolean().optional().describe("Required true for ban.") },
|
|
2961
|
-
outputSchema: basicEventOutputSchema,
|
|
2962
|
-
annotations: destructiveTool,
|
|
2963
|
-
}, async ({ eventId, uid: targetUid, confirm }) => {
|
|
2964
|
-
const actor = await authed(["chat:moderate"]);
|
|
2965
|
-
await readEvent(eventId, actor.uid, "moderate");
|
|
2966
|
-
requireConfirm(confirm, "oko_ban_user", { eventId, targetUid });
|
|
2967
|
-
await set(ref(chatRtdb, `bans/${eventId}/${targetUid}`), true);
|
|
2968
|
-
const messagesSnap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), limitToLast(500)));
|
|
2969
|
-
const messages = (messagesSnap.val() ?? {});
|
|
2970
|
-
const deletes = {};
|
|
2971
|
-
for (const [messageId, message] of Object.entries(messages)) {
|
|
2972
|
-
if (message.uid === targetUid)
|
|
2973
|
-
deletes[`mod/deleted/${eventId}/${messageId}`] = { deletedAt: rtdbServerTimestamp(), ts: Date.now(), by: actor.uid, reason: "ban" };
|
|
2974
|
-
}
|
|
2975
|
-
if (Object.keys(deletes).length > 0)
|
|
2976
|
-
await update(ref(chatRtdb), deletes);
|
|
2977
|
-
return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
|
|
2978
|
-
});
|
|
2979
|
-
server.registerTool("oko_unmute_user", {
|
|
2980
|
-
title: "Unmute chat user",
|
|
2981
|
-
description: toolDescription({
|
|
2982
|
-
summary: "Remove a chat mute for a user.",
|
|
2983
|
-
args: ["eventId: target event.", "uid: Firebase uid to unmute.", "confirm: must be true."],
|
|
2984
|
-
returns: "eventId, targetUid, and unmuted=true.",
|
|
2985
|
-
examples: ["Use when a user was muted by mistake or the organizer wants to restore chat access."],
|
|
2986
|
-
}),
|
|
2987
|
-
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unmute."), confirm: z.boolean().optional().describe("Required true for unmute.") },
|
|
2988
|
-
outputSchema: basicEventOutputSchema,
|
|
2989
|
-
annotations: idempotentWriteTool,
|
|
2990
|
-
}, async ({ eventId, uid: targetUid, confirm }) => {
|
|
2991
|
-
const actor = await authed(["chat:moderate"]);
|
|
2992
|
-
await readEvent(eventId, actor.uid, "moderate");
|
|
2993
|
-
requireConfirm(confirm, "oko_unmute_user", { eventId, targetUid });
|
|
2994
|
-
await remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`));
|
|
2995
|
-
return textResult({ eventId, targetUid, unmuted: true });
|
|
2996
|
-
});
|
|
2997
|
-
server.registerTool("oko_unban_user", {
|
|
2998
|
-
title: "Unban chat user",
|
|
2999
|
-
description: toolDescription({
|
|
3000
|
-
summary: "Remove a chat ban for a user.",
|
|
3001
|
-
args: ["eventId: target event.", "uid: Firebase uid to unban.", "confirm: must be true."],
|
|
3002
|
-
returns: "eventId, targetUid, and unbanned=true.",
|
|
3003
|
-
examples: ["Use when a user was banned by mistake or the organizer wants to restore event chat access."],
|
|
3004
|
-
}),
|
|
3005
|
-
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unban."), confirm: z.boolean().optional().describe("Required true for unban.") },
|
|
3006
|
-
outputSchema: basicEventOutputSchema,
|
|
3007
|
-
annotations: idempotentWriteTool,
|
|
3008
|
-
}, async ({ eventId, uid: targetUid, confirm }) => {
|
|
3009
|
-
const actor = await authed(["chat:moderate"]);
|
|
3010
|
-
await readEvent(eventId, actor.uid, "moderate");
|
|
3011
|
-
requireConfirm(confirm, "oko_unban_user", { eventId, targetUid });
|
|
3012
|
-
await remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`));
|
|
3013
|
-
return textResult({ eventId, targetUid, unbanned: true });
|
|
3014
|
-
});
|
|
3015
|
-
server.registerTool("oko_set_chat_settings", {
|
|
3016
|
-
title: "Set chat settings",
|
|
3017
|
-
description: toolDescription({
|
|
3018
|
-
summary: "Set slow mode and automod settings for an event chat.",
|
|
3019
|
-
args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle.", "confirm: must be true."],
|
|
3020
|
-
returns: "eventId and updated RTDB setting paths.",
|
|
3021
|
-
examples: ["Use slowModeMs=5000 to slow busy chats; use automodEnabled=false as an emergency kill switch."],
|
|
3022
|
-
}),
|
|
3023
|
-
inputSchema: {
|
|
3024
|
-
eventId: eventIdField,
|
|
3025
|
-
slowModeMs: z.number().int().min(0).max(60000).optional().describe("Slow mode delay in milliseconds."),
|
|
3026
|
-
automodEnabled: z.boolean().optional().describe("Enable or disable chat automoderation."),
|
|
3027
|
-
confirm: z.boolean().optional().describe("Required true to change live chat settings."),
|
|
3028
|
-
},
|
|
3029
|
-
outputSchema: basicEventOutputSchema,
|
|
3030
|
-
annotations: idempotentWriteTool,
|
|
3031
|
-
}, async ({ eventId, slowModeMs, automodEnabled, confirm }) => {
|
|
3032
|
-
const { uid } = await authed(["chat:moderate"]);
|
|
3033
|
-
await readEvent(eventId, uid, "moderate");
|
|
3034
|
-
requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null });
|
|
3035
|
-
const patch = {};
|
|
3036
|
-
if (slowModeMs !== undefined)
|
|
3037
|
-
patch[`settings/${eventId}/slowModeMs`] = slowModeMs;
|
|
3038
|
-
if (automodEnabled !== undefined)
|
|
3039
|
-
patch[`settings/${eventId}/automodEnabled`] = automodEnabled;
|
|
3040
|
-
await update(ref(chatRtdb), patch);
|
|
3041
|
-
return textResult({ eventId, updated: Object.keys(patch) });
|
|
3042
|
-
});
|
|
3043
|
-
server.registerTool("oko_list_leads", {
|
|
3044
|
-
title: "List form leads",
|
|
3045
|
-
description: toolDescription({
|
|
3046
|
-
summary: "Read paginated lead form submissions for an event.",
|
|
3047
|
-
args: [
|
|
3048
|
-
"eventId: target event.",
|
|
3049
|
-
"formId: optional lead form id filter.",
|
|
3050
|
-
"csv: return CSV string instead of leads array.",
|
|
3051
|
-
"limit: max rows, default 50, max 200.",
|
|
3052
|
-
"cursor: lead document id returned as nextCursor by the previous page.",
|
|
3053
|
-
],
|
|
3054
|
-
returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
3055
|
-
examples: ["Use formId to export one form; if Firestore requests an index, create it from the error link and rerun."],
|
|
3056
|
-
}),
|
|
3057
|
-
inputSchema: {
|
|
3058
|
-
eventId: eventIdField,
|
|
3059
|
-
formId: z.string().optional().describe("Optional lead form id to filter submissions."),
|
|
3060
|
-
csv: z.boolean().default(false).describe("Return CSV string instead of JSON rows."),
|
|
3061
|
-
limit: limitField(MAX_EXPORT_LIMIT, 50),
|
|
3062
|
-
cursor: cursorField,
|
|
3063
|
-
},
|
|
3064
|
-
outputSchema: leadsOutputSchema,
|
|
3065
|
-
annotations: readOnlyTool,
|
|
3066
|
-
}, async ({ eventId, formId, csv, limit: max, cursor }) => {
|
|
3067
|
-
const { uid } = await authed(["leads:read"]);
|
|
3068
|
-
await readEvent(eventId, uid, "analytics");
|
|
3069
|
-
const base = collection(firestore, "events", eventId, "leads");
|
|
3070
|
-
const constraints = formId
|
|
3071
|
-
? [where("formId", "==", formId), orderBy(documentId()), ...(cursor ? [startAfter(cursor)] : []), limit(max + 1)]
|
|
3072
|
-
: [orderBy(documentId()), ...(cursor ? [startAfter(cursor)] : []), limit(max + 1)];
|
|
3073
|
-
const snap = await getDocs(query(base, ...constraints));
|
|
3074
|
-
const page = snap.docs.slice(0, max);
|
|
3075
|
-
const leads = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
3076
|
-
const hasMore = snap.docs.length > max;
|
|
3077
|
-
const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
|
|
3078
|
-
const meta = cursorMeta(leads.length, max, hasMore, nextCursor);
|
|
3079
|
-
if (csv) {
|
|
3080
|
-
const text = csvRows(["id", "formId", "source", "submittedAt", "valuesJson"], leads.map((lead) => [lead.id, lead.formId, lead.source, lead.submittedAt, JSON.stringify(lead.values ?? {})]));
|
|
3081
|
-
return textResult({ csv: text, ...meta });
|
|
3082
|
-
}
|
|
3083
|
-
return textResult({ leads, ...meta });
|
|
3084
|
-
});
|
|
3085
|
-
server.registerTool("oko_delete_lead", {
|
|
3086
|
-
title: "Delete form lead",
|
|
3087
|
-
description: toolDescription({
|
|
3088
|
-
summary: "Delete one lead/form submission from an event.",
|
|
3089
|
-
args: ["eventId: event id.", "leadId: events/{eventId}/leads document id.", "confirm: must be true."],
|
|
3090
|
-
returns: "eventId, leadId, deleted=true, and a small preview of the deleted lead.",
|
|
3091
|
-
examples: ["Use after listing leads and confirming the exact submission to remove."],
|
|
3092
|
-
notes: ["Destructive. Requires setup:write in addition to leads:read so read-only keys cannot delete leads."],
|
|
3093
|
-
}),
|
|
3094
|
-
inputSchema: {
|
|
3095
|
-
eventId: eventIdField,
|
|
3096
|
-
leadId: z.string().min(1).max(160).describe("Lead document id under events/{eventId}/leads."),
|
|
3097
|
-
confirm: z.boolean().optional().describe("Required true for lead deletion."),
|
|
3098
|
-
},
|
|
3099
|
-
outputSchema: deleteLeadOutputSchema,
|
|
3100
|
-
annotations: destructiveTool,
|
|
3101
|
-
}, async ({ eventId, leadId, confirm }) => {
|
|
3102
|
-
const { uid } = await authed(["leads:read", "setup:write"]);
|
|
3103
|
-
await readEvent(eventId, uid, "edit");
|
|
3104
|
-
const leadRef = doc(firestore, "events", eventId, "leads", leadId);
|
|
3105
|
-
const leadSnap = await getDoc(leadRef);
|
|
3106
|
-
if (!leadSnap.exists())
|
|
3107
|
-
throw new Error("Lead not found.");
|
|
3108
|
-
const lead = serialize(leadSnap.data());
|
|
3109
|
-
requireConfirm(confirm, "oko_delete_lead", { eventId, leadId, formId: lead.formId, source: lead.source, submittedAt: lead.submittedAt });
|
|
3110
|
-
await deleteDoc(leadRef);
|
|
3111
|
-
return textResult({ eventId, leadId, deleted: true, leadPreview: lead });
|
|
3112
|
-
});
|
|
3113
|
-
server.registerTool("oko_list_notifications", {
|
|
3114
|
-
title: "List notifications",
|
|
3115
|
-
description: toolDescription({
|
|
3116
|
-
summary: "List in-app notifications for the authenticated organizer.",
|
|
3117
|
-
args: [
|
|
3118
|
-
"unreadOnly: only unread notifications, default false.",
|
|
3119
|
-
"limit: max notifications, default 20, max 100.",
|
|
3120
|
-
"cursor: createdAt cursor returned as nextCursor by the previous page.",
|
|
3121
|
-
],
|
|
3122
|
-
returns: "notifications plus unreadCountOnPage and cursor pagination metadata.",
|
|
3123
|
-
examples: ["Use when the organizer asks what changed in OKO or what notifications are waiting."],
|
|
3124
|
-
}),
|
|
3125
|
-
inputSchema: {
|
|
3126
|
-
unreadOnly: z.boolean().default(false).describe("Return only unread notifications."),
|
|
3127
|
-
limit: limitField(100, 20),
|
|
3128
|
-
cursor: cursorField,
|
|
3129
|
-
},
|
|
3130
|
-
outputSchema: notificationsOutputSchema,
|
|
3131
|
-
annotations: readOnlyTool,
|
|
3132
|
-
}, async ({ unreadOnly, limit: max, cursor }) => {
|
|
3133
|
-
const { uid } = await authed(["workspace:read"]);
|
|
3134
|
-
const fetchLimit = unreadOnly ? Math.min(max * 5 + 1, 501) : max + 1;
|
|
3135
|
-
const constraints = [
|
|
3136
|
-
orderBy("createdAt", "desc"),
|
|
3137
|
-
limit(fetchLimit),
|
|
3138
|
-
];
|
|
3139
|
-
if (cursor)
|
|
3140
|
-
constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
|
|
3141
|
-
const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), ...constraints));
|
|
3142
|
-
const matchingDocs = unreadOnly ? snap.docs.filter((item) => item.data().read !== true) : snap.docs;
|
|
3143
|
-
const { page, hasMore: hasMoreInMatches } = pickPage(matchingDocs, max);
|
|
3144
|
-
const hasMore = hasMoreInMatches || snap.docs.length >= fetchLimit;
|
|
3145
|
-
const cursorSource = hasMoreInMatches ? page.at(-1) : snap.docs.at(-1);
|
|
3146
|
-
const notifications = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
3147
|
-
const nextCursor = hasMore ? timestampCursorFrom(cursorSource?.data().createdAt) : null;
|
|
3148
|
-
return textResult({
|
|
3149
|
-
notifications,
|
|
3150
|
-
unreadCountOnPage: notifications.filter((item) => item.read !== true).length,
|
|
3151
|
-
...cursorMeta(notifications.length, max, hasMore, nextCursor),
|
|
3152
|
-
});
|
|
3153
|
-
});
|
|
3154
|
-
server.registerTool("oko_mark_notifications_read", {
|
|
3155
|
-
title: "Mark notifications read",
|
|
3156
|
-
description: toolDescription({
|
|
3157
|
-
summary: "Mark own OKO in-app notifications as read.",
|
|
3158
|
-
args: [
|
|
3159
|
-
"notificationIds: optional explicit notification ids, max 100.",
|
|
3160
|
-
"allUnread: true to mark unread notifications on the first page, max controlled by limit.",
|
|
3161
|
-
"limit: when allUnread=true, max unread notifications to mark, default 100, max 200.",
|
|
3162
|
-
],
|
|
3163
|
-
returns: "markedRead count and notification ids updated.",
|
|
3164
|
-
examples: ["Use after oko_list_notifications when the user asks to clear the bell/unread state."],
|
|
3165
|
-
notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications."],
|
|
3166
|
-
}),
|
|
3167
|
-
inputSchema: {
|
|
3168
|
-
notificationIds: z.array(z.string().min(1).max(160)).max(100).optional().describe("Specific notification document ids."),
|
|
3169
|
-
allUnread: z.boolean().default(false).describe("Mark unread notifications as read."),
|
|
3170
|
-
limit: limitField(200, 100),
|
|
3171
|
-
},
|
|
3172
|
-
outputSchema: notificationsMarkOutputSchema,
|
|
3173
|
-
annotations: idempotentWriteTool,
|
|
3174
|
-
}, async ({ notificationIds, allUnread, limit: max }) => {
|
|
3175
|
-
const { uid } = await authed(["workspace:read"]);
|
|
3176
|
-
if (Boolean(notificationIds?.length) === allUnread) {
|
|
3177
|
-
throw new Error("Provide either notificationIds or allUnread=true.");
|
|
3178
|
-
}
|
|
3179
|
-
let ids = [...new Set(notificationIds ?? [])];
|
|
3180
|
-
if (allUnread) {
|
|
3181
|
-
const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), where("read", "==", false), limit(max)));
|
|
3182
|
-
ids = snap.docs.map((item) => item.id);
|
|
3183
|
-
}
|
|
3184
|
-
await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
|
|
3185
|
-
return textResult({ markedRead: ids.length, notificationIds: ids });
|
|
3186
|
-
});
|
|
3187
|
-
server.registerTool("oko_create_telegram_link", {
|
|
3188
|
-
title: "Link personal Telegram",
|
|
3189
|
-
description: toolDescription({
|
|
3190
|
-
summary: "Create a short-lived Telegram deep link that binds the organizer's personal Telegram identity to OKO.",
|
|
3191
|
-
args: [],
|
|
3192
|
-
returns: "@OKOStreamBot link and expiry in seconds.",
|
|
3193
|
-
examples: ["Open the returned link in the organizer's Telegram account before claiming a channel."],
|
|
3194
|
-
notes: ["The link expires in 15 minutes and can be used only once. Opening it is a user action outside MCP."],
|
|
3195
|
-
}),
|
|
3196
|
-
inputSchema: {},
|
|
3197
|
-
outputSchema: z.object({ url: z.string().url(), expiresIn: z.number().int().positive() }).passthrough(),
|
|
3198
|
-
annotations: writeTool,
|
|
3199
|
-
}, async () => {
|
|
3200
|
-
await authed(["telegram:write"]);
|
|
3201
|
-
const createLink = httpsCallable(functions, "createTelegramLink");
|
|
3202
|
-
return textResult((await createLink({})).data);
|
|
3203
|
-
});
|
|
3204
|
-
server.registerTool("oko_list_telegram_channels", {
|
|
3205
|
-
title: "List Telegram channels",
|
|
3206
|
-
description: toolDescription({
|
|
3207
|
-
summary: "List Telegram channels available to the authenticated organizer.",
|
|
3208
|
-
args: [
|
|
3209
|
-
"eventId: optional event; when provided, include channels explicitly shared to that event's workspace.",
|
|
3210
|
-
"includeWorkspaceChannels: deprecated compatibility field; eventId is required to resolve workspace sharing safely.",
|
|
3211
|
-
],
|
|
3212
|
-
returns: "canonical channels with identity, workspace sharing, ownership, subscriber count, and connection health.",
|
|
3213
|
-
examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
|
|
3214
|
-
}),
|
|
3215
|
-
inputSchema: {
|
|
3216
|
-
eventId: eventIdField.optional().describe("Optional event used to include channels shared to its workspace."),
|
|
3217
|
-
includeWorkspaceChannels: z.boolean().optional().describe("Deprecated; retained for client compatibility."),
|
|
3218
|
-
},
|
|
3219
|
-
outputSchema: telegramChannelsOutputSchema,
|
|
3220
|
-
annotations: readOnlyTool,
|
|
3221
|
-
}, async ({ eventId }) => {
|
|
3222
|
-
await authed(["telegram:write"]);
|
|
3223
|
-
const listChannels = httpsCallable(functions, "listTelegramChannels");
|
|
3224
|
-
const result = await listChannels(eventId ? { eventId } : {});
|
|
3225
|
-
const parsed = z.array(canonicalTelegramChannelSchema).safeParse(result.data.channels);
|
|
3226
|
-
if (!parsed.success)
|
|
3227
|
-
throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
|
|
3228
|
-
const channels = parsed.data.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
|
|
3229
|
-
return textResult({ channels, count: channels.length });
|
|
3230
|
-
});
|
|
3231
|
-
server.registerTool("oko_claim_telegram_channel", {
|
|
3232
|
-
title: "Claim Telegram channel",
|
|
3233
|
-
description: toolDescription({
|
|
3234
|
-
summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
|
|
3235
|
-
args: [
|
|
3236
|
-
"claimCode: code posted by the bot after it was added to a channel.",
|
|
3237
|
-
"workspaceIds: optional owned workspaces to expose this channel to after claim.",
|
|
3238
|
-
"confirm: must be true.",
|
|
3239
|
-
],
|
|
3240
|
-
returns: "claimed channel and workspace ids updated.",
|
|
3241
|
-
examples: ["Link personal Telegram first, add @OKOStreamBot as channel administrator, copy the claim code, then call this tool."],
|
|
3242
|
-
notes: ["The linked personal Telegram user must be a channel administrator. Workspace sharing is server-validated."],
|
|
3243
|
-
}),
|
|
3244
|
-
inputSchema: {
|
|
3245
|
-
claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
|
|
3246
|
-
workspaceIds: z.array(z.string().min(1)).max(20).default([]).describe("Optional workspace ids to attach the channel to."),
|
|
3247
|
-
confirm: z.boolean().optional().describe("Required true to claim and attach the channel."),
|
|
3248
|
-
},
|
|
3249
|
-
outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
|
|
3250
|
-
annotations: writeTool,
|
|
3251
|
-
}, async ({ claimCode, workspaceIds, confirm }) => {
|
|
3252
|
-
await authed(["telegram:write"]);
|
|
3253
|
-
requireConfirm(confirm, "oko_claim_telegram_channel", { workspaceIds: workspaceIds.join(",") });
|
|
3254
|
-
const claimChannel = httpsCallable(functions, "claimTelegramChannel");
|
|
3255
|
-
const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
|
|
3256
|
-
const data = serialize(result.data);
|
|
3257
|
-
const channelRaw = data.channel && typeof data.channel === "object" ? data.channel : {};
|
|
3258
|
-
const parsed = telegramChannelSchema.safeParse(channelRaw);
|
|
3259
|
-
if (!parsed.success)
|
|
3260
|
-
throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
|
|
3261
|
-
if (workspaceIds.length > 0) {
|
|
3262
|
-
const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
|
|
3263
|
-
await setWorkspaces({ channelId: parsed.data.chatId, workspaceIds });
|
|
3264
|
-
}
|
|
3265
|
-
return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated: workspaceIds });
|
|
3266
|
-
});
|
|
3267
|
-
server.registerTool("oko_set_telegram_channel_workspaces", {
|
|
3268
|
-
title: "Share Telegram channel to workspaces",
|
|
3269
|
-
description: toolDescription({
|
|
3270
|
-
summary: "Replace the owned Telegram channel's explicit OKO workspace sharing list.",
|
|
3271
|
-
args: ["channelId: owned Telegram channel id.", "workspaceIds: complete replacement list, up to 50.", "confirm: must be true."],
|
|
3272
|
-
returns: "channel id and the applied workspace ids.",
|
|
3273
|
-
examples: ["Use an empty workspaceIds list to make the channel private to its owner again."],
|
|
3274
|
-
notes: ["This replaces, rather than appends to, the current sharing list. Every workspace must be owned by the organizer."],
|
|
3275
|
-
}),
|
|
3276
|
-
inputSchema: {
|
|
3277
|
-
channelId: z.number().int().describe("Owned Telegram channel id."),
|
|
3278
|
-
workspaceIds: z.array(z.string().min(1).max(128)).max(50).describe("Complete replacement workspace sharing list."),
|
|
3279
|
-
confirm: z.boolean().optional().describe("Required true to change workspace sharing."),
|
|
3280
|
-
},
|
|
3281
|
-
outputSchema: z.object({ success: z.boolean(), channelId: z.number().int(), workspaceIds: z.array(z.string()) }).passthrough(),
|
|
3282
|
-
annotations: writeTool,
|
|
3283
|
-
}, async ({ channelId, workspaceIds, confirm }) => {
|
|
3284
|
-
await authed(["telegram:write"]);
|
|
3285
|
-
requireConfirm(confirm, "oko_set_telegram_channel_workspaces", { channelId, workspaceIds: workspaceIds.join(",") });
|
|
3286
|
-
const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
|
|
3287
|
-
return textResult((await setWorkspaces({ channelId, workspaceIds })).data);
|
|
3288
|
-
});
|
|
3289
|
-
server.registerTool("oko_disconnect_telegram_channel", {
|
|
3290
|
-
title: "Disconnect Telegram channel",
|
|
3291
|
-
description: toolDescription({
|
|
3292
|
-
summary: "Disconnect an owned Telegram channel from OKO and clear all workspace sharing.",
|
|
3293
|
-
args: ["channelId: owned Telegram channel id.", "confirm: must be true."],
|
|
3294
|
-
returns: "disconnected channel id.",
|
|
3295
|
-
examples: ["Use only after showing the exact channel selected from oko_list_telegram_channels."],
|
|
3296
|
-
notes: ["Existing sent Telegram messages are not deleted. The channel must be claimed again before future publication."],
|
|
3297
|
-
}),
|
|
3298
|
-
inputSchema: {
|
|
3299
|
-
channelId: z.number().int().describe("Owned Telegram channel id."),
|
|
3300
|
-
confirm: z.boolean().optional().describe("Required true to disconnect the channel."),
|
|
3301
|
-
},
|
|
3302
|
-
outputSchema: z.object({ success: z.boolean(), channelId: z.number().int() }).passthrough(),
|
|
3303
|
-
annotations: destructiveTool,
|
|
3304
|
-
}, async ({ channelId, confirm }) => {
|
|
3305
|
-
await authed(["telegram:write"]);
|
|
3306
|
-
requireConfirm(confirm, "oko_disconnect_telegram_channel", { channelId });
|
|
3307
|
-
const disconnect = httpsCallable(functions, "disconnectTelegramChannel");
|
|
3308
|
-
return textResult((await disconnect({ channelId })).data);
|
|
3309
|
-
});
|
|
3310
|
-
server.registerTool("oko_list_telegram_posts", {
|
|
3311
|
-
title: "List Telegram posts",
|
|
3312
|
-
description: toolDescription({
|
|
3313
|
-
summary: "List Telegram notification posts for an event.",
|
|
3314
|
-
args: [
|
|
3315
|
-
"eventId: event whose Telegram posts to list.",
|
|
3316
|
-
"status: optional client-side status filter.",
|
|
3317
|
-
"limit: max posts, default 30, max 100.",
|
|
3318
|
-
"cursor: createdAt cursor returned by previous page.",
|
|
3319
|
-
],
|
|
3320
|
-
returns: "posts plus cursor pagination metadata.",
|
|
3321
|
-
examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."],
|
|
3322
|
-
}),
|
|
3323
|
-
inputSchema: {
|
|
3324
|
-
eventId: eventIdField,
|
|
3325
|
-
status: z.enum(TELEGRAM_POST_STATUSES).optional().describe("Optional post status filter."),
|
|
3326
|
-
limit: limitField(),
|
|
3327
|
-
cursor: cursorField,
|
|
3328
|
-
},
|
|
3329
|
-
outputSchema: telegramPostsOutputSchema,
|
|
3330
|
-
annotations: readOnlyTool,
|
|
3331
|
-
}, async ({ eventId, status, limit: max, cursor }) => {
|
|
3332
|
-
const { uid } = await authed(["telegram:write"]);
|
|
3333
|
-
await readEvent(eventId, uid, "notify");
|
|
3334
|
-
const constraints = [
|
|
3335
|
-
where("eventId", "==", eventId),
|
|
3336
|
-
orderBy("createdAt", "desc"),
|
|
3337
|
-
limit(max + 1),
|
|
3338
|
-
];
|
|
3339
|
-
if (cursor)
|
|
3340
|
-
constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
|
|
3341
|
-
const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
|
|
3342
|
-
const { page, hasMore } = pickPage(snap.docs, max);
|
|
3343
|
-
let posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
3344
|
-
if (status)
|
|
3345
|
-
posts = posts.filter((post) => post.status === status);
|
|
3346
|
-
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
|
|
3347
|
-
return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
|
|
3348
|
-
});
|
|
3349
|
-
server.registerTool("oko_cancel_telegram_post", {
|
|
3350
|
-
title: "Cancel Telegram post",
|
|
3351
|
-
description: toolDescription({
|
|
3352
|
-
summary: "Delete a draft/scheduled/failed Telegram post record before it is sent.",
|
|
3353
|
-
args: ["postId: telegramPosts document id.", "confirm: must be true."],
|
|
3354
|
-
returns: "postId, eventId, status, and canceled=true.",
|
|
3355
|
-
examples: ["Use for scheduled posts that should not be sent."],
|
|
3356
|
-
notes: ["Already sent Telegram messages cannot be unsent through OKO; this tool refuses status=sent."],
|
|
3357
|
-
}),
|
|
3358
|
-
inputSchema: { postId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for cancellation.") },
|
|
3359
|
-
outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
|
|
3360
|
-
annotations: destructiveTool,
|
|
3361
|
-
}, async ({ postId, confirm }) => {
|
|
3362
|
-
const { uid } = await authed(["telegram:write"]);
|
|
3363
|
-
const postRef = doc(firestore, "telegramPosts", postId);
|
|
3364
|
-
const postSnap = await getDoc(postRef);
|
|
3365
|
-
if (!postSnap.exists())
|
|
3366
|
-
throw new Error("Telegram post not found.");
|
|
3367
|
-
const post = postSnap.data();
|
|
3368
|
-
if (typeof post.eventId !== "string")
|
|
3369
|
-
throw new Error("Telegram post has no valid event.");
|
|
3370
|
-
await readEvent(post.eventId, uid, "notify");
|
|
3371
|
-
if (!["draft", "pending", "scheduled", "failed"].includes(safeString(post.status))) {
|
|
3372
|
-
throw new Error("Only draft, pending, scheduled, or failed Telegram posts can be canceled. A sent or delivery-unknown post must be reviewed manually.");
|
|
3373
|
-
}
|
|
3374
|
-
requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
|
|
3375
|
-
await deleteDoc(postRef);
|
|
3376
|
-
return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
|
|
3377
|
-
});
|
|
3378
|
-
server.registerTool("oko_publish_telegram_post", {
|
|
3379
|
-
title: "Publish Telegram post",
|
|
3380
|
-
description: toolDescription({
|
|
3381
|
-
summary: "Create and optionally send or schedule a Telegram post for an OKO event.",
|
|
3382
|
-
args: [
|
|
3383
|
-
"eventId: event used for access checks and default button URL.",
|
|
3384
|
-
"channelId: Telegram channel id configured for the organizer.",
|
|
3385
|
-
"caption: post caption, max 4096 chars.",
|
|
3386
|
-
"scheduleAt: optional ISO datetime; omitted means immediate send.",
|
|
3387
|
-
"confirm: must be true for both immediate and scheduled publication.",
|
|
3388
|
-
],
|
|
3389
|
-
returns: "postId, scheduled flag, and sent flag.",
|
|
3390
|
-
examples: ["Use scheduleAt for scheduled posts; for immediate send, show the exact channel/caption and ask for confirmation."],
|
|
3391
|
-
notes: ["External side effect: immediate sends can publish to real Telegram channels."],
|
|
3392
|
-
}),
|
|
3393
|
-
inputSchema: {
|
|
3394
|
-
eventId: eventIdField,
|
|
3395
|
-
channelId: z.number().int().describe("Telegram channel id."),
|
|
3396
|
-
caption: z.string().min(1).max(4096).describe("Telegram post caption."),
|
|
3397
|
-
imageUrl: z.string().nullable().optional().describe("Optional image URL."),
|
|
3398
|
-
buttonLabel: z.string().max(64).nullable().optional().describe("Optional inline button label."),
|
|
3399
|
-
buttonUrl: z.string().max(1000).nullable().optional().describe("Optional inline button URL; defaults to event URL when buttonLabel is set."),
|
|
3400
|
-
scheduleAt: z.string().datetime().nullable().optional().describe("Optional ISO datetime for scheduled send."),
|
|
3401
|
-
confirm: z.boolean().optional().describe("Required true to create the external Telegram side effect."),
|
|
3402
|
-
},
|
|
3403
|
-
outputSchema: z.object({ postId: z.string(), scheduled: z.boolean(), sent: z.boolean() }).passthrough(),
|
|
3404
|
-
annotations: writeTool,
|
|
3405
|
-
}, async (args) => {
|
|
3406
|
-
const { uid } = await authed(["telegram:write"]);
|
|
3407
|
-
await readEvent(args.eventId, uid, "notify");
|
|
3408
|
-
const listChannels = httpsCallable(functions, "listTelegramChannels");
|
|
3409
|
-
const availableResult = await listChannels({ eventId: args.eventId });
|
|
3410
|
-
const available = z.array(canonicalTelegramChannelSchema).safeParse(availableResult.data.channels);
|
|
3411
|
-
if (!available.success)
|
|
3412
|
-
throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
|
|
3413
|
-
const channel = available.data.find((item) => item.chatId === args.channelId);
|
|
3414
|
-
if (!channel)
|
|
3415
|
-
throw new Error("Telegram channel is not available for this event. List channels with this eventId and choose a returned channel.");
|
|
3416
|
-
const scheduled = !!args.scheduleAt;
|
|
3417
|
-
requireConfirm(args.confirm, "oko_publish_telegram_post", {
|
|
3418
|
-
eventId: args.eventId,
|
|
3419
|
-
channelId: args.channelId,
|
|
3420
|
-
channelTitle: channel.title ?? "",
|
|
3421
|
-
caption: args.caption,
|
|
3422
|
-
scheduleAt: args.scheduleAt ?? null,
|
|
3423
|
-
});
|
|
3424
|
-
const postData = {
|
|
3425
|
-
eventId: args.eventId,
|
|
3426
|
-
ownerId: uid,
|
|
3427
|
-
ownerEmail: "",
|
|
3428
|
-
channelId: args.channelId,
|
|
3429
|
-
channelKey: String(args.channelId),
|
|
3430
|
-
caption: args.caption.trim(),
|
|
3431
|
-
imageUrl: args.imageUrl?.trim() || null,
|
|
3432
|
-
buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: args.buttonUrl?.trim() || eventUrl(args.eventId) }] : [],
|
|
3433
|
-
status: scheduled ? "scheduled" : "draft",
|
|
3434
|
-
scheduledAt: args.scheduleAt ? Timestamp.fromDate(new Date(args.scheduleAt)) : null,
|
|
3435
|
-
sentAt: null,
|
|
3436
|
-
telegramMessageId: null,
|
|
3437
|
-
error: null,
|
|
3438
|
-
createdAt: serverTimestamp(),
|
|
3439
|
-
updatedAt: serverTimestamp(),
|
|
3440
|
-
};
|
|
3441
|
-
const postRef = await addDoc(collection(firestore, "telegramPosts"), postData);
|
|
3442
|
-
if (!scheduled) {
|
|
3443
|
-
const sendPost = httpsCallable(functions, "sendTelegramPost");
|
|
3444
|
-
await sendPost({ postId: postRef.id });
|
|
3445
|
-
}
|
|
3446
|
-
return textResult({ postId: postRef.id, scheduled, sent: !scheduled });
|
|
3447
|
-
});
|
|
3448
|
-
server.registerTool("oko_create_workspace_invite", {
|
|
3449
|
-
title: "Create workspace invite",
|
|
3450
|
-
description: toolDescription({
|
|
3451
|
-
summary: "Create a workspace invite for a known OKO workspace.",
|
|
3452
|
-
args: [
|
|
3453
|
-
"workspaceId: workspace owned by the authenticated organizer.",
|
|
3454
|
-
"code: optional invite code, 4-64 chars; omitted means generated.",
|
|
3455
|
-
"email: optional intended recipient email.",
|
|
3456
|
-
"permissions: optional whitelist of workspace permission booleans.",
|
|
3457
|
-
"confirm: must be true.",
|
|
3458
|
-
],
|
|
3459
|
-
returns: "workspaceId, invite code, inviteUrl, and created=true.",
|
|
3460
|
-
examples: ["Use only after confirming the recipient and permission set with the workspace owner."],
|
|
3461
|
-
}),
|
|
3462
|
-
inputSchema: {
|
|
3463
|
-
workspaceId: z.string().min(1),
|
|
3464
|
-
code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
|
|
3465
|
-
email: z.string().email().optional(),
|
|
3466
|
-
comment: z.string().max(500).default(""),
|
|
3467
|
-
permissions: workspacePermissionsSchema,
|
|
3468
|
-
confirm: z.boolean().optional(),
|
|
3469
|
-
},
|
|
3470
|
-
outputSchema: z.object({ workspaceId: z.string(), code: z.string(), inviteUrl: z.string().url(), created: z.boolean() }).passthrough(),
|
|
3471
|
-
annotations: writeTool,
|
|
3472
|
-
}, async (args) => {
|
|
3473
|
-
const { uid } = await authed(["workspace:write"]);
|
|
3474
|
-
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
3475
|
-
requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
|
|
3476
|
-
const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
|
|
3477
|
-
const inviteUrl = `https://okostream.app/invite/${code}`;
|
|
3478
|
-
await setDoc(doc(firestore, "workspaceInvites", code), {
|
|
3479
|
-
workspaceId: args.workspaceId,
|
|
3480
|
-
workspaceName: safeString(workspace.name, "Workspace"),
|
|
3481
|
-
email: args.email ?? "",
|
|
3482
|
-
comment: args.comment,
|
|
3483
|
-
permissions: normalizeWorkspacePermissions(args.permissions),
|
|
3484
|
-
createdBy: uid,
|
|
3485
|
-
createdAt: serverTimestamp(),
|
|
3486
|
-
claimed: false,
|
|
3487
|
-
claimedBy: null,
|
|
3488
|
-
});
|
|
3489
|
-
return textResult({ workspaceId: args.workspaceId, code, inviteUrl, created: true });
|
|
3490
|
-
});
|
|
22
|
+
// Обёртка ставится первой: она подменяет server.registerTool, и всё
|
|
23
|
+
// зарегистрированное ПОСЛЕ получает аудит записей и человеческий ответ
|
|
24
|
+
// на отказ авторизации.
|
|
25
|
+
installToolInstrumentation(server);
|
|
26
|
+
// Порядок сохранён с дораборного файла — он задаёт порядок в tools/list.
|
|
27
|
+
registerAccountTools(server);
|
|
28
|
+
registerWorkspaceTools(server);
|
|
29
|
+
registerEventTools(server);
|
|
30
|
+
registerImageTools(server);
|
|
31
|
+
registerLifecycleTools(server);
|
|
32
|
+
registerLiveTools(server);
|
|
33
|
+
registerSessionTools(server);
|
|
34
|
+
registerChatTools(server);
|
|
35
|
+
registerLeadTools(server);
|
|
36
|
+
registerNotificationTools(server);
|
|
37
|
+
registerTelegramTools(server);
|
|
3491
38
|
}
|