@okostream/mcp 0.11.1 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ // Каналы Telegram и анонсы событий.
2
+ //
3
+ // Публикация — внешнее действие, которое нельзя отозвать, поэтому требует
4
+ // явного confirm. Число подписчиков и состояние бота отдаёт CF40 с
5
+ // десятиминутным кешем; при сбое Telegram она возвращает null и признак
6
+ // «неизвестно», а не выдуманное число.
7
+ import { z } from "zod";
8
+ import { requireConfirm, safeString, serialize, textResult } from "../utils.js";
9
+ import { authed, eventUrl } from "./context.js";
10
+ import { cursorField, destructiveTool, eventIdField, limitField, readOnlyTool, TELEGRAM_POST_STATUSES, telegramChannelsOutputSchema, telegramPostsOutputSchema, toolDescription, unknownRecordSchema, writeTool } from "./schemas.js";
11
+ import { addDoc, collection, deleteDoc, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, startAfter, Timestamp, where } from "firebase/firestore";
12
+ import { canonicalTelegramChannelSchema, telegramChannelSchema } from "../telegramChannelSchema.js";
13
+ import { cursorMeta, parseTimestampCursor, pickPage, timestampCursorFrom } from "./formatting.js";
14
+ import { firestore, functions } from "../firebase.js";
15
+ import { httpsCallable } from "firebase/functions";
16
+ import { readEvent } from "./eventAccess.js";
17
+ export function registerTelegramTools(server) {
18
+ server.registerTool("oko_create_telegram_link", {
19
+ title: "Link personal Telegram",
20
+ description: toolDescription({
21
+ summary: "Create a short-lived Telegram deep link that binds the organizer's personal Telegram identity to OKO.",
22
+ args: [],
23
+ returns: "@OKOStreamBot link and expiry in seconds.",
24
+ examples: ["Open the returned link in the organizer's Telegram account before claiming a channel."],
25
+ notes: ["The link expires in 15 minutes and can be used only once. Opening it is a user action outside MCP."],
26
+ }),
27
+ inputSchema: {},
28
+ outputSchema: z.object({ url: z.string().url(), expiresIn: z.number().int().positive() }).passthrough(),
29
+ annotations: writeTool,
30
+ }, async () => {
31
+ await authed(["telegram:write"]);
32
+ const createLink = httpsCallable(functions, "createTelegramLink");
33
+ return textResult((await createLink({})).data);
34
+ });
35
+ server.registerTool("oko_list_telegram_channels", {
36
+ title: "List Telegram channels",
37
+ description: toolDescription({
38
+ summary: "List Telegram channels available to the authenticated organizer.",
39
+ args: [
40
+ "eventId: optional event; when provided, include channels explicitly shared to that event's workspace.",
41
+ "includeWorkspaceChannels: deprecated compatibility field; eventId is required to resolve workspace sharing safely.",
42
+ ],
43
+ returns: "canonical channels with identity, workspace sharing, ownership, subscriber count, and connection health.",
44
+ examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
45
+ }),
46
+ inputSchema: {
47
+ eventId: eventIdField.optional().describe("Optional event used to include channels shared to its workspace."),
48
+ includeWorkspaceChannels: z.boolean().optional().describe("Deprecated; retained for client compatibility."),
49
+ },
50
+ outputSchema: telegramChannelsOutputSchema,
51
+ annotations: readOnlyTool,
52
+ }, async ({ eventId }) => {
53
+ await authed(["telegram:write"]);
54
+ const listChannels = httpsCallable(functions, "listTelegramChannels");
55
+ const result = await listChannels(eventId ? { eventId } : {});
56
+ const parsed = z.array(canonicalTelegramChannelSchema).safeParse(result.data.channels);
57
+ if (!parsed.success)
58
+ throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
59
+ const channels = parsed.data.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
60
+ return textResult({ channels, count: channels.length });
61
+ });
62
+ server.registerTool("oko_claim_telegram_channel", {
63
+ title: "Claim Telegram channel",
64
+ description: toolDescription({
65
+ summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
66
+ args: [
67
+ "claimCode: code posted by the bot after it was added to a channel.",
68
+ "workspaceIds: optional owned workspaces to expose this channel to after claim.",
69
+ "confirm: must be true.",
70
+ ],
71
+ returns: "claimed channel and workspace ids updated.",
72
+ examples: ["Link personal Telegram first, add @OKOStreamBot as channel administrator, copy the claim code, then call this tool."],
73
+ notes: ["The linked personal Telegram user must be a channel administrator. Workspace sharing is server-validated."],
74
+ }),
75
+ inputSchema: {
76
+ claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
77
+ workspaceIds: z.array(z.string().min(1)).max(20).default([]).describe("Optional workspace ids to attach the channel to."),
78
+ confirm: z.boolean().optional().describe("Required true to claim and attach the channel."),
79
+ },
80
+ outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
81
+ annotations: writeTool,
82
+ }, async ({ claimCode, workspaceIds, confirm }) => {
83
+ await authed(["telegram:write"]);
84
+ requireConfirm(confirm, "oko_claim_telegram_channel", { workspaceIds: workspaceIds.join(",") });
85
+ const claimChannel = httpsCallable(functions, "claimTelegramChannel");
86
+ const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
87
+ const data = serialize(result.data);
88
+ const channelRaw = data.channel && typeof data.channel === "object" ? data.channel : {};
89
+ const parsed = telegramChannelSchema.safeParse(channelRaw);
90
+ if (!parsed.success)
91
+ throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
92
+ if (workspaceIds.length > 0) {
93
+ const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
94
+ await setWorkspaces({ channelId: parsed.data.chatId, workspaceIds });
95
+ }
96
+ return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated: workspaceIds });
97
+ });
98
+ server.registerTool("oko_set_telegram_channel_workspaces", {
99
+ title: "Share Telegram channel to workspaces",
100
+ description: toolDescription({
101
+ summary: "Replace the owned Telegram channel's explicit OKO workspace sharing list.",
102
+ args: ["channelId: owned Telegram channel id.", "workspaceIds: complete replacement list, up to 50.", "confirm: must be true."],
103
+ returns: "channel id and the applied workspace ids.",
104
+ examples: ["Use an empty workspaceIds list to make the channel private to its owner again."],
105
+ notes: ["This replaces, rather than appends to, the current sharing list. Every workspace must be owned by the organizer."],
106
+ }),
107
+ inputSchema: {
108
+ channelId: z.number().int().describe("Owned Telegram channel id."),
109
+ workspaceIds: z.array(z.string().min(1).max(128)).max(50).describe("Complete replacement workspace sharing list."),
110
+ confirm: z.boolean().optional().describe("Required true to change workspace sharing."),
111
+ },
112
+ outputSchema: z.object({ success: z.boolean(), channelId: z.number().int(), workspaceIds: z.array(z.string()) }).passthrough(),
113
+ annotations: writeTool,
114
+ }, async ({ channelId, workspaceIds, confirm }) => {
115
+ await authed(["telegram:write"]);
116
+ requireConfirm(confirm, "oko_set_telegram_channel_workspaces", { channelId, workspaceIds: workspaceIds.join(",") });
117
+ const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
118
+ return textResult((await setWorkspaces({ channelId, workspaceIds })).data);
119
+ });
120
+ server.registerTool("oko_disconnect_telegram_channel", {
121
+ title: "Disconnect Telegram channel",
122
+ description: toolDescription({
123
+ summary: "Disconnect an owned Telegram channel from OKO and clear all workspace sharing.",
124
+ args: ["channelId: owned Telegram channel id.", "confirm: must be true."],
125
+ returns: "disconnected channel id.",
126
+ examples: ["Use only after showing the exact channel selected from oko_list_telegram_channels."],
127
+ notes: ["Existing sent Telegram messages are not deleted. The channel must be claimed again before future publication."],
128
+ }),
129
+ inputSchema: {
130
+ channelId: z.number().int().describe("Owned Telegram channel id."),
131
+ confirm: z.boolean().optional().describe("Required true to disconnect the channel."),
132
+ },
133
+ outputSchema: z.object({ success: z.boolean(), channelId: z.number().int() }).passthrough(),
134
+ annotations: destructiveTool,
135
+ }, async ({ channelId, confirm }) => {
136
+ await authed(["telegram:write"]);
137
+ requireConfirm(confirm, "oko_disconnect_telegram_channel", { channelId });
138
+ const disconnect = httpsCallable(functions, "disconnectTelegramChannel");
139
+ return textResult((await disconnect({ channelId })).data);
140
+ });
141
+ server.registerTool("oko_list_telegram_posts", {
142
+ title: "List Telegram posts",
143
+ description: toolDescription({
144
+ summary: "List Telegram notification posts for an event.",
145
+ args: [
146
+ "eventId: event whose Telegram posts to list.",
147
+ "status: optional client-side status filter.",
148
+ "limit: max posts, default 30, max 100.",
149
+ "cursor: createdAt cursor returned by previous page.",
150
+ ],
151
+ returns: "posts plus cursor pagination metadata.",
152
+ examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."],
153
+ }),
154
+ inputSchema: {
155
+ eventId: eventIdField,
156
+ status: z.enum(TELEGRAM_POST_STATUSES).optional().describe("Optional post status filter."),
157
+ limit: limitField(),
158
+ cursor: cursorField,
159
+ },
160
+ outputSchema: telegramPostsOutputSchema,
161
+ annotations: readOnlyTool,
162
+ }, async ({ eventId, status, limit: max, cursor }) => {
163
+ const { uid } = await authed(["telegram:write"]);
164
+ await readEvent(eventId, uid, "notify");
165
+ const constraints = [
166
+ where("eventId", "==", eventId),
167
+ orderBy("createdAt", "desc"),
168
+ limit(max + 1),
169
+ ];
170
+ if (cursor)
171
+ constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
172
+ const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
173
+ const { page, hasMore } = pickPage(snap.docs, max);
174
+ let posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
175
+ if (status)
176
+ posts = posts.filter((post) => post.status === status);
177
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
178
+ return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
179
+ });
180
+ server.registerTool("oko_cancel_telegram_post", {
181
+ title: "Cancel Telegram post",
182
+ description: toolDescription({
183
+ summary: "Delete a draft/scheduled/failed Telegram post record before it is sent.",
184
+ args: ["postId: telegramPosts document id.", "confirm: must be true."],
185
+ returns: "postId, eventId, status, and canceled=true.",
186
+ examples: ["Use for scheduled posts that should not be sent."],
187
+ notes: ["Already sent Telegram messages cannot be unsent through OKO; this tool refuses status=sent."],
188
+ }),
189
+ inputSchema: { postId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for cancellation.") },
190
+ outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
191
+ annotations: destructiveTool,
192
+ }, async ({ postId, confirm }) => {
193
+ const { uid } = await authed(["telegram:write"]);
194
+ const postRef = doc(firestore, "telegramPosts", postId);
195
+ const postSnap = await getDoc(postRef);
196
+ if (!postSnap.exists())
197
+ throw new Error("Telegram post not found.");
198
+ const post = postSnap.data();
199
+ if (typeof post.eventId !== "string")
200
+ throw new Error("Telegram post has no valid event.");
201
+ await readEvent(post.eventId, uid, "notify");
202
+ if (!["draft", "pending", "scheduled", "failed"].includes(safeString(post.status))) {
203
+ throw new Error("Only draft, pending, scheduled, or failed Telegram posts can be canceled. A sent or delivery-unknown post must be reviewed manually.");
204
+ }
205
+ requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
206
+ await deleteDoc(postRef);
207
+ return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
208
+ });
209
+ server.registerTool("oko_publish_telegram_post", {
210
+ title: "Publish Telegram post",
211
+ description: toolDescription({
212
+ summary: "Create and optionally send or schedule a Telegram post for an OKO event.",
213
+ args: [
214
+ "eventId: event used for access checks and default button URL.",
215
+ "channelId: Telegram channel id configured for the organizer.",
216
+ "caption: post caption, max 4096 chars.",
217
+ "scheduleAt: optional ISO datetime; omitted means immediate send.",
218
+ "confirm: must be true for both immediate and scheduled publication.",
219
+ ],
220
+ returns: "postId, scheduled flag, and sent flag.",
221
+ examples: ["Use scheduleAt for scheduled posts; for immediate send, show the exact channel/caption and ask for confirmation."],
222
+ notes: ["External side effect: immediate sends can publish to real Telegram channels."],
223
+ }),
224
+ inputSchema: {
225
+ eventId: eventIdField,
226
+ channelId: z.number().int().describe("Telegram channel id."),
227
+ caption: z.string().min(1).max(4096).describe("Telegram post caption."),
228
+ imageUrl: z.string().nullable().optional().describe("Optional image URL."),
229
+ buttonLabel: z.string().max(64).nullable().optional().describe("Optional inline button label."),
230
+ buttonUrl: z.string().max(1000).nullable().optional().describe("Optional inline button URL; defaults to event URL when buttonLabel is set."),
231
+ scheduleAt: z.string().datetime().nullable().optional().describe("Optional ISO datetime for scheduled send."),
232
+ confirm: z.boolean().optional().describe("Required true to create the external Telegram side effect."),
233
+ },
234
+ outputSchema: z.object({ postId: z.string(), scheduled: z.boolean(), sent: z.boolean() }).passthrough(),
235
+ annotations: writeTool,
236
+ }, async (args) => {
237
+ const { uid } = await authed(["telegram:write"]);
238
+ await readEvent(args.eventId, uid, "notify");
239
+ const listChannels = httpsCallable(functions, "listTelegramChannels");
240
+ const availableResult = await listChannels({ eventId: args.eventId });
241
+ const available = z.array(canonicalTelegramChannelSchema).safeParse(availableResult.data.channels);
242
+ if (!available.success)
243
+ throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
244
+ const channel = available.data.find((item) => item.chatId === args.channelId);
245
+ if (!channel)
246
+ throw new Error("Telegram channel is not available for this event. List channels with this eventId and choose a returned channel.");
247
+ const scheduled = !!args.scheduleAt;
248
+ requireConfirm(args.confirm, "oko_publish_telegram_post", {
249
+ eventId: args.eventId,
250
+ channelId: args.channelId,
251
+ channelTitle: channel.title ?? "",
252
+ caption: args.caption,
253
+ scheduleAt: args.scheduleAt ?? null,
254
+ });
255
+ const postData = {
256
+ eventId: args.eventId,
257
+ ownerId: uid,
258
+ ownerEmail: "",
259
+ channelId: args.channelId,
260
+ channelKey: String(args.channelId),
261
+ caption: args.caption.trim(),
262
+ imageUrl: args.imageUrl?.trim() || null,
263
+ buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: args.buttonUrl?.trim() || eventUrl(args.eventId) }] : [],
264
+ status: scheduled ? "scheduled" : "draft",
265
+ scheduledAt: args.scheduleAt ? Timestamp.fromDate(new Date(args.scheduleAt)) : null,
266
+ sentAt: null,
267
+ telegramMessageId: null,
268
+ error: null,
269
+ createdAt: serverTimestamp(),
270
+ updatedAt: serverTimestamp(),
271
+ };
272
+ const postRef = await addDoc(collection(firestore, "telegramPosts"), postData);
273
+ if (!scheduled) {
274
+ const sendPost = httpsCallable(functions, "sendTelegramPost");
275
+ await sendPost({ postId: postRef.id });
276
+ }
277
+ return textResult({ postId: postRef.id, scheduled, sent: !scheduled });
278
+ });
279
+ }
@@ -0,0 +1,55 @@
1
+ // Кто и что может: пространства организатора, права участников, роль в чате.
2
+ //
3
+ // Права нормализуются к девяти известным булевым ключам — неизвестное поле
4
+ // в приглашении не должно превращаться в способность. Роль в чате читается
5
+ // из серверного зеркала (его пишут CF7/CF18), а не выводится клиентом.
6
+ import { randomBytes } from "node:crypto";
7
+ import { collection, doc, getDoc, getDocs, query, where } from "firebase/firestore";
8
+ import { get, ref } from "firebase/database";
9
+ import { chatRtdb, firestore } from "../firebase.js";
10
+ import { WORKSPACE_PERMISSION_KEYS } from "./schemas.js";
11
+ import { serialize } from "../utils.js";
12
+ export async function readWorkspaceForOwner(workspaceId, uid) {
13
+ const snap = await getDoc(doc(firestore, "workspaces", workspaceId));
14
+ if (!snap.exists())
15
+ throw new Error("Workspace not found.");
16
+ const data = snap.data();
17
+ if (data.ownerId !== uid)
18
+ throw new Error("Only the workspace owner can manage this workspace.");
19
+ return data;
20
+ }
21
+ export function normalizeWorkspacePermissions(value) {
22
+ const out = {};
23
+ for (const key of WORKSPACE_PERMISSION_KEYS) {
24
+ if (value[key] === true)
25
+ out[key] = true;
26
+ else if (value[key] === false)
27
+ out[key] = false;
28
+ }
29
+ return out;
30
+ }
31
+ export function randomInviteCode() {
32
+ return randomBytes(8).toString("hex").toUpperCase();
33
+ }
34
+ export async function listVisibleWorkspaces(uid, includeShared) {
35
+ const out = [];
36
+ const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
37
+ ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
38
+ if (includeShared) {
39
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
40
+ const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
41
+ for (const workspaceId of sharedIds) {
42
+ if (out.some((row) => row.id === workspaceId))
43
+ continue;
44
+ const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
45
+ const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
46
+ if (wsSnap.exists())
47
+ out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+ export async function getChatRole(eventId, uid) {
53
+ const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
54
+ return typeof snap.val() === "string" ? snap.val() : "viewer";
55
+ }
@@ -0,0 +1,292 @@
1
+ // Рабочие пространства: состав, права участников и приглашения.
2
+ //
3
+ // Девять способностей выдаются точечно; их разносит по событиям CF18, а не
4
+ // клиент. Личное пространство имеет id, равный uid владельца, — искать его
5
+ // отдельным запросом не нужно.
6
+ //
7
+ // oko_create_workspace_invite до 01.09 лежал в конце файла, оторванным от
8
+ // своей группы; при разборе возвращён к остальным инструментам приглашений.
9
+ import { z } from "zod";
10
+ import { requireConfirm, safeString, serialize, textResult } from "../utils.js";
11
+ import { authed, inviteUrl } from "./context.js";
12
+ import { destructiveTool, idempotentWriteTool, MAX_WORKSPACE_INVITE_CODE_LENGTH, MAX_WORKSPACE_NAME_LENGTH, readOnlyTool, toolDescription, unknownRecordSchema, updateOutputSchema, workspaceAccessOutputSchema, workspacePermissionsSchema, workspacesOutputSchema, writeTool } from "./schemas.js";
13
+ import { addDoc, collection, deleteDoc, doc, getDoc, getDocs, limit, query, serverTimestamp, setDoc, updateDoc, where } from "firebase/firestore";
14
+ import { firestore, functions } from "../firebase.js";
15
+ import { httpsCallable } from "firebase/functions";
16
+ import { listVisibleWorkspaces, normalizeWorkspacePermissions, randomInviteCode, readWorkspaceForOwner } from "./workspaceAccess.js";
17
+ export function registerWorkspaceTools(server) {
18
+ server.registerTool("oko_list_workspaces", {
19
+ title: "List workspaces",
20
+ description: toolDescription({
21
+ summary: "List OKO workspaces visible to the authenticated organizer.",
22
+ args: ["includeShared: include workspaces shared with this organizer, default true."],
23
+ returns: "workspaces array with id, ownership flag, and permissions for shared workspaces, plus count.",
24
+ examples: ["Use before oko_create_webinar when the user wants to create a webinar in a shared workspace."],
25
+ }),
26
+ inputSchema: { includeShared: z.boolean().default(true).describe("Include workspaces shared with this organizer.") },
27
+ outputSchema: workspacesOutputSchema,
28
+ annotations: readOnlyTool,
29
+ }, async ({ includeShared }) => {
30
+ const { uid } = await authed(["workspace:read"]);
31
+ const out = await listVisibleWorkspaces(uid, includeShared);
32
+ return textResult({ workspaces: out, count: out.length });
33
+ });
34
+ server.registerTool("oko_create_workspace", {
35
+ title: "Create workspace",
36
+ description: toolDescription({
37
+ summary: "Create a shared OKO workspace owned by the authenticated organizer.",
38
+ args: ["name: workspace display name."],
39
+ returns: "workspaceId, name, and created=true.",
40
+ examples: ["Use when the organizer wants to create a new team/workspace for events."],
41
+ }),
42
+ inputSchema: { name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("Workspace name.") },
43
+ outputSchema: z.object({ workspaceId: z.string(), created: z.boolean() }).passthrough(),
44
+ annotations: writeTool,
45
+ }, async ({ name }) => {
46
+ const { uid } = await authed(["workspace:write"]);
47
+ const workspaceRef = await addDoc(collection(firestore, "workspaces"), {
48
+ name: name.trim(),
49
+ ownerId: uid,
50
+ personal: false,
51
+ createdAt: serverTimestamp(),
52
+ });
53
+ return textResult({ workspaceId: workspaceRef.id, name: name.trim(), created: true });
54
+ });
55
+ server.registerTool("oko_update_workspace", {
56
+ title: "Update workspace",
57
+ description: toolDescription({
58
+ summary: "Rename a workspace owned by the authenticated organizer.",
59
+ args: ["workspaceId: workspace to update.", "name: new workspace display name."],
60
+ returns: "workspaceId and updated fields.",
61
+ examples: ["Use for workspace rename requests."],
62
+ }),
63
+ inputSchema: { workspaceId: z.string().min(1), name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("New workspace name.") },
64
+ outputSchema: updateOutputSchema,
65
+ annotations: idempotentWriteTool,
66
+ }, async ({ workspaceId, name }) => {
67
+ const { uid } = await authed(["workspace:write"]);
68
+ await readWorkspaceForOwner(workspaceId, uid);
69
+ await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name.trim() });
70
+ return textResult({ workspaceId, updated: ["name"] });
71
+ });
72
+ server.registerTool("oko_delete_workspace", {
73
+ title: "Delete workspace",
74
+ description: toolDescription({
75
+ summary: "Delete an empty non-personal workspace owned by the authenticated organizer.",
76
+ args: ["workspaceId: workspace to delete.", "confirm: must be true."],
77
+ returns: "workspaceId and deleted=true.",
78
+ examples: ["Use after listing workspace access and confirming the workspace is empty."],
79
+ notes: ["Personal workspaces cannot be deleted. This tool refuses to delete workspaces that still contain events; move/delete events first."],
80
+ }),
81
+ inputSchema: { workspaceId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for workspace deletion.") },
82
+ outputSchema: z.object({ workspaceId: z.string(), deleted: z.boolean() }).passthrough(),
83
+ annotations: destructiveTool,
84
+ }, async ({ workspaceId, confirm }) => {
85
+ const { uid } = await authed(["workspace:write"]);
86
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
87
+ requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
88
+ if (workspace.personal === true)
89
+ throw new Error("Personal workspace cannot be deleted.");
90
+ const eventsSnap = await getDocs(query(collection(firestore, "events"), where("workspaceId", "==", workspaceId), limit(1)));
91
+ if (!eventsSnap.empty)
92
+ throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
93
+ await deleteDoc(doc(firestore, "workspaces", workspaceId));
94
+ return textResult({ workspaceId, deleted: true });
95
+ });
96
+ server.registerTool("oko_list_workspace_access", {
97
+ title: "List workspace access",
98
+ description: toolDescription({
99
+ summary: "List workspace members/shares and pending invites.",
100
+ args: ["workspaceId: workspace owned by the authenticated organizer."],
101
+ returns: "workspace document, members array, invites array.",
102
+ examples: ["Use before changing permissions, removing a member, or revoking an invite."],
103
+ }),
104
+ inputSchema: { workspaceId: z.string().min(1) },
105
+ outputSchema: workspaceAccessOutputSchema,
106
+ annotations: readOnlyTool,
107
+ }, async ({ workspaceId }) => {
108
+ const { uid } = await authed(["workspace:read"]);
109
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
110
+ const [sharesSnap, invitesSnap] = await Promise.all([
111
+ getDocs(collection(firestore, "workspaces", workspaceId, "shares")),
112
+ getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId))),
113
+ ]);
114
+ const members = [];
115
+ for (const share of sharesSnap.docs) {
116
+ // Правила разрешают читать чужой профиль только владельцу платформы,
117
+ // поэтому на любом рабочем пространстве с участниками это чтение
118
+ // заканчивается отказом. Без обработки инструмент падал целиком вместо
119
+ // того, чтобы вернуть состав команды без необязательных профилей.
120
+ const profile = await getDoc(doc(firestore, "userProfiles", share.id))
121
+ .then((snap) => (snap.exists() ? serialize(snap.data()) : null))
122
+ .catch(() => null);
123
+ members.push({
124
+ uid: share.id,
125
+ ...serialize(share.data()),
126
+ profile,
127
+ });
128
+ }
129
+ const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
130
+ return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites });
131
+ });
132
+ server.registerTool("oko_update_workspace_member", {
133
+ title: "Update workspace member",
134
+ description: toolDescription({
135
+ summary: "Create or update a workspace share/member permissions document.",
136
+ args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
137
+ returns: "workspaceId, uid, and updated=true.",
138
+ examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
139
+ notes: ["Changing a share materializes lightweight event grants and updates RTDB workspace/chat mirrors."],
140
+ }),
141
+ inputSchema: {
142
+ workspaceId: z.string().min(1),
143
+ uid: z.string().min(1).describe("Member Firebase uid."),
144
+ email: z.string().email().optional().describe("Optional member email cache."),
145
+ comment: z.string().max(500).default(""),
146
+ permissions: workspacePermissionsSchema,
147
+ confirm: z.boolean().optional().describe("Required true for share updates."),
148
+ },
149
+ outputSchema: updateOutputSchema,
150
+ annotations: writeTool,
151
+ }, async (args) => {
152
+ const { uid } = await authed(["workspace:write"]);
153
+ const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
154
+ requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
155
+ await setDoc(doc(firestore, "workspaces", args.workspaceId, "shares", args.uid), {
156
+ email: args.email ?? "",
157
+ comment: args.comment.trim(),
158
+ permissions: normalizeWorkspacePermissions(args.permissions),
159
+ sharedAt: serverTimestamp(),
160
+ sharedBy: uid,
161
+ }, { merge: true });
162
+ return textResult({ workspaceId: args.workspaceId, uid: args.uid, updated: true });
163
+ });
164
+ server.registerTool("oko_remove_workspace_member", {
165
+ title: "Remove workspace member",
166
+ description: toolDescription({
167
+ summary: "Remove a member/share from a workspace.",
168
+ args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
169
+ returns: "workspaceId, uid, removed=true.",
170
+ examples: ["Use after listing workspace access and confirming the exact uid."],
171
+ notes: ["Removing a share revokes its event grants and RTDB workspace/chat mirrors."],
172
+ }),
173
+ inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
174
+ outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
175
+ annotations: destructiveTool,
176
+ }, async ({ workspaceId, uid: targetUid, confirm }) => {
177
+ const { uid } = await authed(["workspace:write"]);
178
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
179
+ requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
180
+ await deleteDoc(doc(firestore, "workspaces", workspaceId, "shares", targetUid));
181
+ return textResult({ workspaceId, uid: targetUid, removed: true });
182
+ });
183
+ server.registerTool("oko_list_workspace_invites", {
184
+ title: "List workspace invites",
185
+ description: toolDescription({
186
+ summary: "List workspace invite links for a workspace owned by the organizer.",
187
+ args: ["workspaceId: workspace owned by the organizer."],
188
+ returns: "invites array with code, claim status, permissions, and inviteUrl.",
189
+ examples: ["Use before revoking or sharing an invite code."],
190
+ }),
191
+ inputSchema: { workspaceId: z.string().min(1) },
192
+ outputSchema: z.object({ workspaceId: z.string(), invites: z.array(unknownRecordSchema), count: z.number().int().nonnegative() }).passthrough(),
193
+ annotations: readOnlyTool,
194
+ }, async ({ workspaceId }) => {
195
+ const { uid } = await authed(["workspace:read"]);
196
+ await readWorkspaceForOwner(workspaceId, uid);
197
+ const snap = await getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)));
198
+ const invites = snap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()), inviteUrl: inviteUrl(invite.id) }));
199
+ return textResult({ workspaceId, invites, count: invites.length });
200
+ });
201
+ server.registerTool("oko_revoke_workspace_invite", {
202
+ title: "Revoke workspace invite",
203
+ description: toolDescription({
204
+ summary: "Delete a pending workspace invite code.",
205
+ args: ["code: invite code.", "confirm: must be true."],
206
+ returns: "code and revoked=true.",
207
+ examples: ["Use after confirming the exact invite code."],
208
+ }),
209
+ inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH), confirm: z.boolean().optional().describe("Required true for invite revocation.") },
210
+ outputSchema: z.object({ code: z.string(), revoked: z.boolean() }).passthrough(),
211
+ annotations: destructiveTool,
212
+ }, async ({ code, confirm }) => {
213
+ const { uid } = await authed(["workspace:write"]);
214
+ const normalizedCode = code.trim().toUpperCase();
215
+ const inviteSnap = await getDoc(doc(firestore, "workspaceInvites", normalizedCode));
216
+ if (!inviteSnap.exists())
217
+ throw new Error("Workspace invite not found.");
218
+ const invite = inviteSnap.data();
219
+ if (invite.createdBy !== uid)
220
+ throw new Error("Only the invite creator/workspace owner can revoke this invite.");
221
+ requireConfirm(confirm, "oko_revoke_workspace_invite", { code: normalizedCode, workspaceId: invite.workspaceId, claimed: invite.claimed });
222
+ await deleteDoc(doc(firestore, "workspaceInvites", normalizedCode));
223
+ return textResult({ code: normalizedCode, revoked: true });
224
+ });
225
+ server.registerTool("oko_claim_workspace_invite", {
226
+ title: "Claim workspace invite",
227
+ description: toolDescription({
228
+ summary: "Accept a workspace invite code for the authenticated organizer.",
229
+ args: ["code: invite code from /invite/{code}.", "confirm: must be true."],
230
+ returns: "success plus workspace id/name returned by OKO CF19.",
231
+ examples: ["Use when the organizer wants to join a workspace from an invite code."],
232
+ }),
233
+ inputSchema: {
234
+ code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH),
235
+ confirm: z.boolean().optional().describe("Required true to join the workspace."),
236
+ },
237
+ outputSchema: z.object({
238
+ success: z.boolean(),
239
+ workspace: z.object({ id: z.string(), name: z.string() }).passthrough(),
240
+ }).passthrough(),
241
+ annotations: writeTool,
242
+ }, async ({ code, confirm }) => {
243
+ await authed(["workspace:write"]);
244
+ requireConfirm(confirm, "oko_claim_workspace_invite", { code: code.trim().toUpperCase() });
245
+ const claimInvite = httpsCallable(functions, "claimWorkspaceInvite");
246
+ const result = await claimInvite({ code: code.trim().toUpperCase() });
247
+ return textResult({ ...serialize(result.data) });
248
+ });
249
+ server.registerTool("oko_create_workspace_invite", {
250
+ title: "Create workspace invite",
251
+ description: toolDescription({
252
+ summary: "Create a workspace invite for a known OKO workspace.",
253
+ args: [
254
+ "workspaceId: workspace owned by the authenticated organizer.",
255
+ "code: optional invite code, 4-64 chars; omitted means generated.",
256
+ "email: optional intended recipient email.",
257
+ "permissions: optional whitelist of workspace permission booleans.",
258
+ "confirm: must be true.",
259
+ ],
260
+ returns: "workspaceId, invite code, inviteUrl, and created=true.",
261
+ examples: ["Use only after confirming the recipient and permission set with the workspace owner."],
262
+ }),
263
+ inputSchema: {
264
+ workspaceId: z.string().min(1),
265
+ code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
266
+ email: z.string().email().optional(),
267
+ comment: z.string().max(500).default(""),
268
+ permissions: workspacePermissionsSchema,
269
+ confirm: z.boolean().optional(),
270
+ },
271
+ outputSchema: z.object({ workspaceId: z.string(), code: z.string(), inviteUrl: z.string().url(), created: z.boolean() }).passthrough(),
272
+ annotations: writeTool,
273
+ }, async (args) => {
274
+ const { uid } = await authed(["workspace:write"]);
275
+ const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
276
+ requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
277
+ const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
278
+ const url = inviteUrl(code);
279
+ await setDoc(doc(firestore, "workspaceInvites", code), {
280
+ workspaceId: args.workspaceId,
281
+ workspaceName: safeString(workspace.name, "Workspace"),
282
+ email: args.email ?? "",
283
+ comment: args.comment,
284
+ permissions: normalizeWorkspacePermissions(args.permissions),
285
+ createdBy: uid,
286
+ createdAt: serverTimestamp(),
287
+ claimed: false,
288
+ claimedBy: null,
289
+ });
290
+ return textResult({ workspaceId: args.workspaceId, code, inviteUrl: url, created: true });
291
+ });
292
+ }