@okostream/mcp 0.5.4 → 0.6.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/CHANGELOG.md +7 -0
- package/README.md +6 -3
- package/dist/guides.js +11 -9
- package/dist/server.js +1 -1
- package/dist/tools.js +235 -139
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.0 - 2026-07-10
|
|
4
|
+
|
|
5
|
+
- Replaced legacy profile/workspace Telegram arrays with canonical server-owned channel operations.
|
|
6
|
+
- Added personal Telegram linking, explicit workspace sharing, channel disconnect, and event-scoped channel discovery.
|
|
7
|
+
- Added 30-day event trash, restore, and exact-title permanent deletion tools; retained `oko_delete_event` as a recoverable trash alias.
|
|
8
|
+
- Prevented cancellation of `delivery_unknown` Telegram posts and resolved channels server-side before publication to reduce duplicate or unauthorized sends.
|
|
9
|
+
|
|
3
10
|
## 0.5.4 - 2026-07-10
|
|
4
11
|
|
|
5
12
|
- Separated NMO from the standard webinar workflow throughout instructions, resources, prompts, README, and the companion skill.
|
package/README.md
CHANGED
|
@@ -127,7 +127,8 @@ Core discovery/read tools:
|
|
|
127
127
|
|
|
128
128
|
Event/setup/media tools:
|
|
129
129
|
|
|
130
|
-
- `oko_create_webinar`, `oko_update_event`, `oko_delete_event`
|
|
130
|
+
- `oko_create_webinar`, `oko_update_event`, `oko_delete_event` (30-day trash compatibility alias)
|
|
131
|
+
- `oko_trash_event`, `oko_restore_event`, `oko_delete_event_permanently`
|
|
131
132
|
- `oko_duplicate_event`, `oko_move_event`
|
|
132
133
|
- `oko_configure_blocks`, `oko_configure_cards`
|
|
133
134
|
- `oko_upload_image`, `oko_upload_card_images_batch`, `oko_wait_for_image_variants`, `oko_delete_image`
|
|
@@ -145,7 +146,9 @@ Workspace and Telegram tools:
|
|
|
145
146
|
- `oko_create_workspace`, `oko_update_workspace`, `oko_delete_workspace`
|
|
146
147
|
- `oko_list_workspace_access`, `oko_update_workspace_member`, `oko_remove_workspace_member`
|
|
147
148
|
- `oko_create_workspace_invite`, `oko_list_workspace_invites`, `oko_revoke_workspace_invite`, `oko_claim_workspace_invite`
|
|
148
|
-
- `
|
|
149
|
+
- `oko_create_telegram_link`, `oko_list_telegram_channels`, `oko_claim_telegram_channel`
|
|
150
|
+
- `oko_set_telegram_channel_workspaces`, `oko_disconnect_telegram_channel`
|
|
151
|
+
- `oko_list_telegram_posts`, `oko_cancel_telegram_post`, `oko_publish_telegram_post`
|
|
149
152
|
|
|
150
153
|
Dangerous or external side-effect tools require `confirm: true`.
|
|
151
154
|
|
|
@@ -163,7 +166,7 @@ Dangerous or external side-effect tools require `confirm: true`.
|
|
|
163
166
|
10. `oko_get_session_report` should return markdown and structured session data for a completed session.
|
|
164
167
|
11. `oko_list_notifications` -> `oko_mark_notifications_read` should read and clear an own disposable notification.
|
|
165
168
|
12. `oko_get_analytics` should read an existing event with analytics sessions.
|
|
166
|
-
13. MCP protocol smoke should report
|
|
169
|
+
13. MCP protocol smoke should report 65 tools, 7 resources, 3 prompts, and non-empty initialize instructions.
|
|
167
170
|
|
|
168
171
|
CLI help:
|
|
169
172
|
|
package/dist/guides.js
CHANGED
|
@@ -118,14 +118,14 @@ const GUIDE_RESOURCES = [
|
|
|
118
118
|
"",
|
|
119
119
|
"- `events:read`: list and read event documents.",
|
|
120
120
|
"- `events:write`: create, duplicate, move, and update event metadata.",
|
|
121
|
-
"- `events:delete`: delete
|
|
121
|
+
"- `events:delete`: move events to trash, restore them, and permanently delete already-trashed events.",
|
|
122
122
|
"- `setup:write`: configure blocks, forms, cards, and images.",
|
|
123
123
|
"- `analytics:read`: read live state, sessions, reports, chat history.",
|
|
124
124
|
"- `analytics:write` + `live:control`: start/end/fix/delete analytics sessions.",
|
|
125
125
|
"- `live:control`: live block visibility and viewer refresh.",
|
|
126
126
|
"- `chat:write`: send organizer chat messages.",
|
|
127
127
|
"- `chat:moderate`: delete, mute, ban, unmute, unban, and chat settings.",
|
|
128
|
-
"- `telegram:write`: claim/list channels and create/cancel/publish Telegram posts.",
|
|
128
|
+
"- `telegram:write`: link personal Telegram, claim/list/share/disconnect channels, and create/cancel/publish Telegram posts.",
|
|
129
129
|
"- `workspace:read` / `workspace:write`: workspace, members, invites, notifications.",
|
|
130
130
|
"- `nmo:write`: NMO participants and checkpoints.",
|
|
131
131
|
"- `leads:read`: read form submissions; lead deletion also requires `setup:write`.",
|
|
@@ -166,16 +166,18 @@ const GUIDE_RESOURCES = [
|
|
|
166
166
|
"# Connect Telegram and Publish Webinar Posts",
|
|
167
167
|
"",
|
|
168
168
|
"## Claim a channel",
|
|
169
|
-
"1.
|
|
170
|
-
"2.
|
|
171
|
-
"3.
|
|
172
|
-
"4. Call `
|
|
173
|
-
"5. Call `
|
|
169
|
+
"1. Call `oko_create_telegram_link`, open its one-time @OKOStreamBot link in the organizer's personal Telegram account, and complete the link there.",
|
|
170
|
+
"2. Add `@OKOStreamBot` to the target channel as an administrator with permission to post.",
|
|
171
|
+
"3. The bot provides a claim code for that channel. Copy it exactly; do not invent a channel ID.",
|
|
172
|
+
"4. Call `oko_claim_telegram_channel` with the claim code, optional owned workspace ids, and `confirm:true`. The server verifies that the linked personal Telegram user is a channel administrator.",
|
|
173
|
+
"5. Call `oko_list_telegram_channels` and choose a channel by its returned title and id. Pass `eventId` when workspace-shared channels should be included.",
|
|
174
|
+
"6. Use `oko_set_telegram_channel_workspaces` to replace explicit workspace sharing, or `oko_disconnect_telegram_channel` to disconnect an owned channel; both require confirmation.",
|
|
175
|
+
"7. Call `oko_configure_blocks` with `telegramChannelIds` to connect the claimed channel to an event.",
|
|
174
176
|
"",
|
|
175
177
|
"## Publish safely",
|
|
176
178
|
"1. Use `oko_publish_telegram_post` with the event id, returned channel id, caption, optional image/button, and `confirm:true`.",
|
|
177
179
|
"2. Include `scheduleAt` for a scheduled post. Without it, the tool sends immediately to the real channel, so show the exact channel and caption before confirmation.",
|
|
178
|
-
"3. Use `oko_list_telegram_posts` to inspect draft, scheduled, sent, or
|
|
180
|
+
"3. Use `oko_list_telegram_posts` to inspect draft, scheduled, sent, failed, or delivery-unknown posts. Only cancel draft, pending, scheduled, or failed posts with `oko_cancel_telegram_post` and `confirm:true`; review delivery-unknown manually to avoid duplicate publication.",
|
|
179
181
|
"",
|
|
180
182
|
"## Automatic reminders",
|
|
181
183
|
"OKO has a platform auto-reminder job for opted-in events. The current MCP surface does not toggle `telegram.autoReminder`, so an MCP-only workflow should create a scheduled reminder post explicitly. Use the web setup if the organizer specifically needs the platform auto-reminder setting.",
|
|
@@ -190,7 +192,7 @@ const GUIDE_RESOURCES = [
|
|
|
190
192
|
"# OKO MCP Capabilities",
|
|
191
193
|
"",
|
|
192
194
|
"## Webinar setup",
|
|
193
|
-
"- Create, duplicate, move, update, validate,
|
|
195
|
+
"- Create, duplicate, move, update, and validate webinars; trash them for 30 days, restore them, or permanently delete an already-trashed event with exact-title confirmation.",
|
|
194
196
|
"- Configure forms, CTA, banner, cards, video placeholder, and Telegram settings for standard webinars.",
|
|
195
197
|
"- Upload images from local paths, data URLs, or HTTPS URLs and wait for variants.",
|
|
196
198
|
"",
|
package/dist/server.js
CHANGED
package/dist/tools.js
CHANGED
|
@@ -2,7 +2,7 @@ import { lookup } from "node:dns/promises";
|
|
|
2
2
|
import { stat, readFile, realpath } from "node:fs/promises";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
6
|
import sharp from "sharp";
|
|
7
7
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
8
8
|
import { z } from "zod";
|
|
@@ -43,7 +43,7 @@ const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
|
|
|
43
43
|
const MAX_NMO_IMPORT_PARTICIPANTS = 200;
|
|
44
44
|
const MAX_WORKSPACE_NAME_LENGTH = 120;
|
|
45
45
|
const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
|
|
46
|
-
const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed"];
|
|
46
|
+
const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed", "delivery_unknown"];
|
|
47
47
|
const readOnlyTool = {
|
|
48
48
|
readOnlyHint: true,
|
|
49
49
|
destructiveHint: false,
|
|
@@ -145,6 +145,8 @@ const imageOutputSchema = z.object({
|
|
|
145
145
|
variantsPending: z.boolean(),
|
|
146
146
|
sourceBytes: z.number().int().nonnegative(),
|
|
147
147
|
masterBytes: z.number().int().nonnegative(),
|
|
148
|
+
imageWidth: z.number().int().positive(),
|
|
149
|
+
imageHeight: z.number().int().positive(),
|
|
148
150
|
note: z.string(),
|
|
149
151
|
}).passthrough();
|
|
150
152
|
const deleteImageOutputSchema = z.object({
|
|
@@ -275,6 +277,10 @@ const telegramChannelSchema = z.object({
|
|
|
275
277
|
title: z.string().max(200).optional(),
|
|
276
278
|
username: z.string().max(120).nullable().optional(),
|
|
277
279
|
}).strict();
|
|
280
|
+
const canonicalTelegramChannelSchema = telegramChannelSchema.extend({
|
|
281
|
+
workspaceIds: z.array(z.string()).max(50),
|
|
282
|
+
owned: z.boolean(),
|
|
283
|
+
}).strict();
|
|
278
284
|
const profileDisplayNameSchema = z.string().max(40).nullable();
|
|
279
285
|
const timezoneSchema = z.string()
|
|
280
286
|
.min(1)
|
|
@@ -346,7 +352,7 @@ function pickPage(items, max) {
|
|
|
346
352
|
};
|
|
347
353
|
}
|
|
348
354
|
function randomId() {
|
|
349
|
-
return
|
|
355
|
+
return randomUUID();
|
|
350
356
|
}
|
|
351
357
|
function imageStoragePath(eventId, uid, kind, imageId) {
|
|
352
358
|
return `events/${eventId}/${uid}/images/${kind}/${imageId}/master.webp`;
|
|
@@ -631,6 +637,8 @@ function readCardsConfig(eventData) {
|
|
|
631
637
|
imageUrl: safeString(item.imageUrl).trim() || null,
|
|
632
638
|
imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
|
|
633
639
|
imageVariants: readImageVariants(item.imageVariants),
|
|
640
|
+
imageWidth: typeof item.imageWidth === "number" ? item.imageWidth : null,
|
|
641
|
+
imageHeight: typeof item.imageHeight === "number" ? item.imageHeight : null,
|
|
634
642
|
formId: safeString(item.formId).trim() || null,
|
|
635
643
|
};
|
|
636
644
|
});
|
|
@@ -702,6 +710,8 @@ function buildCardsItems({ inputs, existing, formIds, }) {
|
|
|
702
710
|
imageUrl: previous?.imageUrl ?? null,
|
|
703
711
|
imageMasterUrl: previous?.imageMasterUrl ?? null,
|
|
704
712
|
imageVariants: previous?.imageVariants ?? null,
|
|
713
|
+
imageWidth: previous?.imageWidth ?? null,
|
|
714
|
+
imageHeight: previous?.imageHeight ?? null,
|
|
705
715
|
formId,
|
|
706
716
|
};
|
|
707
717
|
});
|
|
@@ -814,49 +824,28 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
|
814
824
|
const previous = readImageRef(event, kind, cardId);
|
|
815
825
|
const sourceBuffer = await readImageSource(source);
|
|
816
826
|
const masterBuffer = await compressToMasterWebp(sourceBuffer);
|
|
817
|
-
const imageId = kind === "card" ? cardId : randomUUID();
|
|
818
|
-
const masterPath = imageStoragePath(eventId, uid, kind, imageId);
|
|
819
827
|
const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
|
|
820
828
|
const deleteWarnings = [];
|
|
821
829
|
let deletesAttempted = 0;
|
|
822
|
-
|
|
823
|
-
|
|
830
|
+
const expectedCardPath = kind === "card" ? imageStoragePath(eventId, uid, kind, cardId) : null;
|
|
831
|
+
if (expectedCardPath && previousMasterPath === expectedCardPath) {
|
|
832
|
+
const variantPaths = variantPathsFromMasterPath(expectedCardPath);
|
|
824
833
|
deletesAttempted += variantPaths.length;
|
|
825
834
|
deleteWarnings.push(...await deleteStoragePaths(eventId, variantPaths));
|
|
826
835
|
}
|
|
827
|
-
let
|
|
836
|
+
let uploaded = null;
|
|
828
837
|
try {
|
|
829
|
-
|
|
830
|
-
masterUrl = uploaded.masterUrl;
|
|
831
|
-
if (kind === "promoBanner") {
|
|
832
|
-
await updateDoc(doc(firestore, "events", eventId), {
|
|
833
|
-
"blocks.promoBanner.imageMasterUrl": masterUrl,
|
|
834
|
-
"blocks.promoBanner.imageUrl": masterUrl,
|
|
835
|
-
"blocks.promoBanner.imageVariants": null,
|
|
836
|
-
});
|
|
837
|
-
}
|
|
838
|
-
else if (kind === "videoPlaceholder") {
|
|
839
|
-
await updateDoc(doc(firestore, "events", eventId), {
|
|
840
|
-
"blocks.video.placeholder.imageMasterUrl": masterUrl,
|
|
841
|
-
"blocks.video.placeholder.imageUrl": masterUrl,
|
|
842
|
-
"blocks.video.placeholder.imageVariants": null,
|
|
843
|
-
});
|
|
844
|
-
}
|
|
845
|
-
else {
|
|
846
|
-
await updateCardImageInTransaction(eventId, cardId, {
|
|
847
|
-
imageUrl: masterUrl,
|
|
848
|
-
imageMasterUrl: masterUrl,
|
|
849
|
-
imageVariants: null,
|
|
850
|
-
});
|
|
851
|
-
}
|
|
838
|
+
uploaded = await uploadMasterImage(eventId, kind, cardId, masterBuffer);
|
|
852
839
|
}
|
|
853
840
|
catch (err) {
|
|
854
|
-
if (
|
|
855
|
-
await deleteStoragePaths(eventId, [
|
|
841
|
+
if (uploaded) {
|
|
842
|
+
await deleteStoragePaths(eventId, [uploaded.storagePath, ...variantPathsFromMasterPath(uploaded.storagePath)]).catch(() => []);
|
|
856
843
|
}
|
|
857
844
|
throw err;
|
|
858
845
|
}
|
|
859
|
-
if (
|
|
846
|
+
if (!uploaded)
|
|
847
|
+
throw new Error("Image upload did not return a result.");
|
|
848
|
+
if (kind !== "card" || (previousMasterPath && previousMasterPath !== uploaded.storagePath) || (!previous.imageMasterUrl && previous.imageUrl)) {
|
|
860
849
|
const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
|
|
861
850
|
deletesAttempted += cleanup.attempted;
|
|
862
851
|
deleteWarnings.push(...cleanup.warnings);
|
|
@@ -864,12 +853,14 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
|
864
853
|
return {
|
|
865
854
|
eventId,
|
|
866
855
|
kind,
|
|
867
|
-
imageId,
|
|
868
|
-
storagePath:
|
|
869
|
-
masterUrl,
|
|
856
|
+
imageId: uploaded.imageId,
|
|
857
|
+
storagePath: uploaded.storagePath,
|
|
858
|
+
masterUrl: uploaded.masterUrl,
|
|
870
859
|
variantsPending: true,
|
|
871
860
|
sourceBytes: sourceBuffer.byteLength,
|
|
872
|
-
masterBytes:
|
|
861
|
+
masterBytes: uploaded.masterBytes,
|
|
862
|
+
imageWidth: uploaded.imageWidth,
|
|
863
|
+
imageHeight: uploaded.imageHeight,
|
|
873
864
|
note: "Image master uploaded. CF generateImageVariants should publish thumb/card/hero variants and update imageUrl/imageVariants in about 10-30 seconds.",
|
|
874
865
|
storageDeletesAttempted: deletesAttempted,
|
|
875
866
|
storageDeleteWarnings: deleteWarnings,
|
|
@@ -955,6 +946,9 @@ async function readEvent(eventId, uid, mode = "read") {
|
|
|
955
946
|
assertEventReadable(data, uid);
|
|
956
947
|
else
|
|
957
948
|
assertEventAccess(data, uid, mode);
|
|
949
|
+
if (data.trashedAt && mode !== "read" && mode !== "delete") {
|
|
950
|
+
throw new Error("Event is in trash. Restore it before making changes or live actions.");
|
|
951
|
+
}
|
|
958
952
|
return data;
|
|
959
953
|
}
|
|
960
954
|
function asRecord(value) {
|
|
@@ -1112,16 +1106,11 @@ function initialEventAccess(uid) {
|
|
|
1112
1106
|
async function addEventWithRtdbMirror(uid, workspaceId, eventData) {
|
|
1113
1107
|
const eventRef = await addDoc(collection(firestore, "events"), eventData);
|
|
1114
1108
|
try {
|
|
1115
|
-
|
|
1109
|
+
const ensureRuntime = httpsCallable(functions, "ensureEventRuntime");
|
|
1110
|
+
await ensureRuntime({ eventId: eventRef.id });
|
|
1116
1111
|
}
|
|
1117
1112
|
catch (error) {
|
|
1118
|
-
|
|
1119
|
-
await deleteDoc(eventRef);
|
|
1120
|
-
}
|
|
1121
|
-
catch (rollbackError) {
|
|
1122
|
-
throw new AggregateError([error, rollbackError], `RTDB mirror failed for event ${eventRef.id}, and Firestore rollback also failed.`);
|
|
1123
|
-
}
|
|
1124
|
-
throw error;
|
|
1113
|
+
console.error(`[OKO MCP] RTDB mirror deferred to onEventCreated for event ${eventRef.id}:`, error);
|
|
1125
1114
|
}
|
|
1126
1115
|
return eventRef;
|
|
1127
1116
|
}
|
|
@@ -1160,7 +1149,7 @@ function normalizeWorkspacePermissions(value) {
|
|
|
1160
1149
|
return out;
|
|
1161
1150
|
}
|
|
1162
1151
|
function randomInviteCode() {
|
|
1163
|
-
return
|
|
1152
|
+
return randomBytes(8).toString("hex").toUpperCase();
|
|
1164
1153
|
}
|
|
1165
1154
|
function normalizeEmail(value) {
|
|
1166
1155
|
return value.trim().toLowerCase();
|
|
@@ -1236,43 +1225,6 @@ function parseNmoParticipantsCsv(csv) {
|
|
|
1236
1225
|
return parsed.data;
|
|
1237
1226
|
});
|
|
1238
1227
|
}
|
|
1239
|
-
async function readTelegramChannels(uid, includeWorkspaceChannels) {
|
|
1240
|
-
const channelsById = new Map();
|
|
1241
|
-
const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
|
|
1242
|
-
const profileChannels = Array.isArray(profileSnap.data()?.telegramChannels) ? profileSnap.data()?.telegramChannels : [];
|
|
1243
|
-
for (const raw of profileChannels) {
|
|
1244
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1245
|
-
continue;
|
|
1246
|
-
const ch = raw;
|
|
1247
|
-
if (typeof ch.chatId !== "number")
|
|
1248
|
-
continue;
|
|
1249
|
-
channelsById.set(ch.chatId, { ...serialize(ch), source: "profile" });
|
|
1250
|
-
}
|
|
1251
|
-
if (includeWorkspaceChannels) {
|
|
1252
|
-
const workspaces = await listVisibleWorkspaces(uid, true);
|
|
1253
|
-
for (const workspace of workspaces) {
|
|
1254
|
-
const workspaceId = safeString(workspace.id);
|
|
1255
|
-
const workspaceName = safeString(workspace.name);
|
|
1256
|
-
const channels = Array.isArray(workspace.telegramChannels) ? workspace.telegramChannels : [];
|
|
1257
|
-
for (const raw of channels) {
|
|
1258
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
1259
|
-
continue;
|
|
1260
|
-
const ch = raw;
|
|
1261
|
-
if (typeof ch.chatId !== "number")
|
|
1262
|
-
continue;
|
|
1263
|
-
const prev = channelsById.get(ch.chatId) ?? {};
|
|
1264
|
-
const visibleInWorkspaces = Array.isArray(prev.visibleInWorkspaces) ? prev.visibleInWorkspaces : [];
|
|
1265
|
-
channelsById.set(ch.chatId, {
|
|
1266
|
-
...prev,
|
|
1267
|
-
...serialize(ch),
|
|
1268
|
-
source: prev.source ? "profile+workspace" : "workspace",
|
|
1269
|
-
visibleInWorkspaces: [...visibleInWorkspaces, { id: workspaceId, name: workspaceName }],
|
|
1270
|
-
});
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
return [...channelsById.values()].sort((a, b) => safeString(a.title).localeCompare(safeString(b.title)));
|
|
1275
|
-
}
|
|
1276
1228
|
async function listVisibleWorkspaces(uid, includeShared) {
|
|
1277
1229
|
const out = [];
|
|
1278
1230
|
const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
@@ -1291,34 +1243,6 @@ async function listVisibleWorkspaces(uid, includeShared) {
|
|
|
1291
1243
|
}
|
|
1292
1244
|
return out;
|
|
1293
1245
|
}
|
|
1294
|
-
async function syncTelegramChannelToWorkspaces(uid, channel, workspaceIds) {
|
|
1295
|
-
const updated = [];
|
|
1296
|
-
if (workspaceIds.length === 0)
|
|
1297
|
-
return updated;
|
|
1298
|
-
const profileRef = doc(firestore, "userProfiles", uid);
|
|
1299
|
-
const profileSnap = await getDoc(profileRef);
|
|
1300
|
-
const channels = Array.isArray(profileSnap.data()?.telegramChannels) ? [...profileSnap.data().telegramChannels] : [];
|
|
1301
|
-
const nextProfileChannels = channels.map((item) => {
|
|
1302
|
-
if (item.chatId !== channel.chatId)
|
|
1303
|
-
return item;
|
|
1304
|
-
const current = Array.isArray(item.workspaceIds) ? item.workspaceIds.filter((id) => typeof id === "string") : [];
|
|
1305
|
-
return { ...item, workspaceIds: [...new Set([...current, ...workspaceIds])] };
|
|
1306
|
-
});
|
|
1307
|
-
await setDoc(profileRef, { telegramChannels: nextProfileChannels }, { merge: true });
|
|
1308
|
-
for (const workspaceId of workspaceIds) {
|
|
1309
|
-
await readWorkspaceForOwner(workspaceId, uid);
|
|
1310
|
-
const wsRef = doc(firestore, "workspaces", workspaceId);
|
|
1311
|
-
const wsSnap = await getDoc(wsRef);
|
|
1312
|
-
const wsChannels = Array.isArray(wsSnap.data()?.telegramChannels) ? wsSnap.data().telegramChannels : [];
|
|
1313
|
-
if (!wsChannels.some((item) => item.chatId === channel.chatId)) {
|
|
1314
|
-
await updateDoc(wsRef, {
|
|
1315
|
-
telegramChannels: [...wsChannels, { chatId: channel.chatId, title: channel.title ?? "", username: channel.username ?? null }],
|
|
1316
|
-
});
|
|
1317
|
-
updated.push(workspaceId);
|
|
1318
|
-
}
|
|
1319
|
-
}
|
|
1320
|
-
return updated;
|
|
1321
|
-
}
|
|
1322
1246
|
async function readAgg(eventId) {
|
|
1323
1247
|
const snap = await get(ref(rtdb, `events/${eventId}/agg`));
|
|
1324
1248
|
const agg = (snap.val() ?? {});
|
|
@@ -2171,7 +2095,10 @@ export function registerTools(server) {
|
|
|
2171
2095
|
outputSchema: updateOutputSchema,
|
|
2172
2096
|
annotations: writeTool,
|
|
2173
2097
|
}, async (args) => {
|
|
2174
|
-
const
|
|
2098
|
+
const requiredScopes = ["setup:write"];
|
|
2099
|
+
if (args.nmo !== undefined)
|
|
2100
|
+
requiredScopes.push("nmo:write");
|
|
2101
|
+
const { uid } = await authed(requiredScopes);
|
|
2175
2102
|
await readEvent(args.eventId, uid, "edit");
|
|
2176
2103
|
const patch = {};
|
|
2177
2104
|
if (args.chat?.enabled !== undefined)
|
|
@@ -2489,6 +2416,8 @@ export function registerTools(server) {
|
|
|
2489
2416
|
"blocks.promoBanner.imageMasterUrl": deleteField(),
|
|
2490
2417
|
"blocks.promoBanner.imageUrl": deleteField(),
|
|
2491
2418
|
"blocks.promoBanner.imageVariants": deleteField(),
|
|
2419
|
+
"blocks.promoBanner.imageWidth": deleteField(),
|
|
2420
|
+
"blocks.promoBanner.imageHeight": deleteField(),
|
|
2492
2421
|
});
|
|
2493
2422
|
}
|
|
2494
2423
|
else if (kind === "videoPlaceholder") {
|
|
@@ -2496,6 +2425,8 @@ export function registerTools(server) {
|
|
|
2496
2425
|
"blocks.video.placeholder.imageMasterUrl": deleteField(),
|
|
2497
2426
|
"blocks.video.placeholder.imageUrl": deleteField(),
|
|
2498
2427
|
"blocks.video.placeholder.imageVariants": deleteField(),
|
|
2428
|
+
"blocks.video.placeholder.imageWidth": deleteField(),
|
|
2429
|
+
"blocks.video.placeholder.imageHeight": deleteField(),
|
|
2499
2430
|
});
|
|
2500
2431
|
}
|
|
2501
2432
|
else {
|
|
@@ -2503,6 +2434,8 @@ export function registerTools(server) {
|
|
|
2503
2434
|
imageUrl: null,
|
|
2504
2435
|
imageMasterUrl: null,
|
|
2505
2436
|
imageVariants: null,
|
|
2437
|
+
imageWidth: null,
|
|
2438
|
+
imageHeight: null,
|
|
2506
2439
|
});
|
|
2507
2440
|
}
|
|
2508
2441
|
return textResult({
|
|
@@ -2515,25 +2448,92 @@ export function registerTools(server) {
|
|
|
2515
2448
|
});
|
|
2516
2449
|
});
|
|
2517
2450
|
server.registerTool("oko_delete_event", {
|
|
2518
|
-
title: "
|
|
2451
|
+
title: "Move event to trash",
|
|
2519
2452
|
description: toolDescription({
|
|
2520
|
-
summary: "
|
|
2521
|
-
args: ["eventId: event to
|
|
2522
|
-
returns: "
|
|
2523
|
-
examples: ["Use
|
|
2524
|
-
notes: ["
|
|
2453
|
+
summary: "Move an OKO event to the 30-day trash. This compatibility alias has the same behavior as oko_trash_event.",
|
|
2454
|
+
args: ["eventId: event to move to trash.", "confirm: must be true."],
|
|
2455
|
+
returns: "trashed event id and purge deadline in epoch milliseconds.",
|
|
2456
|
+
examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
|
|
2457
|
+
notes: ["Recoverable for 30 days with oko_restore_event. This does not create an event lifecycle status."],
|
|
2525
2458
|
}),
|
|
2526
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true
|
|
2527
|
-
outputSchema: z.object({
|
|
2459
|
+
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
|
|
2460
|
+
outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
|
|
2528
2461
|
annotations: destructiveTool,
|
|
2529
2462
|
}, async ({ eventId, confirm }) => {
|
|
2530
2463
|
const { uid } = await authed(["events:delete"]);
|
|
2531
2464
|
const data = await readEvent(eventId, uid, "delete");
|
|
2532
2465
|
requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
|
|
2533
|
-
const
|
|
2534
|
-
const result = await
|
|
2466
|
+
const trash = httpsCallable(functions, "trashEvent");
|
|
2467
|
+
const result = await trash({ eventId });
|
|
2535
2468
|
return textResult(result.data);
|
|
2536
2469
|
});
|
|
2470
|
+
server.registerTool("oko_trash_event", {
|
|
2471
|
+
title: "Move event to trash",
|
|
2472
|
+
description: toolDescription({
|
|
2473
|
+
summary: "Move an OKO event to the recoverable 30-day trash.",
|
|
2474
|
+
args: ["eventId: event to move to trash.", "confirm: must be true."],
|
|
2475
|
+
returns: "trashed event id and purge deadline in epoch milliseconds.",
|
|
2476
|
+
examples: ["Use after showing the exact event title and asking the organizer to confirm moving it to trash."],
|
|
2477
|
+
notes: ["End any active analytics session first. Restore with oko_restore_event before the purge deadline."],
|
|
2478
|
+
}),
|
|
2479
|
+
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to move the event to trash.") },
|
|
2480
|
+
outputSchema: z.object({ trashed: z.string(), purgeAfter: z.number().nullable() }).passthrough(),
|
|
2481
|
+
annotations: destructiveTool,
|
|
2482
|
+
}, async ({ eventId, confirm }) => {
|
|
2483
|
+
const { uid } = await authed(["events:delete"]);
|
|
2484
|
+
const event = await readEvent(eventId, uid, "delete");
|
|
2485
|
+
requireConfirm(confirm, "oko_trash_event", { eventId, title: event.title });
|
|
2486
|
+
const trash = httpsCallable(functions, "trashEvent");
|
|
2487
|
+
return textResult((await trash({ eventId })).data);
|
|
2488
|
+
});
|
|
2489
|
+
server.registerTool("oko_restore_event", {
|
|
2490
|
+
title: "Restore event",
|
|
2491
|
+
description: toolDescription({
|
|
2492
|
+
summary: "Restore an OKO event from the 30-day trash before its purge deadline.",
|
|
2493
|
+
args: ["eventId: trashed event to restore."],
|
|
2494
|
+
returns: "restored event id.",
|
|
2495
|
+
examples: ["Use when the organizer asks to undo a trash action."],
|
|
2496
|
+
notes: ["Idempotent. Fails after the trash retention deadline has expired."],
|
|
2497
|
+
}),
|
|
2498
|
+
inputSchema: { eventId: eventIdField },
|
|
2499
|
+
outputSchema: z.object({ restored: z.string() }).passthrough(),
|
|
2500
|
+
annotations: idempotentWriteTool,
|
|
2501
|
+
}, async ({ eventId }) => {
|
|
2502
|
+
const { uid } = await authed(["events:delete"]);
|
|
2503
|
+
await readEvent(eventId, uid, "delete");
|
|
2504
|
+
const restore = httpsCallable(functions, "restoreEvent");
|
|
2505
|
+
return textResult((await restore({ eventId })).data);
|
|
2506
|
+
});
|
|
2507
|
+
server.registerTool("oko_delete_event_permanently", {
|
|
2508
|
+
title: "Delete event permanently",
|
|
2509
|
+
description: toolDescription({
|
|
2510
|
+
summary: "Permanently delete a trashed OKO event and all associated Firestore, RTDB, chat, Storage, participant, session, and Telegram data.",
|
|
2511
|
+
args: [
|
|
2512
|
+
"eventId: trashed event to delete permanently.",
|
|
2513
|
+
"confirmTitle: exact current event title, including case and spacing.",
|
|
2514
|
+
"confirm: must be true.",
|
|
2515
|
+
],
|
|
2516
|
+
returns: "deleted event id and cross-store cleanup counts.",
|
|
2517
|
+
examples: ["Read the event first, show its exact title, and proceed only after the organizer explicitly types or confirms that exact title."],
|
|
2518
|
+
notes: ["Irreversible. The event must already be in trash; use oko_trash_event first."],
|
|
2519
|
+
}),
|
|
2520
|
+
inputSchema: {
|
|
2521
|
+
eventId: eventIdField,
|
|
2522
|
+
confirmTitle: z.string().max(500).describe("Exact current event title."),
|
|
2523
|
+
confirm: z.boolean().optional().describe("Required true for permanent deletion."),
|
|
2524
|
+
},
|
|
2525
|
+
outputSchema: z.object({ deleted: z.string(), cleanup: unknownRecordSchema }).passthrough(),
|
|
2526
|
+
annotations: destructiveTool,
|
|
2527
|
+
}, async ({ eventId, confirmTitle, confirm }) => {
|
|
2528
|
+
const { uid } = await authed(["events:delete"]);
|
|
2529
|
+
const event = await readEvent(eventId, uid, "delete");
|
|
2530
|
+
const title = safeString(event.title);
|
|
2531
|
+
if (confirmTitle !== title)
|
|
2532
|
+
throw new Error("confirmTitle must exactly match the current event title.");
|
|
2533
|
+
requireConfirm(confirm, "oko_delete_event_permanently", { eventId, title });
|
|
2534
|
+
const remove = httpsCallable(functions, "deleteEventCompletely");
|
|
2535
|
+
return textResult((await remove({ eventId, confirmTitle })).data);
|
|
2536
|
+
});
|
|
2537
2537
|
server.registerTool("oko_duplicate_event", {
|
|
2538
2538
|
title: "Duplicate event",
|
|
2539
2539
|
description: toolDescription({
|
|
@@ -2552,6 +2552,8 @@ export function registerTools(server) {
|
|
|
2552
2552
|
}, async ({ eventId, targetWorkspaceId }) => {
|
|
2553
2553
|
const { uid } = await authed(["events:write"]);
|
|
2554
2554
|
const source = await readEvent(eventId, uid, "read");
|
|
2555
|
+
if (source.nmoMode === true)
|
|
2556
|
+
await authed(["nmo:write"]);
|
|
2555
2557
|
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
2556
2558
|
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
2557
2559
|
const newRef = await addEventWithRtdbMirror(uid, workspaceId, {
|
|
@@ -3191,7 +3193,13 @@ export function registerTools(server) {
|
|
|
3191
3193
|
requireConfirm(confirm, "oko_send_chat_message", { eventId, name, text });
|
|
3192
3194
|
const role = await getChatRole(eventId, uid);
|
|
3193
3195
|
const msgRef = push(ref(chatRtdb, `messages/${eventId}`));
|
|
3194
|
-
|
|
3196
|
+
if (!msgRef.key)
|
|
3197
|
+
throw new Error("Could not allocate chat message id");
|
|
3198
|
+
const timestamp = rtdbServerTimestamp();
|
|
3199
|
+
await update(ref(chatRtdb), {
|
|
3200
|
+
[`messages/${eventId}/${msgRef.key}`]: { text, uid, name, role, ts: timestamp },
|
|
3201
|
+
[`rate_limits/${eventId}/${uid}`]: { ts: timestamp, messageId: msgRef.key },
|
|
3202
|
+
});
|
|
3195
3203
|
await runTransaction(ref(rtdb, `events/${eventId}/agg/chatMessages`), (n) => (n ?? 0) + 1);
|
|
3196
3204
|
return textResult({ eventId, messageId: msgRef.key, sent: true });
|
|
3197
3205
|
});
|
|
@@ -3656,20 +3664,48 @@ export function registerTools(server) {
|
|
|
3656
3664
|
await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
|
|
3657
3665
|
return textResult({ markedRead: ids.length, notificationIds: ids });
|
|
3658
3666
|
});
|
|
3667
|
+
server.registerTool("oko_create_telegram_link", {
|
|
3668
|
+
title: "Link personal Telegram",
|
|
3669
|
+
description: toolDescription({
|
|
3670
|
+
summary: "Create a short-lived Telegram deep link that binds the organizer's personal Telegram identity to OKO.",
|
|
3671
|
+
args: [],
|
|
3672
|
+
returns: "@OKOStreamBot link and expiry in seconds.",
|
|
3673
|
+
examples: ["Open the returned link in the organizer's Telegram account before claiming a channel."],
|
|
3674
|
+
notes: ["The link expires in 15 minutes and can be used only once. Opening it is a user action outside MCP."],
|
|
3675
|
+
}),
|
|
3676
|
+
inputSchema: {},
|
|
3677
|
+
outputSchema: z.object({ url: z.string().url(), expiresIn: z.number().int().positive() }).passthrough(),
|
|
3678
|
+
annotations: writeTool,
|
|
3679
|
+
}, async () => {
|
|
3680
|
+
await authed(["telegram:write"]);
|
|
3681
|
+
const createLink = httpsCallable(functions, "createTelegramLink");
|
|
3682
|
+
return textResult((await createLink({})).data);
|
|
3683
|
+
});
|
|
3659
3684
|
server.registerTool("oko_list_telegram_channels", {
|
|
3660
3685
|
title: "List Telegram channels",
|
|
3661
3686
|
description: toolDescription({
|
|
3662
3687
|
summary: "List Telegram channels available to the authenticated organizer.",
|
|
3663
|
-
args: [
|
|
3664
|
-
|
|
3688
|
+
args: [
|
|
3689
|
+
"eventId: optional event; when provided, include channels explicitly shared to that event's workspace.",
|
|
3690
|
+
"includeWorkspaceChannels: deprecated compatibility field; eventId is required to resolve workspace sharing safely.",
|
|
3691
|
+
],
|
|
3692
|
+
returns: "canonical channels with chatId, title, username, workspaceIds, and owned flag.",
|
|
3665
3693
|
examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
|
|
3666
3694
|
}),
|
|
3667
|
-
inputSchema: {
|
|
3695
|
+
inputSchema: {
|
|
3696
|
+
eventId: eventIdField.optional().describe("Optional event used to include channels shared to its workspace."),
|
|
3697
|
+
includeWorkspaceChannels: z.boolean().optional().describe("Deprecated; retained for client compatibility."),
|
|
3698
|
+
},
|
|
3668
3699
|
outputSchema: telegramChannelsOutputSchema,
|
|
3669
3700
|
annotations: readOnlyTool,
|
|
3670
|
-
}, async ({
|
|
3671
|
-
|
|
3672
|
-
const
|
|
3701
|
+
}, async ({ eventId }) => {
|
|
3702
|
+
await authed(["telegram:write"]);
|
|
3703
|
+
const listChannels = httpsCallable(functions, "listTelegramChannels");
|
|
3704
|
+
const result = await listChannels(eventId ? { eventId } : {});
|
|
3705
|
+
const parsed = z.array(canonicalTelegramChannelSchema).safeParse(result.data.channels);
|
|
3706
|
+
if (!parsed.success)
|
|
3707
|
+
throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
|
|
3708
|
+
const channels = parsed.data.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
|
|
3673
3709
|
return textResult({ channels, count: channels.length });
|
|
3674
3710
|
});
|
|
3675
3711
|
server.registerTool("oko_claim_telegram_channel", {
|
|
@@ -3678,12 +3714,12 @@ export function registerTools(server) {
|
|
|
3678
3714
|
summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
|
|
3679
3715
|
args: [
|
|
3680
3716
|
"claimCode: code posted by the bot after it was added to a channel.",
|
|
3681
|
-
"workspaceIds: optional workspaces to expose this channel to after claim.",
|
|
3717
|
+
"workspaceIds: optional owned workspaces to expose this channel to after claim.",
|
|
3682
3718
|
"confirm: must be true.",
|
|
3683
3719
|
],
|
|
3684
3720
|
returns: "claimed channel and workspace ids updated.",
|
|
3685
|
-
examples: ["
|
|
3686
|
-
notes: ["
|
|
3721
|
+
examples: ["Link personal Telegram first, add @OKOStreamBot as channel administrator, copy the claim code, then call this tool."],
|
|
3722
|
+
notes: ["The linked personal Telegram user must be a channel administrator. Workspace sharing is server-validated."],
|
|
3687
3723
|
}),
|
|
3688
3724
|
inputSchema: {
|
|
3689
3725
|
claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
|
|
@@ -3693,7 +3729,7 @@ export function registerTools(server) {
|
|
|
3693
3729
|
outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
|
|
3694
3730
|
annotations: writeTool,
|
|
3695
3731
|
}, async ({ claimCode, workspaceIds, confirm }) => {
|
|
3696
|
-
|
|
3732
|
+
await authed(["telegram:write"]);
|
|
3697
3733
|
requireConfirm(confirm, "oko_claim_telegram_channel", { workspaceIds: workspaceIds.join(",") });
|
|
3698
3734
|
const claimChannel = httpsCallable(functions, "claimTelegramChannel");
|
|
3699
3735
|
const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
|
|
@@ -3702,8 +3738,54 @@ export function registerTools(server) {
|
|
|
3702
3738
|
const parsed = telegramChannelSchema.safeParse(channelRaw);
|
|
3703
3739
|
if (!parsed.success)
|
|
3704
3740
|
throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
|
|
3705
|
-
|
|
3706
|
-
|
|
3741
|
+
if (workspaceIds.length > 0) {
|
|
3742
|
+
const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
|
|
3743
|
+
await setWorkspaces({ channelId: parsed.data.chatId, workspaceIds });
|
|
3744
|
+
}
|
|
3745
|
+
return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated: workspaceIds });
|
|
3746
|
+
});
|
|
3747
|
+
server.registerTool("oko_set_telegram_channel_workspaces", {
|
|
3748
|
+
title: "Share Telegram channel to workspaces",
|
|
3749
|
+
description: toolDescription({
|
|
3750
|
+
summary: "Replace the owned Telegram channel's explicit OKO workspace sharing list.",
|
|
3751
|
+
args: ["channelId: owned Telegram channel id.", "workspaceIds: complete replacement list, up to 50.", "confirm: must be true."],
|
|
3752
|
+
returns: "channel id and the applied workspace ids.",
|
|
3753
|
+
examples: ["Use an empty workspaceIds list to make the channel private to its owner again."],
|
|
3754
|
+
notes: ["This replaces, rather than appends to, the current sharing list. Every workspace must be owned by the organizer."],
|
|
3755
|
+
}),
|
|
3756
|
+
inputSchema: {
|
|
3757
|
+
channelId: z.number().int().describe("Owned Telegram channel id."),
|
|
3758
|
+
workspaceIds: z.array(z.string().min(1).max(128)).max(50).describe("Complete replacement workspace sharing list."),
|
|
3759
|
+
confirm: z.boolean().optional().describe("Required true to change workspace sharing."),
|
|
3760
|
+
},
|
|
3761
|
+
outputSchema: z.object({ success: z.boolean(), channelId: z.number().int(), workspaceIds: z.array(z.string()) }).passthrough(),
|
|
3762
|
+
annotations: writeTool,
|
|
3763
|
+
}, async ({ channelId, workspaceIds, confirm }) => {
|
|
3764
|
+
await authed(["telegram:write"]);
|
|
3765
|
+
requireConfirm(confirm, "oko_set_telegram_channel_workspaces", { channelId, workspaceIds: workspaceIds.join(",") });
|
|
3766
|
+
const setWorkspaces = httpsCallable(functions, "setTelegramChannelWorkspaces");
|
|
3767
|
+
return textResult((await setWorkspaces({ channelId, workspaceIds })).data);
|
|
3768
|
+
});
|
|
3769
|
+
server.registerTool("oko_disconnect_telegram_channel", {
|
|
3770
|
+
title: "Disconnect Telegram channel",
|
|
3771
|
+
description: toolDescription({
|
|
3772
|
+
summary: "Disconnect an owned Telegram channel from OKO and clear all workspace sharing.",
|
|
3773
|
+
args: ["channelId: owned Telegram channel id.", "confirm: must be true."],
|
|
3774
|
+
returns: "disconnected channel id.",
|
|
3775
|
+
examples: ["Use only after showing the exact channel selected from oko_list_telegram_channels."],
|
|
3776
|
+
notes: ["Existing sent Telegram messages are not deleted. The channel must be claimed again before future publication."],
|
|
3777
|
+
}),
|
|
3778
|
+
inputSchema: {
|
|
3779
|
+
channelId: z.number().int().describe("Owned Telegram channel id."),
|
|
3780
|
+
confirm: z.boolean().optional().describe("Required true to disconnect the channel."),
|
|
3781
|
+
},
|
|
3782
|
+
outputSchema: z.object({ success: z.boolean(), channelId: z.number().int() }).passthrough(),
|
|
3783
|
+
annotations: destructiveTool,
|
|
3784
|
+
}, async ({ channelId, confirm }) => {
|
|
3785
|
+
await authed(["telegram:write"]);
|
|
3786
|
+
requireConfirm(confirm, "oko_disconnect_telegram_channel", { channelId });
|
|
3787
|
+
const disconnect = httpsCallable(functions, "disconnectTelegramChannel");
|
|
3788
|
+
return textResult((await disconnect({ channelId })).data);
|
|
3707
3789
|
});
|
|
3708
3790
|
server.registerTool("oko_list_telegram_posts", {
|
|
3709
3791
|
title: "List Telegram posts",
|
|
@@ -3757,14 +3839,18 @@ export function registerTools(server) {
|
|
|
3757
3839
|
outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
|
|
3758
3840
|
annotations: destructiveTool,
|
|
3759
3841
|
}, async ({ postId, confirm }) => {
|
|
3760
|
-
await authed(["telegram:write"]);
|
|
3842
|
+
const { uid } = await authed(["telegram:write"]);
|
|
3761
3843
|
const postRef = doc(firestore, "telegramPosts", postId);
|
|
3762
3844
|
const postSnap = await getDoc(postRef);
|
|
3763
3845
|
if (!postSnap.exists())
|
|
3764
3846
|
throw new Error("Telegram post not found.");
|
|
3765
3847
|
const post = postSnap.data();
|
|
3766
|
-
if (post.
|
|
3767
|
-
throw new Error("Telegram post
|
|
3848
|
+
if (typeof post.eventId !== "string")
|
|
3849
|
+
throw new Error("Telegram post has no valid event.");
|
|
3850
|
+
await readEvent(post.eventId, uid, "notify");
|
|
3851
|
+
if (!["draft", "pending", "scheduled", "failed"].includes(safeString(post.status))) {
|
|
3852
|
+
throw new Error("Only draft, pending, scheduled, or failed Telegram posts can be canceled. A sent or delivery-unknown post must be reviewed manually.");
|
|
3853
|
+
}
|
|
3768
3854
|
requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
|
|
3769
3855
|
await deleteDoc(postRef);
|
|
3770
3856
|
return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
|
|
@@ -3799,10 +3885,19 @@ export function registerTools(server) {
|
|
|
3799
3885
|
}, async (args) => {
|
|
3800
3886
|
const { uid } = await authed(["telegram:write"]);
|
|
3801
3887
|
await readEvent(args.eventId, uid, "notify");
|
|
3888
|
+
const listChannels = httpsCallable(functions, "listTelegramChannels");
|
|
3889
|
+
const availableResult = await listChannels({ eventId: args.eventId });
|
|
3890
|
+
const available = z.array(canonicalTelegramChannelSchema).safeParse(availableResult.data.channels);
|
|
3891
|
+
if (!available.success)
|
|
3892
|
+
throw new Error("OKO listTelegramChannels returned an invalid channel payload.");
|
|
3893
|
+
const channel = available.data.find((item) => item.chatId === args.channelId);
|
|
3894
|
+
if (!channel)
|
|
3895
|
+
throw new Error("Telegram channel is not available for this event. List channels with this eventId and choose a returned channel.");
|
|
3802
3896
|
const scheduled = !!args.scheduleAt;
|
|
3803
3897
|
requireConfirm(args.confirm, "oko_publish_telegram_post", {
|
|
3804
3898
|
eventId: args.eventId,
|
|
3805
3899
|
channelId: args.channelId,
|
|
3900
|
+
channelTitle: channel.title ?? "",
|
|
3806
3901
|
caption: args.caption,
|
|
3807
3902
|
scheduleAt: args.scheduleAt ?? null,
|
|
3808
3903
|
});
|
|
@@ -3811,6 +3906,7 @@ export function registerTools(server) {
|
|
|
3811
3906
|
ownerId: uid,
|
|
3812
3907
|
ownerEmail: "",
|
|
3813
3908
|
channelId: args.channelId,
|
|
3909
|
+
channelKey: String(args.channelId),
|
|
3814
3910
|
caption: args.caption.trim(),
|
|
3815
3911
|
imageUrl: args.imageUrl?.trim() || null,
|
|
3816
3912
|
buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: args.buttonUrl?.trim() || eventUrl(args.eventId) }] : [],
|