@okostream/mcp 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -38
- package/dist/server.js +1 -1
- package/dist/tools.js +892 -31
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ MCP server for managing OKO Stream webinars as an organizer.
|
|
|
4
4
|
|
|
5
5
|
## Quick Setup
|
|
6
6
|
|
|
7
|
-
1. Create an API key in OKO: `/app/
|
|
7
|
+
1. Create an API key in OKO: `/app/mcp` -> access keys.
|
|
8
8
|
2. Add the server to your MCP client config.
|
|
9
9
|
|
|
10
10
|
Claude Desktop / Claude Code:
|
|
@@ -54,7 +54,7 @@ Every tool returns JSON text plus MCP `structuredContent`. List-like tools use b
|
|
|
54
54
|
|
|
55
55
|
- `limit` defaults to 20-50 depending on the tool.
|
|
56
56
|
- Cursor tools return both `hasMore`/`nextCursor` and `has_more`/`next_cursor`.
|
|
57
|
-
- `oko_list_my_events`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, and `
|
|
57
|
+
- `oko_list_my_events`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`, `oko_list_workspace_access`, `oko_list_workspace_invites`, and `oko_list_telegram_posts` support pagination.
|
|
58
58
|
- `oko_get_analytics` omits heavy `timeline`, baseline/final snapshots, and NMO participant rows by default. Request `includeTimeline`, `includeSnapshots`, or `includeNmoRows` only when needed.
|
|
59
59
|
- Oversized responses are truncated with `truncated: true` and a `truncationMessage`.
|
|
60
60
|
|
|
@@ -89,40 +89,32 @@ Local config without npm publishing:
|
|
|
89
89
|
|
|
90
90
|
All tools are prefixed with `oko_` for safe discovery next to other MCP servers.
|
|
91
91
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
- `oko_whoami`
|
|
95
|
-
- `
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
- `
|
|
100
|
-
- `
|
|
101
|
-
- `
|
|
102
|
-
- `
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
- `
|
|
107
|
-
- `
|
|
108
|
-
- `
|
|
109
|
-
- `
|
|
110
|
-
- `
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
- `
|
|
115
|
-
- `
|
|
116
|
-
- `
|
|
117
|
-
- `
|
|
118
|
-
- `oko_mute_user`
|
|
119
|
-
- `oko_ban_user`
|
|
120
|
-
- `oko_set_chat_settings`
|
|
121
|
-
- `oko_launch_checkpoint`
|
|
122
|
-
- `oko_close_checkpoint`
|
|
123
|
-
- `oko_publish_telegram_post`
|
|
124
|
-
- `oko_create_workspace_invite`
|
|
125
|
-
- `oko_delete_event`
|
|
92
|
+
Core discovery/read tools:
|
|
93
|
+
|
|
94
|
+
- `oko_whoami`, `oko_list_workspaces`, `oko_list_my_events`, `oko_get_event`
|
|
95
|
+
- `oko_get_live_state`, `oko_get_analytics`, `oko_list_recent_chat`, `oko_list_participants`, `oko_list_leads`
|
|
96
|
+
|
|
97
|
+
Event/setup/media tools:
|
|
98
|
+
|
|
99
|
+
- `oko_create_webinar`, `oko_update_event`, `oko_delete_event`
|
|
100
|
+
- `oko_duplicate_event`, `oko_move_event`
|
|
101
|
+
- `oko_configure_blocks`, `oko_configure_cards`
|
|
102
|
+
- `oko_upload_image`, `oko_delete_image`
|
|
103
|
+
|
|
104
|
+
Live/session/chat/NMO tools:
|
|
105
|
+
|
|
106
|
+
- `oko_set_block_visibility`, `oko_refresh_viewers`
|
|
107
|
+
- `oko_start_session`, `oko_end_session`, `oko_fix_orphan_session`, `oko_delete_session`
|
|
108
|
+
- `oko_send_chat_message`, `oko_delete_chat_message`, `oko_mute_user`, `oko_unmute_user`, `oko_ban_user`, `oko_unban_user`, `oko_set_chat_settings`
|
|
109
|
+
- `oko_launch_checkpoint`, `oko_close_checkpoint`
|
|
110
|
+
- `oko_add_nmo_participant`, `oko_import_nmo_participants`, `oko_remove_nmo_participant`
|
|
111
|
+
|
|
112
|
+
Workspace and Telegram tools:
|
|
113
|
+
|
|
114
|
+
- `oko_create_workspace`, `oko_update_workspace`, `oko_delete_workspace`
|
|
115
|
+
- `oko_list_workspace_access`, `oko_update_workspace_member`, `oko_remove_workspace_member`
|
|
116
|
+
- `oko_create_workspace_invite`, `oko_list_workspace_invites`, `oko_revoke_workspace_invite`, `oko_claim_workspace_invite`
|
|
117
|
+
- `oko_list_telegram_channels`, `oko_claim_telegram_channel`, `oko_list_telegram_posts`, `oko_cancel_telegram_post`, `oko_publish_telegram_post`
|
|
126
118
|
|
|
127
119
|
Dangerous or external side-effect tools require `confirm: true`.
|
|
128
120
|
|
|
@@ -133,8 +125,10 @@ Dangerous or external side-effect tools require `confirm: true`.
|
|
|
133
125
|
3. `oko_create_webinar` should create a test webinar and RTDB owner/workspace mirror.
|
|
134
126
|
4. `oko_configure_cards` should create/update `blocks.cards.items` and preserve card image fields by stable card id.
|
|
135
127
|
5. `oko_upload_image` should accept a local path or HTTPS image URL, upload `master.webp`, and let OKO generate variants.
|
|
136
|
-
6. `
|
|
137
|
-
7. `
|
|
128
|
+
6. `oko_start_session` -> `oko_fix_orphan_session` -> `oko_delete_session` should leave no active session pointers on a disposable test event.
|
|
129
|
+
7. `oko_duplicate_event`, workspace tools, and `oko_move_event` should preserve Firestore access rules and RTDB owner/workspace mirrors.
|
|
130
|
+
8. `oko_import_nmo_participants` should call CF24 and return per-row results; clean up test participants after import.
|
|
131
|
+
9. `oko_get_analytics` should read an existing event with analytics sessions.
|
|
138
132
|
|
|
139
133
|
CLI help:
|
|
140
134
|
|
package/dist/server.js
CHANGED
package/dist/tools.js
CHANGED
|
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from "node:path";
|
|
|
6
6
|
import { randomUUID } from "node:crypto";
|
|
7
7
|
import sharp from "sharp";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { addDoc, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
|
|
9
|
+
import { addDoc, arrayRemove, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
|
|
10
10
|
import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
|
|
11
11
|
import { httpsCallable } from "firebase/functions";
|
|
12
12
|
import { deleteObject, getDownloadURL, ref as storageRef, uploadBytes, } from "firebase/storage";
|
|
@@ -39,6 +39,10 @@ const MAX_LIST_LIMIT = 100;
|
|
|
39
39
|
const MAX_EXPORT_LIMIT = 200;
|
|
40
40
|
const ANALYTICS_DEFAULT_LIMIT = 10;
|
|
41
41
|
const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
|
|
42
|
+
const MAX_NMO_IMPORT_PARTICIPANTS = 200;
|
|
43
|
+
const MAX_WORKSPACE_NAME_LENGTH = 120;
|
|
44
|
+
const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
|
|
45
|
+
const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed"];
|
|
42
46
|
const readOnlyTool = {
|
|
43
47
|
readOnlyHint: true,
|
|
44
48
|
destructiveHint: false,
|
|
@@ -140,6 +144,25 @@ const deleteImageOutputSchema = z.object({
|
|
|
140
144
|
storageDeletesAttempted: z.number().int().nonnegative(),
|
|
141
145
|
storageDeleteWarnings: z.array(z.string()),
|
|
142
146
|
}).passthrough();
|
|
147
|
+
const workspaceAccessOutputSchema = z.object({
|
|
148
|
+
workspaceId: z.string(),
|
|
149
|
+
workspace: unknownRecordSchema,
|
|
150
|
+
members: z.array(unknownRecordSchema),
|
|
151
|
+
invites: z.array(unknownRecordSchema),
|
|
152
|
+
}).passthrough();
|
|
153
|
+
const telegramChannelsOutputSchema = z.object({
|
|
154
|
+
channels: z.array(unknownRecordSchema),
|
|
155
|
+
count: z.number().int().nonnegative(),
|
|
156
|
+
}).passthrough();
|
|
157
|
+
const telegramPostsOutputSchema = z.object({
|
|
158
|
+
posts: z.array(unknownRecordSchema),
|
|
159
|
+
...cursorMetaSchema,
|
|
160
|
+
}).passthrough();
|
|
161
|
+
const nmoImportOutputSchema = z.object({
|
|
162
|
+
eventId: z.string(),
|
|
163
|
+
summary: unknownRecordSchema,
|
|
164
|
+
results: z.array(unknownRecordSchema),
|
|
165
|
+
}).passthrough();
|
|
143
166
|
const formFieldSchema = z.object({
|
|
144
167
|
id: z.string().min(1).optional(),
|
|
145
168
|
label: z.string().max(120),
|
|
@@ -174,6 +197,18 @@ const cardItemInputSchema = z.object({
|
|
|
174
197
|
buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
|
|
175
198
|
formId: cardIdSchema.nullable().optional(),
|
|
176
199
|
}).strict();
|
|
200
|
+
const emailSchema = z.string().email().max(320);
|
|
201
|
+
const nmoParticipantInputSchema = z.object({
|
|
202
|
+
email: emailSchema.describe("Participant email."),
|
|
203
|
+
fullName: z.string().min(2).max(200).describe("Participant full name."),
|
|
204
|
+
position: z.string().max(200).nullable().optional().describe("Optional medical position."),
|
|
205
|
+
org: z.string().max(200).nullable().optional().describe("Optional organization/clinic."),
|
|
206
|
+
}).strict();
|
|
207
|
+
const telegramChannelSchema = z.object({
|
|
208
|
+
chatId: z.number().int(),
|
|
209
|
+
title: z.string().max(200).optional(),
|
|
210
|
+
username: z.string().max(120).nullable().optional(),
|
|
211
|
+
}).strict();
|
|
177
212
|
const cardsConfigOutputSchema = z.object({
|
|
178
213
|
eventId: z.string(),
|
|
179
214
|
updated: z.array(z.string()),
|
|
@@ -200,6 +235,17 @@ const workspacePermissionsSchema = z.object({
|
|
|
200
235
|
downloadReports: z.boolean().optional(),
|
|
201
236
|
sendNotifications: z.boolean().optional(),
|
|
202
237
|
}).strict().default({});
|
|
238
|
+
const WORKSPACE_PERMISSION_KEYS = [
|
|
239
|
+
"createEvents",
|
|
240
|
+
"editSetup",
|
|
241
|
+
"deleteEvents",
|
|
242
|
+
"controlLive",
|
|
243
|
+
"moderateChat",
|
|
244
|
+
"startSession",
|
|
245
|
+
"viewAnalytics",
|
|
246
|
+
"downloadReports",
|
|
247
|
+
"sendNotifications",
|
|
248
|
+
];
|
|
203
249
|
const eventIdField = z.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
|
|
204
250
|
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}.`);
|
|
205
251
|
const cursorField = z.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
|
|
@@ -633,6 +679,32 @@ function normalizeVideo(videoUrl) {
|
|
|
633
679
|
function eventUrl(eventId) {
|
|
634
680
|
return `https://okostream.app/event/${eventId}`;
|
|
635
681
|
}
|
|
682
|
+
function setupUrl(eventId) {
|
|
683
|
+
return `https://okostream.app/setup/${eventId}`;
|
|
684
|
+
}
|
|
685
|
+
function analyticsUrl(eventId) {
|
|
686
|
+
return `https://okostream.app/analytics/${eventId}`;
|
|
687
|
+
}
|
|
688
|
+
function nmoRegisterUrl(eventId) {
|
|
689
|
+
return `${eventUrl(eventId)}/register`;
|
|
690
|
+
}
|
|
691
|
+
function nmoLoginUrl(eventId) {
|
|
692
|
+
return `${eventUrl(eventId)}/login`;
|
|
693
|
+
}
|
|
694
|
+
function eventLinks(eventId, eventData) {
|
|
695
|
+
const nmoMode = eventData?.nmoMode === true;
|
|
696
|
+
return {
|
|
697
|
+
url: eventUrl(eventId),
|
|
698
|
+
setupUrl: setupUrl(eventId),
|
|
699
|
+
analyticsUrl: analyticsUrl(eventId),
|
|
700
|
+
registrationUrl: nmoRegisterUrl(eventId),
|
|
701
|
+
loginUrl: nmoLoginUrl(eventId),
|
|
702
|
+
...(nmoMode ? {
|
|
703
|
+
nmoRegistrationUrl: nmoRegisterUrl(eventId),
|
|
704
|
+
nmoLoginUrl: nmoLoginUrl(eventId),
|
|
705
|
+
} : {}),
|
|
706
|
+
};
|
|
707
|
+
}
|
|
636
708
|
function assertEventReadable(data, uid) {
|
|
637
709
|
const access = Array.isArray(data.accessibleBy) ? data.accessibleBy : [];
|
|
638
710
|
const editors = Array.isArray(data.editors) ? data.editors : [];
|
|
@@ -695,6 +767,193 @@ async function buildWorkspaceAccess(uid, workspaceId) {
|
|
|
695
767
|
}
|
|
696
768
|
return { accessibleBy: [...accessibleBy], editors: [...editors] };
|
|
697
769
|
}
|
|
770
|
+
async function buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId) {
|
|
771
|
+
const access = await buildWorkspaceAccess(uid, targetWorkspaceId);
|
|
772
|
+
const accessibleBy = new Set(access.accessibleBy);
|
|
773
|
+
const editors = new Set(access.editors);
|
|
774
|
+
if (originalOwnerId)
|
|
775
|
+
accessibleBy.add(originalOwnerId);
|
|
776
|
+
return { accessibleBy: [...accessibleBy], editors: [...editors] };
|
|
777
|
+
}
|
|
778
|
+
async function readWorkspaceForOwner(workspaceId, uid) {
|
|
779
|
+
const snap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
780
|
+
if (!snap.exists())
|
|
781
|
+
throw new Error("Workspace not found.");
|
|
782
|
+
const data = snap.data();
|
|
783
|
+
if (data.ownerId !== uid)
|
|
784
|
+
throw new Error("Only the workspace owner can manage this workspace.");
|
|
785
|
+
return data;
|
|
786
|
+
}
|
|
787
|
+
function normalizeWorkspacePermissions(value) {
|
|
788
|
+
const out = {};
|
|
789
|
+
for (const key of WORKSPACE_PERMISSION_KEYS) {
|
|
790
|
+
if (value[key] === true)
|
|
791
|
+
out[key] = true;
|
|
792
|
+
else if (value[key] === false)
|
|
793
|
+
out[key] = false;
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
function randomInviteCode() {
|
|
798
|
+
return Math.random().toString(36).slice(2, 8).toUpperCase();
|
|
799
|
+
}
|
|
800
|
+
function normalizeEmail(value) {
|
|
801
|
+
return value.trim().toLowerCase();
|
|
802
|
+
}
|
|
803
|
+
function normalizeOptionalText(value, max) {
|
|
804
|
+
const text = value?.trim() ?? "";
|
|
805
|
+
return text ? text.slice(0, max) : null;
|
|
806
|
+
}
|
|
807
|
+
function parseCsvLine(line) {
|
|
808
|
+
const cells = [];
|
|
809
|
+
let current = "";
|
|
810
|
+
let quoted = false;
|
|
811
|
+
for (let i = 0; i < line.length; i++) {
|
|
812
|
+
const char = line[i];
|
|
813
|
+
const next = line[i + 1];
|
|
814
|
+
if (quoted && char === "\"" && next === "\"") {
|
|
815
|
+
current += "\"";
|
|
816
|
+
i++;
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (char === "\"") {
|
|
820
|
+
quoted = !quoted;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (!quoted && char === ",") {
|
|
824
|
+
cells.push(current.trim());
|
|
825
|
+
current = "";
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
current += char;
|
|
829
|
+
}
|
|
830
|
+
cells.push(current.trim());
|
|
831
|
+
return cells;
|
|
832
|
+
}
|
|
833
|
+
function parseNmoParticipantsCsv(csv) {
|
|
834
|
+
const lines = csv
|
|
835
|
+
.split(/\r?\n/)
|
|
836
|
+
.map((line) => line.trim())
|
|
837
|
+
.filter(Boolean);
|
|
838
|
+
if (lines.length < 2)
|
|
839
|
+
throw new Error("CSV must include a header row and at least one participant row.");
|
|
840
|
+
const headers = parseCsvLine(lines[0] ?? "").map((header) => header.trim().toLowerCase());
|
|
841
|
+
const alias = {
|
|
842
|
+
email: "email",
|
|
843
|
+
mail: "email",
|
|
844
|
+
"e-mail": "email",
|
|
845
|
+
fullname: "fullName",
|
|
846
|
+
full_name: "fullName",
|
|
847
|
+
name: "fullName",
|
|
848
|
+
fio: "fullName",
|
|
849
|
+
"фио": "fullName",
|
|
850
|
+
"имя": "fullName",
|
|
851
|
+
position: "position",
|
|
852
|
+
"должность": "position",
|
|
853
|
+
org: "org",
|
|
854
|
+
organization: "org",
|
|
855
|
+
organisation: "org",
|
|
856
|
+
clinic: "org",
|
|
857
|
+
"организация": "org",
|
|
858
|
+
};
|
|
859
|
+
return lines.slice(1).map((line, index) => {
|
|
860
|
+
const cells = parseCsvLine(line);
|
|
861
|
+
const row = {};
|
|
862
|
+
headers.forEach((header, cellIndex) => {
|
|
863
|
+
const key = alias[header];
|
|
864
|
+
if (key)
|
|
865
|
+
row[key] = cells[cellIndex] ?? "";
|
|
866
|
+
});
|
|
867
|
+
const parsed = nmoParticipantInputSchema.safeParse(row);
|
|
868
|
+
if (!parsed.success) {
|
|
869
|
+
throw new Error(`Invalid NMO CSV row ${index + 2}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
870
|
+
}
|
|
871
|
+
return parsed.data;
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
async function readTelegramChannels(uid, includeWorkspaceChannels) {
|
|
875
|
+
const channelsById = new Map();
|
|
876
|
+
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
877
|
+
const profileChannels = Array.isArray(profileSnap.data()?.telegramChannels) ? profileSnap.data()?.telegramChannels : [];
|
|
878
|
+
for (const raw of profileChannels) {
|
|
879
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
880
|
+
continue;
|
|
881
|
+
const ch = raw;
|
|
882
|
+
if (typeof ch.chatId !== "number")
|
|
883
|
+
continue;
|
|
884
|
+
channelsById.set(ch.chatId, { ...serialize(ch), source: "profile" });
|
|
885
|
+
}
|
|
886
|
+
if (includeWorkspaceChannels) {
|
|
887
|
+
const workspaces = await listVisibleWorkspaces(uid, true);
|
|
888
|
+
for (const workspace of workspaces) {
|
|
889
|
+
const workspaceId = safeString(workspace.id);
|
|
890
|
+
const workspaceName = safeString(workspace.name);
|
|
891
|
+
const channels = Array.isArray(workspace.telegramChannels) ? workspace.telegramChannels : [];
|
|
892
|
+
for (const raw of channels) {
|
|
893
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
894
|
+
continue;
|
|
895
|
+
const ch = raw;
|
|
896
|
+
if (typeof ch.chatId !== "number")
|
|
897
|
+
continue;
|
|
898
|
+
const prev = channelsById.get(ch.chatId) ?? {};
|
|
899
|
+
const visibleInWorkspaces = Array.isArray(prev.visibleInWorkspaces) ? prev.visibleInWorkspaces : [];
|
|
900
|
+
channelsById.set(ch.chatId, {
|
|
901
|
+
...prev,
|
|
902
|
+
...serialize(ch),
|
|
903
|
+
source: prev.source ? "profile+workspace" : "workspace",
|
|
904
|
+
visibleInWorkspaces: [...visibleInWorkspaces, { id: workspaceId, name: workspaceName }],
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return [...channelsById.values()].sort((a, b) => safeString(a.title).localeCompare(safeString(b.title)));
|
|
910
|
+
}
|
|
911
|
+
async function listVisibleWorkspaces(uid, includeShared) {
|
|
912
|
+
const out = [];
|
|
913
|
+
const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
914
|
+
ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
|
|
915
|
+
if (includeShared) {
|
|
916
|
+
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
917
|
+
const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
|
|
918
|
+
for (const workspaceId of sharedIds) {
|
|
919
|
+
if (out.some((row) => row.id === workspaceId))
|
|
920
|
+
continue;
|
|
921
|
+
const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
922
|
+
const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
|
|
923
|
+
if (wsSnap.exists())
|
|
924
|
+
out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
}
|
|
929
|
+
async function syncTelegramChannelToWorkspaces(uid, channel, workspaceIds) {
|
|
930
|
+
const updated = [];
|
|
931
|
+
if (workspaceIds.length === 0)
|
|
932
|
+
return updated;
|
|
933
|
+
const profileRef = doc(firestore, "userProfiles", uid);
|
|
934
|
+
const profileSnap = await getDoc(profileRef);
|
|
935
|
+
const channels = Array.isArray(profileSnap.data()?.telegramChannels) ? [...profileSnap.data().telegramChannels] : [];
|
|
936
|
+
const nextProfileChannels = channels.map((item) => {
|
|
937
|
+
if (item.chatId !== channel.chatId)
|
|
938
|
+
return item;
|
|
939
|
+
const current = Array.isArray(item.workspaceIds) ? item.workspaceIds.filter((id) => typeof id === "string") : [];
|
|
940
|
+
return { ...item, workspaceIds: [...new Set([...current, ...workspaceIds])] };
|
|
941
|
+
});
|
|
942
|
+
await setDoc(profileRef, { telegramChannels: nextProfileChannels }, { merge: true });
|
|
943
|
+
for (const workspaceId of workspaceIds) {
|
|
944
|
+
await readWorkspaceForOwner(workspaceId, uid);
|
|
945
|
+
const wsRef = doc(firestore, "workspaces", workspaceId);
|
|
946
|
+
const wsSnap = await getDoc(wsRef);
|
|
947
|
+
const wsChannels = Array.isArray(wsSnap.data()?.telegramChannels) ? wsSnap.data().telegramChannels : [];
|
|
948
|
+
if (!wsChannels.some((item) => item.chatId === channel.chatId)) {
|
|
949
|
+
await updateDoc(wsRef, {
|
|
950
|
+
telegramChannels: [...wsChannels, { chatId: channel.chatId, title: channel.title ?? "", username: channel.username ?? null }],
|
|
951
|
+
});
|
|
952
|
+
updated.push(workspaceId);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return updated;
|
|
956
|
+
}
|
|
698
957
|
async function readAgg(eventId) {
|
|
699
958
|
const snap = await get(ref(rtdb, `events/${eventId}/agg`));
|
|
700
959
|
const agg = (snap.val() ?? {});
|
|
@@ -971,23 +1230,211 @@ export function registerTools(server) {
|
|
|
971
1230
|
annotations: readOnlyTool,
|
|
972
1231
|
}, async ({ includeShared }) => {
|
|
973
1232
|
const { uid } = await authed(["workspace:read"]);
|
|
974
|
-
const out =
|
|
975
|
-
const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
976
|
-
ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
|
|
977
|
-
if (includeShared) {
|
|
978
|
-
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
979
|
-
const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
|
|
980
|
-
for (const workspaceId of sharedIds) {
|
|
981
|
-
if (out.some((row) => typeof row === "object" && row !== null && row.id === workspaceId))
|
|
982
|
-
continue;
|
|
983
|
-
const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
|
|
984
|
-
const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
|
|
985
|
-
if (wsSnap.exists())
|
|
986
|
-
out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
|
|
987
|
-
}
|
|
988
|
-
}
|
|
1233
|
+
const out = await listVisibleWorkspaces(uid, includeShared);
|
|
989
1234
|
return textResult({ workspaces: out, count: out.length });
|
|
990
1235
|
});
|
|
1236
|
+
server.registerTool("oko_create_workspace", {
|
|
1237
|
+
title: "Create workspace",
|
|
1238
|
+
description: toolDescription({
|
|
1239
|
+
summary: "Create a shared OKO workspace owned by the authenticated organizer.",
|
|
1240
|
+
args: ["name: workspace display name."],
|
|
1241
|
+
returns: "workspaceId, name, and created=true.",
|
|
1242
|
+
examples: ["Use when the organizer wants to create a new team/workspace for events."],
|
|
1243
|
+
}),
|
|
1244
|
+
inputSchema: { name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("Workspace name.") },
|
|
1245
|
+
outputSchema: z.object({ workspaceId: z.string(), created: z.boolean() }).passthrough(),
|
|
1246
|
+
annotations: writeTool,
|
|
1247
|
+
}, async ({ name }) => {
|
|
1248
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1249
|
+
const workspaceRef = await addDoc(collection(firestore, "workspaces"), {
|
|
1250
|
+
name: name.trim(),
|
|
1251
|
+
ownerId: uid,
|
|
1252
|
+
personal: false,
|
|
1253
|
+
createdAt: serverTimestamp(),
|
|
1254
|
+
});
|
|
1255
|
+
return textResult({ workspaceId: workspaceRef.id, name: name.trim(), created: true });
|
|
1256
|
+
});
|
|
1257
|
+
server.registerTool("oko_update_workspace", {
|
|
1258
|
+
title: "Update workspace",
|
|
1259
|
+
description: toolDescription({
|
|
1260
|
+
summary: "Rename a workspace owned by the authenticated organizer.",
|
|
1261
|
+
args: ["workspaceId: workspace to update.", "name: new workspace display name."],
|
|
1262
|
+
returns: "workspaceId and updated fields.",
|
|
1263
|
+
examples: ["Use for workspace rename requests."],
|
|
1264
|
+
}),
|
|
1265
|
+
inputSchema: { workspaceId: z.string().min(1), name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("New workspace name.") },
|
|
1266
|
+
outputSchema: updateOutputSchema,
|
|
1267
|
+
annotations: idempotentWriteTool,
|
|
1268
|
+
}, async ({ workspaceId, name }) => {
|
|
1269
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1270
|
+
await readWorkspaceForOwner(workspaceId, uid);
|
|
1271
|
+
await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name.trim() });
|
|
1272
|
+
return textResult({ workspaceId, updated: ["name"] });
|
|
1273
|
+
});
|
|
1274
|
+
server.registerTool("oko_delete_workspace", {
|
|
1275
|
+
title: "Delete workspace",
|
|
1276
|
+
description: toolDescription({
|
|
1277
|
+
summary: "Delete an empty non-personal workspace owned by the authenticated organizer.",
|
|
1278
|
+
args: ["workspaceId: workspace to delete.", "confirm: must be true."],
|
|
1279
|
+
returns: "workspaceId and deleted=true.",
|
|
1280
|
+
examples: ["Use after listing workspace access and confirming the workspace is empty."],
|
|
1281
|
+
notes: ["Personal workspaces cannot be deleted. This tool refuses to delete workspaces that still contain events; move/delete events first."],
|
|
1282
|
+
}),
|
|
1283
|
+
inputSchema: { workspaceId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for workspace deletion.") },
|
|
1284
|
+
outputSchema: z.object({ workspaceId: z.string(), deleted: z.boolean() }).passthrough(),
|
|
1285
|
+
annotations: destructiveTool,
|
|
1286
|
+
}, async ({ workspaceId, confirm }) => {
|
|
1287
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1288
|
+
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1289
|
+
requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
|
|
1290
|
+
if (workspace.personal === true)
|
|
1291
|
+
throw new Error("Personal workspace cannot be deleted.");
|
|
1292
|
+
const eventsSnap = await getDocs(query(collection(firestore, "events"), where("workspaceId", "==", workspaceId), limit(1)));
|
|
1293
|
+
if (!eventsSnap.empty)
|
|
1294
|
+
throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
|
|
1295
|
+
await deleteDoc(doc(firestore, "workspaces", workspaceId));
|
|
1296
|
+
return textResult({ workspaceId, deleted: true });
|
|
1297
|
+
});
|
|
1298
|
+
server.registerTool("oko_list_workspace_access", {
|
|
1299
|
+
title: "List workspace access",
|
|
1300
|
+
description: toolDescription({
|
|
1301
|
+
summary: "List workspace members/shares and pending invites.",
|
|
1302
|
+
args: ["workspaceId: workspace owned by the authenticated organizer."],
|
|
1303
|
+
returns: "workspace document, members array, invites array.",
|
|
1304
|
+
examples: ["Use before changing permissions, removing a member, or revoking an invite."],
|
|
1305
|
+
}),
|
|
1306
|
+
inputSchema: { workspaceId: z.string().min(1) },
|
|
1307
|
+
outputSchema: workspaceAccessOutputSchema,
|
|
1308
|
+
annotations: readOnlyTool,
|
|
1309
|
+
}, async ({ workspaceId }) => {
|
|
1310
|
+
const { uid } = await authed(["workspace:read"]);
|
|
1311
|
+
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1312
|
+
const [sharesSnap, invitesSnap] = await Promise.all([
|
|
1313
|
+
getDocs(collection(firestore, "workspaces", workspaceId, "shares")),
|
|
1314
|
+
getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId))),
|
|
1315
|
+
]);
|
|
1316
|
+
const members = [];
|
|
1317
|
+
for (const share of sharesSnap.docs) {
|
|
1318
|
+
const profileSnap = await getDoc(doc(firestore, "userProfiles", share.id));
|
|
1319
|
+
members.push({
|
|
1320
|
+
uid: share.id,
|
|
1321
|
+
...serialize(share.data()),
|
|
1322
|
+
profile: profileSnap.exists() ? serialize(profileSnap.data()) : null,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
|
|
1326
|
+
return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites });
|
|
1327
|
+
});
|
|
1328
|
+
server.registerTool("oko_update_workspace_member", {
|
|
1329
|
+
title: "Update workspace member",
|
|
1330
|
+
description: toolDescription({
|
|
1331
|
+
summary: "Create or update a workspace share/member permissions document.",
|
|
1332
|
+
args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
|
|
1333
|
+
returns: "workspaceId, uid, and updated=true.",
|
|
1334
|
+
examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
|
|
1335
|
+
notes: ["Changing a share triggers OKO CF18 fan-out to event access, RTDB workspaceMembers, and chat roles."],
|
|
1336
|
+
}),
|
|
1337
|
+
inputSchema: {
|
|
1338
|
+
workspaceId: z.string().min(1),
|
|
1339
|
+
uid: z.string().min(1).describe("Member Firebase uid."),
|
|
1340
|
+
email: z.string().email().optional().describe("Optional member email cache."),
|
|
1341
|
+
comment: z.string().max(500).default(""),
|
|
1342
|
+
permissions: workspacePermissionsSchema,
|
|
1343
|
+
confirm: z.boolean().optional().describe("Required true for share updates."),
|
|
1344
|
+
},
|
|
1345
|
+
outputSchema: updateOutputSchema,
|
|
1346
|
+
annotations: writeTool,
|
|
1347
|
+
}, async (args) => {
|
|
1348
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1349
|
+
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
1350
|
+
requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
|
|
1351
|
+
await setDoc(doc(firestore, "workspaces", args.workspaceId, "shares", args.uid), {
|
|
1352
|
+
email: args.email ?? "",
|
|
1353
|
+
comment: args.comment.trim(),
|
|
1354
|
+
permissions: normalizeWorkspacePermissions(args.permissions),
|
|
1355
|
+
sharedAt: serverTimestamp(),
|
|
1356
|
+
sharedBy: uid,
|
|
1357
|
+
}, { merge: true });
|
|
1358
|
+
return textResult({ workspaceId: args.workspaceId, uid: args.uid, updated: true });
|
|
1359
|
+
});
|
|
1360
|
+
server.registerTool("oko_remove_workspace_member", {
|
|
1361
|
+
title: "Remove workspace member",
|
|
1362
|
+
description: toolDescription({
|
|
1363
|
+
summary: "Remove a member/share from a workspace.",
|
|
1364
|
+
args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
|
|
1365
|
+
returns: "workspaceId, uid, removed=true.",
|
|
1366
|
+
examples: ["Use after listing workspace access and confirming the exact uid."],
|
|
1367
|
+
notes: ["Removing a share triggers OKO CF18 reverse fan-out."],
|
|
1368
|
+
}),
|
|
1369
|
+
inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
|
|
1370
|
+
outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
|
|
1371
|
+
annotations: destructiveTool,
|
|
1372
|
+
}, async ({ workspaceId, uid: targetUid, confirm }) => {
|
|
1373
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1374
|
+
const workspace = await readWorkspaceForOwner(workspaceId, uid);
|
|
1375
|
+
requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
|
|
1376
|
+
await deleteDoc(doc(firestore, "workspaces", workspaceId, "shares", targetUid));
|
|
1377
|
+
return textResult({ workspaceId, uid: targetUid, removed: true });
|
|
1378
|
+
});
|
|
1379
|
+
server.registerTool("oko_list_workspace_invites", {
|
|
1380
|
+
title: "List workspace invites",
|
|
1381
|
+
description: toolDescription({
|
|
1382
|
+
summary: "List workspace invite links for a workspace owned by the organizer.",
|
|
1383
|
+
args: ["workspaceId: workspace owned by the organizer."],
|
|
1384
|
+
returns: "invites array with code, claim status, permissions, and inviteUrl.",
|
|
1385
|
+
examples: ["Use before revoking or sharing an invite code."],
|
|
1386
|
+
}),
|
|
1387
|
+
inputSchema: { workspaceId: z.string().min(1) },
|
|
1388
|
+
outputSchema: z.object({ workspaceId: z.string(), invites: z.array(unknownRecordSchema), count: z.number().int().nonnegative() }).passthrough(),
|
|
1389
|
+
annotations: readOnlyTool,
|
|
1390
|
+
}, async ({ workspaceId }) => {
|
|
1391
|
+
const { uid } = await authed(["workspace:read"]);
|
|
1392
|
+
await readWorkspaceForOwner(workspaceId, uid);
|
|
1393
|
+
const snap = await getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)));
|
|
1394
|
+
const invites = snap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()), inviteUrl: `https://okostream.app/invite/${invite.id}` }));
|
|
1395
|
+
return textResult({ workspaceId, invites, count: invites.length });
|
|
1396
|
+
});
|
|
1397
|
+
server.registerTool("oko_revoke_workspace_invite", {
|
|
1398
|
+
title: "Revoke workspace invite",
|
|
1399
|
+
description: toolDescription({
|
|
1400
|
+
summary: "Delete a pending workspace invite code.",
|
|
1401
|
+
args: ["code: invite code.", "confirm: must be true."],
|
|
1402
|
+
returns: "code and revoked=true.",
|
|
1403
|
+
examples: ["Use after confirming the exact invite code."],
|
|
1404
|
+
}),
|
|
1405
|
+
inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH), confirm: z.boolean().optional().describe("Required true for invite revocation.") },
|
|
1406
|
+
outputSchema: z.object({ code: z.string(), revoked: z.boolean() }).passthrough(),
|
|
1407
|
+
annotations: destructiveTool,
|
|
1408
|
+
}, async ({ code, confirm }) => {
|
|
1409
|
+
const { uid } = await authed(["workspace:write"]);
|
|
1410
|
+
const normalizedCode = code.trim().toUpperCase();
|
|
1411
|
+
const inviteSnap = await getDoc(doc(firestore, "workspaceInvites", normalizedCode));
|
|
1412
|
+
if (!inviteSnap.exists())
|
|
1413
|
+
throw new Error("Workspace invite not found.");
|
|
1414
|
+
const invite = inviteSnap.data();
|
|
1415
|
+
if (invite.createdBy !== uid)
|
|
1416
|
+
throw new Error("Only the invite creator/workspace owner can revoke this invite.");
|
|
1417
|
+
requireConfirm(confirm, "oko_revoke_workspace_invite", { code: normalizedCode, workspaceId: invite.workspaceId, claimed: invite.claimed });
|
|
1418
|
+
await deleteDoc(doc(firestore, "workspaceInvites", normalizedCode));
|
|
1419
|
+
return textResult({ code: normalizedCode, revoked: true });
|
|
1420
|
+
});
|
|
1421
|
+
server.registerTool("oko_claim_workspace_invite", {
|
|
1422
|
+
title: "Claim workspace invite",
|
|
1423
|
+
description: toolDescription({
|
|
1424
|
+
summary: "Accept a workspace invite code for the authenticated organizer.",
|
|
1425
|
+
args: ["code: invite code from /invite/{code}."],
|
|
1426
|
+
returns: "success plus workspace id/name returned by OKO CF19.",
|
|
1427
|
+
examples: ["Use when the organizer wants to join a workspace from an invite code."],
|
|
1428
|
+
}),
|
|
1429
|
+
inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH) },
|
|
1430
|
+
outputSchema: unknownRecordSchema,
|
|
1431
|
+
annotations: writeTool,
|
|
1432
|
+
}, async ({ code }) => {
|
|
1433
|
+
await authed(["workspace:write"]);
|
|
1434
|
+
const claimInvite = httpsCallable(functions, "claimWorkspaceInvite");
|
|
1435
|
+
const result = await claimInvite({ code: code.trim().toUpperCase() });
|
|
1436
|
+
return textResult({ ...serialize(result.data) });
|
|
1437
|
+
});
|
|
991
1438
|
server.registerTool("oko_list_my_events", {
|
|
992
1439
|
title: "List my events",
|
|
993
1440
|
description: toolDescription({
|
|
@@ -1038,7 +1485,7 @@ export function registerTools(server) {
|
|
|
1038
1485
|
}, async ({ eventId }) => {
|
|
1039
1486
|
const { uid } = await authed(["events:read"]);
|
|
1040
1487
|
const data = await readEvent(eventId, uid, "read");
|
|
1041
|
-
return textResult({ id: eventId, ...serialize(data),
|
|
1488
|
+
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId, data) });
|
|
1042
1489
|
});
|
|
1043
1490
|
server.registerTool("oko_create_webinar", {
|
|
1044
1491
|
title: "Create webinar",
|
|
@@ -1094,7 +1541,7 @@ export function registerTools(server) {
|
|
|
1094
1541
|
ownerId: uid,
|
|
1095
1542
|
workspaceId,
|
|
1096
1543
|
});
|
|
1097
|
-
return textResult({ id: docRef.id,
|
|
1544
|
+
return textResult({ id: docRef.id, ...eventLinks(docRef.id), workspaceId });
|
|
1098
1545
|
});
|
|
1099
1546
|
server.registerTool("oko_update_event", {
|
|
1100
1547
|
title: "Update event metadata",
|
|
@@ -1511,6 +1958,71 @@ export function registerTools(server) {
|
|
|
1511
1958
|
await deleteDoc(doc(firestore, "events", eventId));
|
|
1512
1959
|
return textResult({ deleted: eventId });
|
|
1513
1960
|
});
|
|
1961
|
+
server.registerTool("oko_duplicate_event", {
|
|
1962
|
+
title: "Duplicate event",
|
|
1963
|
+
description: toolDescription({
|
|
1964
|
+
summary: "Duplicate an event setup into the same or another workspace without analytics/session history.",
|
|
1965
|
+
args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
|
|
1966
|
+
returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
|
|
1967
|
+
examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
|
|
1968
|
+
notes: ["Copies title, video, meta, blocks, forms, telegram config, NMO settings; does not copy analytics sessions/leads/participants."],
|
|
1969
|
+
}),
|
|
1970
|
+
inputSchema: {
|
|
1971
|
+
eventId: eventIdField,
|
|
1972
|
+
targetWorkspaceId: z.string().min(1).optional().describe("Target workspace id; omit to use the source workspace or personal workspace."),
|
|
1973
|
+
},
|
|
1974
|
+
outputSchema: createWebinarOutputSchema,
|
|
1975
|
+
annotations: writeTool,
|
|
1976
|
+
}, async ({ eventId, targetWorkspaceId }) => {
|
|
1977
|
+
const { uid } = await authed(["events:write"]);
|
|
1978
|
+
const source = await readEvent(eventId, uid, "read");
|
|
1979
|
+
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
1980
|
+
const access = await buildWorkspaceAccess(uid, workspaceId);
|
|
1981
|
+
const newRef = await addDoc(collection(firestore, "events"), {
|
|
1982
|
+
ownerId: uid,
|
|
1983
|
+
workspaceId,
|
|
1984
|
+
accessibleBy: access.accessibleBy,
|
|
1985
|
+
editors: access.editors,
|
|
1986
|
+
createdAt: serverTimestamp(),
|
|
1987
|
+
title: safeString(source.title, "Untitled event"),
|
|
1988
|
+
pinned: false,
|
|
1989
|
+
video: source.video ?? { originalUrl: null, embedUrl: null },
|
|
1990
|
+
meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
|
|
1991
|
+
blocks: source.blocks ?? {},
|
|
1992
|
+
...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
|
|
1993
|
+
...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
|
|
1994
|
+
...(source.nmoMode === true ? { nmoMode: true } : {}),
|
|
1995
|
+
...(source.nmoCriteria && typeof source.nmoCriteria === "object" ? { nmoCriteria: source.nmoCriteria } : {}),
|
|
1996
|
+
...(source.nmoExtraFields && typeof source.nmoExtraFields === "object" ? { nmoExtraFields: source.nmoExtraFields } : {}),
|
|
1997
|
+
});
|
|
1998
|
+
await update(ref(rtdb, `events/${newRef.id}`), { ownerId: uid, workspaceId });
|
|
1999
|
+
return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
|
|
2000
|
+
});
|
|
2001
|
+
server.registerTool("oko_move_event", {
|
|
2002
|
+
title: "Move event",
|
|
2003
|
+
description: toolDescription({
|
|
2004
|
+
summary: "Move an event to another workspace and recompute accessibleBy/editors.",
|
|
2005
|
+
args: ["eventId: event to move.", "targetWorkspaceId: target workspace id."],
|
|
2006
|
+
returns: "eventId, targetWorkspaceId, updated Firestore fields, and RTDB mirror status.",
|
|
2007
|
+
examples: ["Use when an event belongs in another workspace. Call oko_list_workspaces first to choose the target."],
|
|
2008
|
+
notes: ["Reads target workspace shares from Firestore with permissions; does not use RTDB workspaceMembers."],
|
|
2009
|
+
}),
|
|
2010
|
+
inputSchema: { eventId: eventIdField, targetWorkspaceId: z.string().min(1).describe("Target workspace id.") },
|
|
2011
|
+
outputSchema: updateOutputSchema,
|
|
2012
|
+
annotations: writeTool,
|
|
2013
|
+
}, async ({ eventId, targetWorkspaceId }) => {
|
|
2014
|
+
const { uid } = await authed(["events:write", "workspace:write"]);
|
|
2015
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
2016
|
+
const originalOwnerId = safeString(event.ownerId) || null;
|
|
2017
|
+
const access = await buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId);
|
|
2018
|
+
await updateDoc(doc(firestore, "events", eventId), {
|
|
2019
|
+
workspaceId: targetWorkspaceId,
|
|
2020
|
+
accessibleBy: access.accessibleBy,
|
|
2021
|
+
editors: access.editors,
|
|
2022
|
+
});
|
|
2023
|
+
await set(ref(rtdb, `events/${eventId}/workspaceId`), targetWorkspaceId);
|
|
2024
|
+
return textResult({ eventId, targetWorkspaceId, updated: ["workspaceId", "accessibleBy", "editors"], rtdbMirrorUpdated: true });
|
|
2025
|
+
});
|
|
1514
2026
|
server.registerTool("oko_get_live_state", {
|
|
1515
2027
|
title: "Get live state",
|
|
1516
2028
|
description: toolDescription({
|
|
@@ -1854,6 +2366,103 @@ export function registerTools(server) {
|
|
|
1854
2366
|
]);
|
|
1855
2367
|
return textResult({ eventId, sessionId, ended: true });
|
|
1856
2368
|
});
|
|
2369
|
+
server.registerTool("oko_fix_orphan_session", {
|
|
2370
|
+
title: "Fix orphan analytics session",
|
|
2371
|
+
description: toolDescription({
|
|
2372
|
+
summary: "Clear stuck active-session pointers when an analytics session cannot be ended normally.",
|
|
2373
|
+
args: [
|
|
2374
|
+
"eventId: event with stuck session pointers.",
|
|
2375
|
+
"deleteRtdbSessionData: optional, also delete events/{eventId}/sessions/{sessionId}; default false.",
|
|
2376
|
+
"confirm: must be true.",
|
|
2377
|
+
],
|
|
2378
|
+
returns: "eventId, cleared sessionId if found, and cleaned paths.",
|
|
2379
|
+
examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
|
|
2380
|
+
notes: ["This does not stop the video stream; OKO Start/End controls analytics sessions only."],
|
|
2381
|
+
}),
|
|
2382
|
+
inputSchema: {
|
|
2383
|
+
eventId: eventIdField,
|
|
2384
|
+
deleteRtdbSessionData: z.boolean().default(false).describe("Also delete RTDB session data for the stuck session."),
|
|
2385
|
+
confirm: z.boolean().optional().describe("Required true for clearing session pointers."),
|
|
2386
|
+
},
|
|
2387
|
+
outputSchema: basicEventOutputSchema,
|
|
2388
|
+
annotations: destructiveTool,
|
|
2389
|
+
}, async ({ eventId, deleteRtdbSessionData, confirm }) => {
|
|
2390
|
+
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2391
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
2392
|
+
const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
|
|
2393
|
+
const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
|
|
2394
|
+
requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
|
|
2395
|
+
const cleanup = [
|
|
2396
|
+
updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
|
|
2397
|
+
remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
2398
|
+
];
|
|
2399
|
+
if (sessionId)
|
|
2400
|
+
cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
|
|
2401
|
+
if (sessionId && deleteRtdbSessionData)
|
|
2402
|
+
cleanup.push(remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)));
|
|
2403
|
+
const results = await Promise.allSettled(cleanup);
|
|
2404
|
+
const warnings = results
|
|
2405
|
+
.filter((result) => result.status === "rejected")
|
|
2406
|
+
.map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
2407
|
+
return textResult({
|
|
2408
|
+
eventId,
|
|
2409
|
+
sessionId: sessionId || null,
|
|
2410
|
+
fixed: warnings.length === 0,
|
|
2411
|
+
cleared: ["events.activeAnalyticsSessionId", "ctrl/activeSessionId", ...(sessionId ? [`activeSessions/${sessionId}`] : []), ...(sessionId && deleteRtdbSessionData ? [`events/${eventId}/sessions/${sessionId}`] : [])],
|
|
2412
|
+
warnings,
|
|
2413
|
+
});
|
|
2414
|
+
});
|
|
2415
|
+
server.registerTool("oko_delete_session", {
|
|
2416
|
+
title: "Delete analytics session",
|
|
2417
|
+
description: toolDescription({
|
|
2418
|
+
summary: "Delete an analytics session document and optionally its RTDB session data.",
|
|
2419
|
+
args: [
|
|
2420
|
+
"eventId: event id.",
|
|
2421
|
+
"sessionId: analytics session id.",
|
|
2422
|
+
"deleteRtdbData: best-effort delete of events/{eventId}/sessions/{sessionId}; default false.",
|
|
2423
|
+
"confirm: must be true.",
|
|
2424
|
+
],
|
|
2425
|
+
returns: "eventId, sessionId, deleted=true, and cleaned paths.",
|
|
2426
|
+
examples: ["Use only after confirming the exact session id with the organizer."],
|
|
2427
|
+
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."],
|
|
2428
|
+
}),
|
|
2429
|
+
inputSchema: {
|
|
2430
|
+
eventId: eventIdField,
|
|
2431
|
+
sessionId: z.string().min(1).describe("analyticsSessions document id."),
|
|
2432
|
+
deleteRtdbData: z.boolean().default(false).describe("Best-effort delete RTDB session data for this session."),
|
|
2433
|
+
confirm: z.boolean().optional().describe("Required true for session deletion."),
|
|
2434
|
+
},
|
|
2435
|
+
outputSchema: basicEventOutputSchema,
|
|
2436
|
+
annotations: destructiveTool,
|
|
2437
|
+
}, async ({ eventId, sessionId, deleteRtdbData, confirm }) => {
|
|
2438
|
+
const { uid } = await authed(["analytics:write", "live:control"]);
|
|
2439
|
+
const event = await readEvent(eventId, uid, "edit");
|
|
2440
|
+
requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
|
|
2441
|
+
const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
|
|
2442
|
+
const cleanup = [];
|
|
2443
|
+
if (isActive) {
|
|
2444
|
+
cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
|
|
2445
|
+
cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
|
|
2446
|
+
cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
|
|
2447
|
+
}
|
|
2448
|
+
cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
|
|
2449
|
+
const results = await Promise.allSettled(cleanup);
|
|
2450
|
+
const warnings = results
|
|
2451
|
+
.filter((result) => result.status === "rejected")
|
|
2452
|
+
.map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
|
|
2453
|
+
if (warnings.length > 0)
|
|
2454
|
+
throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
|
|
2455
|
+
const rtdbCleanupWarnings = [];
|
|
2456
|
+
if (deleteRtdbData) {
|
|
2457
|
+
try {
|
|
2458
|
+
await remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`));
|
|
2459
|
+
}
|
|
2460
|
+
catch (err) {
|
|
2461
|
+
rtdbCleanupWarnings.push(err instanceof Error ? err.message : String(err));
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
return textResult({ eventId, sessionId, deleted: true, wasActive: isActive, deleteRtdbData, rtdbCleanupWarnings });
|
|
2465
|
+
});
|
|
1857
2466
|
server.registerTool("oko_get_analytics", {
|
|
1858
2467
|
title: "Get analytics sessions",
|
|
1859
2468
|
description: toolDescription({
|
|
@@ -2008,6 +2617,42 @@ export function registerTools(server) {
|
|
|
2008
2617
|
await update(ref(chatRtdb), deletes);
|
|
2009
2618
|
return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
|
|
2010
2619
|
});
|
|
2620
|
+
server.registerTool("oko_unmute_user", {
|
|
2621
|
+
title: "Unmute chat user",
|
|
2622
|
+
description: toolDescription({
|
|
2623
|
+
summary: "Remove a chat mute for a user.",
|
|
2624
|
+
args: ["eventId: target event.", "uid: Firebase uid to unmute.", "confirm: must be true."],
|
|
2625
|
+
returns: "eventId, targetUid, and unmuted=true.",
|
|
2626
|
+
examples: ["Use when a user was muted by mistake or the organizer wants to restore chat access."],
|
|
2627
|
+
}),
|
|
2628
|
+
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unmute."), confirm: z.boolean().optional().describe("Required true for unmute.") },
|
|
2629
|
+
outputSchema: basicEventOutputSchema,
|
|
2630
|
+
annotations: idempotentWriteTool,
|
|
2631
|
+
}, async ({ eventId, uid: targetUid, confirm }) => {
|
|
2632
|
+
const actor = await authed(["chat:moderate"]);
|
|
2633
|
+
await readEvent(eventId, actor.uid, "edit");
|
|
2634
|
+
requireConfirm(confirm, "oko_unmute_user", { eventId, targetUid });
|
|
2635
|
+
await remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`));
|
|
2636
|
+
return textResult({ eventId, targetUid, unmuted: true });
|
|
2637
|
+
});
|
|
2638
|
+
server.registerTool("oko_unban_user", {
|
|
2639
|
+
title: "Unban chat user",
|
|
2640
|
+
description: toolDescription({
|
|
2641
|
+
summary: "Remove a chat ban for a user.",
|
|
2642
|
+
args: ["eventId: target event.", "uid: Firebase uid to unban.", "confirm: must be true."],
|
|
2643
|
+
returns: "eventId, targetUid, and unbanned=true.",
|
|
2644
|
+
examples: ["Use when a user was banned by mistake or the organizer wants to restore event chat access."],
|
|
2645
|
+
}),
|
|
2646
|
+
inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unban."), confirm: z.boolean().optional().describe("Required true for unban.") },
|
|
2647
|
+
outputSchema: basicEventOutputSchema,
|
|
2648
|
+
annotations: idempotentWriteTool,
|
|
2649
|
+
}, async ({ eventId, uid: targetUid, confirm }) => {
|
|
2650
|
+
const actor = await authed(["chat:moderate"]);
|
|
2651
|
+
await readEvent(eventId, actor.uid, "edit");
|
|
2652
|
+
requireConfirm(confirm, "oko_unban_user", { eventId, targetUid });
|
|
2653
|
+
await remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`));
|
|
2654
|
+
return textResult({ eventId, targetUid, unbanned: true });
|
|
2655
|
+
});
|
|
2011
2656
|
server.registerTool("oko_set_chat_settings", {
|
|
2012
2657
|
title: "Set chat settings",
|
|
2013
2658
|
description: toolDescription({
|
|
@@ -2113,6 +2758,116 @@ export function registerTools(server) {
|
|
|
2113
2758
|
}
|
|
2114
2759
|
return textResult({ participants: rows, ...meta });
|
|
2115
2760
|
});
|
|
2761
|
+
server.registerTool("oko_add_nmo_participant", {
|
|
2762
|
+
title: "Add NMO participant",
|
|
2763
|
+
description: toolDescription({
|
|
2764
|
+
summary: "Add or update one NMO participant for an event through OKO organizer import.",
|
|
2765
|
+
args: ["eventId: NMO event.", "email/fullName/position/org: participant fields."],
|
|
2766
|
+
returns: "eventId, summary, and per-row import result.",
|
|
2767
|
+
examples: ["Use when the organizer dictates one doctor/participant to add before the webinar."],
|
|
2768
|
+
notes: ["This does not return a participant login token; use registration/login URLs from oko_get_event for attendee access."],
|
|
2769
|
+
}),
|
|
2770
|
+
inputSchema: { eventId: eventIdField, participant: nmoParticipantInputSchema },
|
|
2771
|
+
outputSchema: nmoImportOutputSchema,
|
|
2772
|
+
annotations: writeTool,
|
|
2773
|
+
}, async ({ eventId, participant }) => {
|
|
2774
|
+
const { uid } = await authed(["nmo:write"]);
|
|
2775
|
+
await readEvent(eventId, uid, "edit");
|
|
2776
|
+
const importFn = httpsCallable(functions, "importNmoParticipants");
|
|
2777
|
+
const result = await importFn({
|
|
2778
|
+
eventId,
|
|
2779
|
+
participants: [{
|
|
2780
|
+
email: normalizeEmail(participant.email),
|
|
2781
|
+
fullName: participant.fullName.trim(),
|
|
2782
|
+
position: normalizeOptionalText(participant.position, 200),
|
|
2783
|
+
org: normalizeOptionalText(participant.org, 200),
|
|
2784
|
+
}],
|
|
2785
|
+
});
|
|
2786
|
+
const data = serialize(result.data);
|
|
2787
|
+
const results = Array.isArray(data.results) ? data.results : [];
|
|
2788
|
+
const first = results[0];
|
|
2789
|
+
if (!first || first.ok !== true) {
|
|
2790
|
+
throw new Error(`NMO participant import failed: ${safeString(first?.error, "unknown error")}`);
|
|
2791
|
+
}
|
|
2792
|
+
return textResult({ eventId, ...data });
|
|
2793
|
+
});
|
|
2794
|
+
server.registerTool("oko_import_nmo_participants", {
|
|
2795
|
+
title: "Import NMO participants",
|
|
2796
|
+
description: toolDescription({
|
|
2797
|
+
summary: "Bulk import NMO participants from structured rows or CSV text.",
|
|
2798
|
+
args: [
|
|
2799
|
+
"eventId: NMO event.",
|
|
2800
|
+
"participants: optional array of rows { email, fullName, position?, org? }.",
|
|
2801
|
+
"csv: optional CSV text with headers email, fullName, position, org.",
|
|
2802
|
+
],
|
|
2803
|
+
returns: "eventId, summary, and per-row results with ok/error.",
|
|
2804
|
+
examples: ["Use csv when the organizer pastes a participant list from a spreadsheet."],
|
|
2805
|
+
notes: ["Provide exactly one of participants or csv. Max 200 rows. The callable returns partial success for bulk imports."],
|
|
2806
|
+
}),
|
|
2807
|
+
inputSchema: {
|
|
2808
|
+
eventId: eventIdField,
|
|
2809
|
+
participants: z.array(nmoParticipantInputSchema).max(MAX_NMO_IMPORT_PARTICIPANTS).optional(),
|
|
2810
|
+
csv: z.string().max(80_000).optional().describe("CSV with headers email, fullName, position, org."),
|
|
2811
|
+
},
|
|
2812
|
+
outputSchema: nmoImportOutputSchema,
|
|
2813
|
+
annotations: writeTool,
|
|
2814
|
+
}, async ({ eventId, participants, csv }) => {
|
|
2815
|
+
const { uid } = await authed(["nmo:write"]);
|
|
2816
|
+
await readEvent(eventId, uid, "edit");
|
|
2817
|
+
if (Boolean(participants) === Boolean(csv))
|
|
2818
|
+
throw new Error("Provide exactly one of participants or csv.");
|
|
2819
|
+
const rows = participants ?? parseNmoParticipantsCsv(csv ?? "");
|
|
2820
|
+
if (rows.length === 0 || rows.length > MAX_NMO_IMPORT_PARTICIPANTS)
|
|
2821
|
+
throw new Error(`Import supports 1-${MAX_NMO_IMPORT_PARTICIPANTS} participants.`);
|
|
2822
|
+
const importFn = httpsCallable(functions, "importNmoParticipants");
|
|
2823
|
+
const result = await importFn({
|
|
2824
|
+
eventId,
|
|
2825
|
+
participants: rows.map((row) => ({
|
|
2826
|
+
email: normalizeEmail(row.email),
|
|
2827
|
+
fullName: row.fullName.trim(),
|
|
2828
|
+
position: normalizeOptionalText(row.position, 200),
|
|
2829
|
+
org: normalizeOptionalText(row.org, 200),
|
|
2830
|
+
})),
|
|
2831
|
+
});
|
|
2832
|
+
return textResult({ eventId, ...serialize(result.data) });
|
|
2833
|
+
});
|
|
2834
|
+
server.registerTool("oko_remove_nmo_participant", {
|
|
2835
|
+
title: "Remove NMO participant",
|
|
2836
|
+
description: toolDescription({
|
|
2837
|
+
summary: "Remove an NMO participant and immediately revoke event access.",
|
|
2838
|
+
args: ["eventId: NMO event.", "uid or email: participant to remove.", "confirm: must be true."],
|
|
2839
|
+
returns: "eventId, participant uid/email, and removed=true.",
|
|
2840
|
+
examples: ["Use after listing participants and confirming the exact participant."],
|
|
2841
|
+
notes: ["Order is intentional: arrayRemove from events.participantUids first, then delete participant doc."],
|
|
2842
|
+
}),
|
|
2843
|
+
inputSchema: {
|
|
2844
|
+
eventId: eventIdField,
|
|
2845
|
+
uid: z.string().min(1).optional().describe("Participant Firebase uid."),
|
|
2846
|
+
email: emailSchema.optional().describe("Participant email; used to resolve uid if uid is omitted."),
|
|
2847
|
+
confirm: z.boolean().optional().describe("Required true for participant removal."),
|
|
2848
|
+
},
|
|
2849
|
+
outputSchema: z.object({ eventId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
|
|
2850
|
+
annotations: destructiveTool,
|
|
2851
|
+
}, async ({ eventId, uid: participantUid, email, confirm }) => {
|
|
2852
|
+
const { uid } = await authed(["nmo:write"]);
|
|
2853
|
+
await readEvent(eventId, uid, "edit");
|
|
2854
|
+
let targetUid = participantUid;
|
|
2855
|
+
const normalizedEmail = email ? normalizeEmail(email) : null;
|
|
2856
|
+
if (!targetUid && normalizedEmail) {
|
|
2857
|
+
const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), where("email", "==", normalizedEmail), limit(2)));
|
|
2858
|
+
if (snap.empty)
|
|
2859
|
+
throw new Error(`NMO participant with email ${normalizedEmail} not found.`);
|
|
2860
|
+
if (snap.docs.length > 1)
|
|
2861
|
+
throw new Error(`Multiple NMO participants found for email ${normalizedEmail}; use uid.`);
|
|
2862
|
+
targetUid = snap.docs[0].id;
|
|
2863
|
+
}
|
|
2864
|
+
if (!targetUid)
|
|
2865
|
+
throw new Error("Provide uid or email.");
|
|
2866
|
+
requireConfirm(confirm, "oko_remove_nmo_participant", { eventId, uid: targetUid, email: normalizedEmail });
|
|
2867
|
+
await updateDoc(doc(firestore, "events", eventId), { participantUids: arrayRemove(targetUid) });
|
|
2868
|
+
await deleteDoc(doc(firestore, "eventParticipants", eventId, "items", targetUid));
|
|
2869
|
+
return textResult({ eventId, uid: targetUid, email: normalizedEmail, removed: true });
|
|
2870
|
+
});
|
|
2116
2871
|
server.registerTool("oko_list_leads", {
|
|
2117
2872
|
title: "List form leads",
|
|
2118
2873
|
description: toolDescription({
|
|
@@ -2155,6 +2910,116 @@ export function registerTools(server) {
|
|
|
2155
2910
|
}
|
|
2156
2911
|
return textResult({ leads, ...meta });
|
|
2157
2912
|
});
|
|
2913
|
+
server.registerTool("oko_list_telegram_channels", {
|
|
2914
|
+
title: "List Telegram channels",
|
|
2915
|
+
description: toolDescription({
|
|
2916
|
+
summary: "List Telegram channels available to the authenticated organizer.",
|
|
2917
|
+
args: ["includeWorkspaceChannels: include channels exposed through visible workspaces, default true."],
|
|
2918
|
+
returns: "channels with chatId, title, username, source, and visible workspace metadata.",
|
|
2919
|
+
examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
|
|
2920
|
+
}),
|
|
2921
|
+
inputSchema: { includeWorkspaceChannels: z.boolean().default(true).describe("Include workspace-level channels visible to this organizer.") },
|
|
2922
|
+
outputSchema: telegramChannelsOutputSchema,
|
|
2923
|
+
annotations: readOnlyTool,
|
|
2924
|
+
}, async ({ includeWorkspaceChannels }) => {
|
|
2925
|
+
const { uid } = await authed(["telegram:write"]);
|
|
2926
|
+
const channels = await readTelegramChannels(uid, includeWorkspaceChannels);
|
|
2927
|
+
return textResult({ channels, count: channels.length });
|
|
2928
|
+
});
|
|
2929
|
+
server.registerTool("oko_claim_telegram_channel", {
|
|
2930
|
+
title: "Claim Telegram channel",
|
|
2931
|
+
description: toolDescription({
|
|
2932
|
+
summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
|
|
2933
|
+
args: [
|
|
2934
|
+
"claimCode: code posted by the bot after it was added to a channel.",
|
|
2935
|
+
"workspaceIds: optional workspaces to expose this channel to after claim.",
|
|
2936
|
+
],
|
|
2937
|
+
returns: "claimed channel and workspace ids updated.",
|
|
2938
|
+
examples: ["Add @OKOStreamBot to a Telegram channel, copy the claim code, then call this tool."],
|
|
2939
|
+
notes: ["Workspace sync requires the authenticated organizer to own each workspace."],
|
|
2940
|
+
}),
|
|
2941
|
+
inputSchema: {
|
|
2942
|
+
claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
|
|
2943
|
+
workspaceIds: z.array(z.string().min(1)).max(20).default([]).describe("Optional workspace ids to attach the channel to."),
|
|
2944
|
+
},
|
|
2945
|
+
outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
|
|
2946
|
+
annotations: writeTool,
|
|
2947
|
+
}, async ({ claimCode, workspaceIds }) => {
|
|
2948
|
+
const { uid } = await authed(["telegram:write"]);
|
|
2949
|
+
const claimChannel = httpsCallable(functions, "claimTelegramChannel");
|
|
2950
|
+
const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
|
|
2951
|
+
const data = serialize(result.data);
|
|
2952
|
+
const channelRaw = data.channel && typeof data.channel === "object" ? data.channel : {};
|
|
2953
|
+
const parsed = telegramChannelSchema.safeParse(channelRaw);
|
|
2954
|
+
if (!parsed.success)
|
|
2955
|
+
throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
|
|
2956
|
+
const workspaceIdsUpdated = await syncTelegramChannelToWorkspaces(uid, parsed.data, workspaceIds);
|
|
2957
|
+
return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated });
|
|
2958
|
+
});
|
|
2959
|
+
server.registerTool("oko_list_telegram_posts", {
|
|
2960
|
+
title: "List Telegram posts",
|
|
2961
|
+
description: toolDescription({
|
|
2962
|
+
summary: "List Telegram notification posts for an event.",
|
|
2963
|
+
args: [
|
|
2964
|
+
"eventId: event whose Telegram posts to list.",
|
|
2965
|
+
"status: optional client-side status filter.",
|
|
2966
|
+
"limit: max posts, default 30, max 100.",
|
|
2967
|
+
"cursor: createdAt cursor returned by previous page.",
|
|
2968
|
+
],
|
|
2969
|
+
returns: "posts plus cursor pagination metadata.",
|
|
2970
|
+
examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."],
|
|
2971
|
+
}),
|
|
2972
|
+
inputSchema: {
|
|
2973
|
+
eventId: eventIdField,
|
|
2974
|
+
status: z.enum(TELEGRAM_POST_STATUSES).optional().describe("Optional post status filter."),
|
|
2975
|
+
limit: limitField(),
|
|
2976
|
+
cursor: cursorField,
|
|
2977
|
+
},
|
|
2978
|
+
outputSchema: telegramPostsOutputSchema,
|
|
2979
|
+
annotations: readOnlyTool,
|
|
2980
|
+
}, async ({ eventId, status, limit: max, cursor }) => {
|
|
2981
|
+
const { uid } = await authed(["telegram:write"]);
|
|
2982
|
+
await readEvent(eventId, uid, "read");
|
|
2983
|
+
const constraints = [
|
|
2984
|
+
where("eventId", "==", eventId),
|
|
2985
|
+
orderBy("createdAt", "desc"),
|
|
2986
|
+
limit(max + 1),
|
|
2987
|
+
];
|
|
2988
|
+
if (cursor)
|
|
2989
|
+
constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
|
|
2990
|
+
const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
|
|
2991
|
+
const { page, hasMore } = pickPage(snap.docs, max);
|
|
2992
|
+
let posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
|
|
2993
|
+
if (status)
|
|
2994
|
+
posts = posts.filter((post) => post.status === status);
|
|
2995
|
+
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
|
|
2996
|
+
return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
|
|
2997
|
+
});
|
|
2998
|
+
server.registerTool("oko_cancel_telegram_post", {
|
|
2999
|
+
title: "Cancel Telegram post",
|
|
3000
|
+
description: toolDescription({
|
|
3001
|
+
summary: "Delete a draft/scheduled/failed Telegram post record before it is sent.",
|
|
3002
|
+
args: ["postId: telegramPosts document id.", "confirm: must be true."],
|
|
3003
|
+
returns: "postId, eventId, status, and canceled=true.",
|
|
3004
|
+
examples: ["Use for scheduled posts that should not be sent."],
|
|
3005
|
+
notes: ["Already sent Telegram messages cannot be unsent through OKO; this tool refuses status=sent."],
|
|
3006
|
+
}),
|
|
3007
|
+
inputSchema: { postId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for cancellation.") },
|
|
3008
|
+
outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
|
|
3009
|
+
annotations: destructiveTool,
|
|
3010
|
+
}, async ({ postId, confirm }) => {
|
|
3011
|
+
await authed(["telegram:write"]);
|
|
3012
|
+
const postRef = doc(firestore, "telegramPosts", postId);
|
|
3013
|
+
const postSnap = await getDoc(postRef);
|
|
3014
|
+
if (!postSnap.exists())
|
|
3015
|
+
throw new Error("Telegram post not found.");
|
|
3016
|
+
const post = postSnap.data();
|
|
3017
|
+
if (post.status === "sent")
|
|
3018
|
+
throw new Error("Telegram post was already sent and cannot be canceled through OKO.");
|
|
3019
|
+
requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
|
|
3020
|
+
await deleteDoc(postRef);
|
|
3021
|
+
return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
|
|
3022
|
+
});
|
|
2158
3023
|
server.registerTool("oko_publish_telegram_post", {
|
|
2159
3024
|
title: "Publish Telegram post",
|
|
2160
3025
|
description: toolDescription({
|
|
@@ -2217,45 +3082,41 @@ export function registerTools(server) {
|
|
|
2217
3082
|
summary: "Create a workspace invite for a known OKO workspace.",
|
|
2218
3083
|
args: [
|
|
2219
3084
|
"workspaceId: workspace owned by the authenticated organizer.",
|
|
2220
|
-
"code: invite code, 4-64 chars;
|
|
3085
|
+
"code: optional invite code, 4-64 chars; omitted means generated.",
|
|
2221
3086
|
"email: optional intended recipient email.",
|
|
2222
3087
|
"permissions: optional whitelist of workspace permission booleans.",
|
|
2223
3088
|
"confirm: must be true.",
|
|
2224
3089
|
],
|
|
2225
|
-
returns: "workspaceId, invite code, and created=true.",
|
|
3090
|
+
returns: "workspaceId, invite code, inviteUrl, and created=true.",
|
|
2226
3091
|
examples: ["Use only after confirming the recipient and permission set with the workspace owner."],
|
|
2227
3092
|
}),
|
|
2228
3093
|
inputSchema: {
|
|
2229
3094
|
workspaceId: z.string().min(1),
|
|
2230
|
-
code: z.string().min(4).max(
|
|
3095
|
+
code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
|
|
2231
3096
|
email: z.string().email().optional(),
|
|
2232
3097
|
comment: z.string().max(500).default(""),
|
|
2233
3098
|
permissions: workspacePermissionsSchema,
|
|
2234
3099
|
confirm: z.boolean().optional(),
|
|
2235
3100
|
},
|
|
2236
|
-
outputSchema: z.object({ workspaceId: z.string(), code: z.string(), created: z.boolean() }).passthrough(),
|
|
3101
|
+
outputSchema: z.object({ workspaceId: z.string(), code: z.string(), inviteUrl: z.string().url(), created: z.boolean() }).passthrough(),
|
|
2237
3102
|
annotations: writeTool,
|
|
2238
3103
|
}, async (args) => {
|
|
2239
3104
|
const { uid } = await authed(["workspace:write"]);
|
|
2240
|
-
const
|
|
2241
|
-
if (!workspaceSnap.exists())
|
|
2242
|
-
throw new Error("Workspace not found.");
|
|
2243
|
-
const workspace = workspaceSnap.data();
|
|
2244
|
-
if (workspace.ownerId !== uid)
|
|
2245
|
-
throw new Error("Only the workspace owner can create invites.");
|
|
3105
|
+
const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
|
|
2246
3106
|
requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
|
|
2247
|
-
const code = args.code
|
|
3107
|
+
const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
|
|
3108
|
+
const inviteUrl = `https://okostream.app/invite/${code}`;
|
|
2248
3109
|
await setDoc(doc(firestore, "workspaceInvites", code), {
|
|
2249
3110
|
workspaceId: args.workspaceId,
|
|
2250
3111
|
workspaceName: safeString(workspace.name, "Workspace"),
|
|
2251
3112
|
email: args.email ?? "",
|
|
2252
3113
|
comment: args.comment,
|
|
2253
|
-
permissions: args.permissions,
|
|
3114
|
+
permissions: normalizeWorkspacePermissions(args.permissions),
|
|
2254
3115
|
createdBy: uid,
|
|
2255
3116
|
createdAt: serverTimestamp(),
|
|
2256
3117
|
claimed: false,
|
|
2257
3118
|
claimedBy: null,
|
|
2258
3119
|
});
|
|
2259
|
-
return textResult({ workspaceId: args.workspaceId, code, created: true });
|
|
3120
|
+
return textResult({ workspaceId: args.workspaceId, code, inviteUrl, created: true });
|
|
2260
3121
|
});
|
|
2261
3122
|
}
|