@omnisocials/mcp-server 1.39.0 → 1.46.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 +55 -2
- package/build/client.d.ts +136 -1
- package/build/client.js +27 -0
- package/build/index.js +3 -1
- package/build/metrics.js +30 -0
- package/build/tools/accounts.js +35 -3
- package/build/tools/analytics.js +2 -2
- package/build/tools/automations.d.ts +25 -0
- package/build/tools/automations.js +199 -0
- package/build/tools/media.js +14 -1
- package/build/tools/posts.js +298 -29
- package/build/types.d.ts +224 -0
- package/package.json +2 -2
package/build/tools/posts.js
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { formatDateTime, truncate, capitalize, formatNumber, metricRows } from "../client.js";
|
|
3
|
+
// Comment-to-DM automations bound to the post (Instagram or Facebook must be
|
|
4
|
+
// a channel; `platforms` picks which). The shape of create_automation without
|
|
5
|
+
// the post: always this post, comment trigger. Needs the opt-in
|
|
6
|
+
// automations:write scope on the API key.
|
|
7
|
+
const COMMENT_DM_AUTOMATION = z.object({
|
|
8
|
+
platforms: z.array(z.enum(["instagram", "facebook"])).min(1).optional().describe("The channels that get the automation: 'instagram', 'facebook' (the Facebook Page; the DM lands in Messenger) or both. One automation per channel, each with the same keywords, DM, public reply and limits. Each one must be in channels. Omit it for one automation: Instagram when the post goes there, else Facebook."),
|
|
9
|
+
trigger: z.object({
|
|
10
|
+
keywords: z.array(z.string()).max(20).optional().describe("Keywords that start the automation, matched case-insensitively (up to 20). Required unless match is 'any'."),
|
|
11
|
+
match: z.enum(["word", "contains", "any"]).optional().describe("word (default): the keyword stands on its own. contains: anywhere in the comment. any: every comment (ask the user first)."),
|
|
12
|
+
}).optional(),
|
|
13
|
+
actions: z.object({
|
|
14
|
+
dm: z.object({ text: z.string().max(1000).describe("The DM the person receives, once (max 1000 characters). Confirm the text with the user: it goes out in their name.") }),
|
|
15
|
+
public_reply: z.object({
|
|
16
|
+
enabled: z.boolean().optional(),
|
|
17
|
+
variations: z.array(z.string().max(300)).max(10).optional().describe("Public replies posted under the comment, one picked at random per comment."),
|
|
18
|
+
}).optional(),
|
|
19
|
+
}),
|
|
20
|
+
limits: z.object({
|
|
21
|
+
max_sends: z.number().int().positive().nullable().optional().describe("Stop after this many DMs."),
|
|
22
|
+
stop_at: z.string().nullable().optional().describe("Stop at this time (ISO 8601)."),
|
|
23
|
+
}).optional(),
|
|
24
|
+
}).describe("Comment-to-DM automation on this post: when somebody comments one of trigger.keywords on the post once it is live on Instagram or the Facebook Page, the account DMs them actions.dm.text once (on Facebook the DM lands in Messenger), and optionally posts one of actions.public_reply.variations under the comment. Instagram or Facebook must be in channels; platforms picks which ones get it. Show the user the keywords and the exact DM text before creating it. The created automations come back as comment_dm_automation on the post (automations lists the id of each channel); manage them later with update_automation / get_automation_activity. Needs the opt-in automations:write scope on the API key.");
|
|
25
|
+
const commentDmLine = (p) => {
|
|
26
|
+
const a = p.comment_dm_automation;
|
|
27
|
+
if (!a)
|
|
28
|
+
return "";
|
|
29
|
+
const keywords = a.trigger.match === "any" ? "any comment" : a.trigger.keywords.map((k) => `"${k}"`).join(", ");
|
|
30
|
+
const list = a.automations?.length ? a.automations : [{ id: a.id, platform: a.platform }];
|
|
31
|
+
const ids = list.map((x) => `\`${x.id}\` (${x.platform === "facebook" ? "Facebook Page" : "Instagram"})`).join(", ");
|
|
32
|
+
return `| **Comment-to-DM** | ${a.status}, keywords ${keywords}, ${list.length > 1 ? "automations" : "automation"} ${ids} |\n`;
|
|
33
|
+
};
|
|
3
34
|
// Shared media entry schemas — every media list (flat arrays, per-platform
|
|
4
35
|
// records, and x/bluesky/mastodon/threads thread parts) accepts either a plain string
|
|
5
36
|
// or an object carrying `alt`: an accessibility description (alt text, max
|
|
@@ -81,6 +112,11 @@ const VIDEO_COVER_ENTRY = z.object({
|
|
|
81
112
|
thumb_offset: z.number().int().min(0).optional().describe("Milliseconds into the video (type 'frame'). 3000 = 0:03."),
|
|
82
113
|
cover_url: z.string().optional().describe("Public image URL, JPEG or PNG (type 'custom')."),
|
|
83
114
|
});
|
|
115
|
+
// Image fit per channel (shared/post-validation imageFit.js on the backend).
|
|
116
|
+
const MEDIA_FIT_OPTIONS = z
|
|
117
|
+
.record(z.string(), z.enum(["auto", "crop", "keep"]))
|
|
118
|
+
.optional()
|
|
119
|
+
.describe("Image fit per channel, for feed images (type 'post') whose ratio is outside what a channel shows uncropped: { instagram: 'auto' | 'crop' | 'keep', pinterest: ... }. 'auto' (the default when omitted) takes the channel's rule: Instagram crops a tall (9:16) or wide image to 4:5 / 1.91:1 around the subject (Instagram cuts it to that window anyway, and Meta cannot boost a feed post outside it); Pinterest keeps a Pin taller than 2:3 and only reports it. 'crop' / 'keep' force it for that channel. Every decision comes back in the response `warnings` with code `image_fit` (relay it). The crop is a separate file for that channel only; 'default' and the other channels keep the original. On update_post, media_fit alone (no media) re-decides the stored images: 'keep' gives the original back, 'crop' crops an image that was kept.");
|
|
84
120
|
const VIDEO_COVER_OPTIONS = VIDEO_COVER_ENTRY.extend({
|
|
85
121
|
overrides: z
|
|
86
122
|
.record(z.string(), VIDEO_COVER_ENTRY.nullable())
|
|
@@ -152,6 +188,103 @@ const LINKEDIN_POLL_OPTIONS = z
|
|
|
152
188
|
.nullable()
|
|
153
189
|
.optional()
|
|
154
190
|
.describe("Non-sponsored LinkedIn poll(s) — independent per channel, keyed by `linkedin` (personal profile) / `linkedin_page` (company page). A poll is mutually exclusive with media and a link share on that channel's post — a poll takes priority over both at publish time. Still requires `content.linkedin` (or `content.default`) as that channel's caption; the poll itself only carries the question/options/duration. On update_post, set a channel's key to `null` to clear that channel's poll and revert it to a normal post — send the full desired state for both channels, since the whole object replaces wholesale.");
|
|
191
|
+
// Polls on X, Threads and Mastodon (2026-10-12). One shape, nested under
|
|
192
|
+
// the channel's options as `poll`; the post text is the question. A channel
|
|
193
|
+
// with a poll carries no media (the API refuses the pair). On update_post,
|
|
194
|
+
// `null` removes the poll; omitted leaves it.
|
|
195
|
+
const X_POLL_OPTIONS = z
|
|
196
|
+
.object({
|
|
197
|
+
options: z.array(z.string().min(1).max(25)).min(2).max(4).describe("2 to 4 answer options of up to 25 characters, all different."),
|
|
198
|
+
duration_minutes: z.number().int().min(5).max(10080).optional().describe("How long the poll runs, in minutes (5 to 10080 = 7 days). Default 1440 (one day)."),
|
|
199
|
+
})
|
|
200
|
+
.nullable()
|
|
201
|
+
.optional()
|
|
202
|
+
.describe("Attach a poll to the tweet; the tweet text is the question. No media on X together with a poll. On a thread the poll goes on the first tweet. On update_post, null removes it.");
|
|
203
|
+
const THREADS_POLL_OPTIONS = z
|
|
204
|
+
.object({
|
|
205
|
+
options: z.array(z.string().min(1).max(25)).min(2).max(4).describe("2 to 4 answer options of 1 to 25 characters, all different."),
|
|
206
|
+
})
|
|
207
|
+
.nullable()
|
|
208
|
+
.optional()
|
|
209
|
+
.describe("Attach a poll to the post; the post text is the question. Threads closes the poll 24 hours after it is published (no duration). Polls go on text-only posts: no media on Threads together with a poll. On a thread the poll goes on the first post. On update_post, null removes it.");
|
|
210
|
+
const MASTODON_POLL_OPTIONS = z
|
|
211
|
+
.object({
|
|
212
|
+
options: z.array(z.string().min(1)).min(2).describe("The answer options, all different; count and length within the instance's limits (Mastodon's defaults: 4 options of 50 characters)."),
|
|
213
|
+
expires_in: z.number().int().min(300).optional().describe("How long the poll runs, in seconds, within the instance's range (Mastodon's defaults: 5 minutes to 1 month). Default 86400 (one day)."),
|
|
214
|
+
multiple: z.boolean().optional().describe("Allow several answers per person."),
|
|
215
|
+
hide_totals: z.boolean().optional().describe("Hide the vote counts until the poll ends."),
|
|
216
|
+
})
|
|
217
|
+
.nullable()
|
|
218
|
+
.optional()
|
|
219
|
+
.describe("Attach a poll to the status; the status text is the question. No media on Mastodon together with a poll. On a thread the poll goes on the first status. On update_post, null removes it.");
|
|
220
|
+
// Telegram (2026-10-17): the connected channel or group, posted by the
|
|
221
|
+
// OmniSocials bot. One schema for create and update; on update_post every
|
|
222
|
+
// key merges into the stored options and null removes it. Same shape as the
|
|
223
|
+
// hosted server (backend/routes/api/v1/mcp.js, telegramSchema).
|
|
224
|
+
const TELEGRAM_OPTIONS = z
|
|
225
|
+
.object({
|
|
226
|
+
silent: z.boolean().nullable().optional().describe("Send without a notification sound."),
|
|
227
|
+
link_preview: z.boolean().nullable().optional().describe("false hides the link preview of a text-only post (shown by default)."),
|
|
228
|
+
pin: z.boolean().nullable().optional().describe("Pin the post in the chat after it is sent. Needs the bot's Edit messages right in a channel (Pin messages in a group); without it the post is sent unpinned and get_post shows pin_error."),
|
|
229
|
+
protect_content: z.boolean().nullable().optional().describe("Stop forwarding and saving of the post in Telegram apps."),
|
|
230
|
+
spoiler: z.boolean().nullable().optional().describe("Blur the photos and videos until someone taps them."),
|
|
231
|
+
caption_above_media: z.boolean().nullable().optional().describe("Show the text above the media instead of below."),
|
|
232
|
+
buttons: z
|
|
233
|
+
.array(z.object({
|
|
234
|
+
text: z.string().max(64).describe("Button label (1 to 64 chars)."),
|
|
235
|
+
url: z.string().describe("Link the button opens: https://, http:// or tg://."),
|
|
236
|
+
}))
|
|
237
|
+
.max(3)
|
|
238
|
+
.nullable()
|
|
239
|
+
.optional()
|
|
240
|
+
.describe("Up to 3 link buttons under the post, one per row. Not on an album (2+ media) or a poll."),
|
|
241
|
+
poll: z
|
|
242
|
+
.object({
|
|
243
|
+
options: z.array(z.string().min(1).max(100)).min(2).max(12).describe("2 to 12 answer options of up to 100 chars, all different."),
|
|
244
|
+
open_period: z.number().int().min(5).max(2628000).optional().describe("Seconds the poll stays open (5 s to about 30 days). Omit to keep it open."),
|
|
245
|
+
multiple: z.boolean().optional().describe("Allow several answers per person."),
|
|
246
|
+
})
|
|
247
|
+
.nullable()
|
|
248
|
+
.optional()
|
|
249
|
+
.describe("A native Telegram poll. The post text is the question (max 300 chars). No media with a poll. On update_post, null removes it."),
|
|
250
|
+
first_comment: z.string().max(4096).optional().describe("A reply the bot sends under the post right after it is published (max 4,096 chars). On update_post pass an empty string to clear it."),
|
|
251
|
+
})
|
|
252
|
+
.nullable()
|
|
253
|
+
.optional()
|
|
254
|
+
.describe("Telegram options. Text up to 4,096 chars; with media, text over 1,024 chars is sent as a reply under the media. Up to 10 photos and videos per album (no GIF in an album), files up to 50 MB, no Stories. A Reel is sent as a video message.");
|
|
255
|
+
// Discord (2026-10-22): the connected Discord channel, posted through its
|
|
256
|
+
// webhook. One schema for create and update; on update_post every key merges
|
|
257
|
+
// into the stored options and null removes it. Same shape as the hosted
|
|
258
|
+
// server (backend/routes/api/v1/mcp.js, discordSchema).
|
|
259
|
+
const DISCORD_OPTIONS = z
|
|
260
|
+
.object({
|
|
261
|
+
silent: z.boolean().nullable().optional().describe("Send without a push or desktop notification (Discord's @silent)."),
|
|
262
|
+
link_preview: z.boolean().nullable().optional().describe("false hides the previews of the links in the text (shown by default)."),
|
|
263
|
+
allow_mentions: z.boolean().nullable().optional().describe("true lets @everyone, @here, role and user mentions in the text notify people. Off by default: mentions show as text and ping nobody. Only turn it on when the user asks to ping."),
|
|
264
|
+
spoiler: z.boolean().nullable().optional().describe("Blur every image and video until someone clicks it."),
|
|
265
|
+
thread_name: z.string().max(100).nullable().optional().describe("The post title in a forum or media channel, where Discord requires one (1 to 100 chars). Only for those channels: a text channel takes none, so the post goes out there without it (warning thread_name_ignored). On update_post, null removes it."),
|
|
266
|
+
buttons: z
|
|
267
|
+
.array(z.object({
|
|
268
|
+
text: z.string().max(80).describe("Button label (1 to 80 chars)."),
|
|
269
|
+
url: z.string().describe("Link the button opens: https:// or http://."),
|
|
270
|
+
}))
|
|
271
|
+
.max(5)
|
|
272
|
+
.nullable()
|
|
273
|
+
.optional()
|
|
274
|
+
.describe("Up to 5 link buttons in one row under the message. Not with a poll."),
|
|
275
|
+
poll: z
|
|
276
|
+
.object({
|
|
277
|
+
options: z.array(z.string().min(1).max(55)).min(2).max(10).describe("2 to 10 answer options of up to 55 chars, all different."),
|
|
278
|
+
duration_hours: z.number().int().min(1).max(768).optional().describe("Hours the poll stays open (1 to 768, 32 days). Default 24."),
|
|
279
|
+
multiple: z.boolean().optional().describe("Allow several answers per person."),
|
|
280
|
+
})
|
|
281
|
+
.nullable()
|
|
282
|
+
.optional()
|
|
283
|
+
.describe("A native Discord poll. The post text is the question (max 300 chars). No media and no buttons with a poll. On update_post, null removes it."),
|
|
284
|
+
})
|
|
285
|
+
.nullable()
|
|
286
|
+
.optional()
|
|
287
|
+
.describe("Discord options. The post goes to the connected Discord channel through its webhook and shows the webhook's name and picture. Text up to 2,000 chars. Up to 10 images, GIFs and videos per message (they may mix); a video or GIF over Discord's 20 MB file limit is sent as a link in the text. No Stories; a Reel is sent as one video message.");
|
|
155
288
|
// Per-platform option schemas shared verbatim by create_post,
|
|
156
289
|
// create_and_publish_post and update_post (they were byte-identical in all
|
|
157
290
|
// three). Platform blocks that genuinely differ per tool (instagram, youtube,
|
|
@@ -204,8 +337,8 @@ const X_THREAD_PART = z.object({
|
|
|
204
337
|
});
|
|
205
338
|
const BLUESKY_THREAD_PART = z.object({
|
|
206
339
|
text: z.string().describe("Post text (≤ 300 characters, counted as graphemes — one emoji counts as 1)."),
|
|
207
|
-
media_ids: z.array(mediaIdEntry).max(
|
|
208
|
-
media_urls: z.array(mediaUrlEntry).max(
|
|
340
|
+
media_ids: z.array(mediaIdEntry).max(10).optional().describe("Per-post media as Library IDs from upload_media. A part is one video OR up to 10 images (5 or more post as a gallery). Each entry is a plain ID string or { id, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
341
|
+
media_urls: z.array(mediaUrlEntry).max(10).optional().describe("Per-post media as external URLs. A part is one video OR up to 10 images (5 or more post as a gallery). Each entry is a plain URL string or { url, alt } to attach alt text (set as the image's embed alt on Bluesky)."),
|
|
209
342
|
});
|
|
210
343
|
const MASTODON_THREAD_PART = z.object({
|
|
211
344
|
text: z.string().describe("Status text (≤ 500 characters by default; some instances allow more)."),
|
|
@@ -231,7 +364,7 @@ const GOOGLE_BUSINESS_OPTIONS = z.object({
|
|
|
231
364
|
endDate: z.object({ year: z.number(), month: z.number(), day: z.number() }).optional(),
|
|
232
365
|
endTime: z.object({ hours: z.number(), minutes: z.number() }).optional(),
|
|
233
366
|
}).optional().describe("Google's split date+time shape. startDate required; end (if present) must be ≥ start."),
|
|
234
|
-
}).optional().describe("Required when topic_type is EVENT."),
|
|
367
|
+
}).optional().describe("Required when topic_type is EVENT or OFFER. For an OFFER, the title is the offer's name and the schedule its validity period (Google rejects an offer without it)."),
|
|
235
368
|
offer: z.object({
|
|
236
369
|
couponCode: z.string().max(58).optional(),
|
|
237
370
|
redeemOnlineUrl: z.string().optional().describe("Must be https://. http URLs are rejected."),
|
|
@@ -285,15 +418,32 @@ function postAppUrl(p) {
|
|
|
285
418
|
// client URL, so it's correct on staging/production. Fall back to a
|
|
286
419
|
// hardcoded production URL for older API responses that don't return it.
|
|
287
420
|
if (typeof p?.app_url === "string" && p.app_url)
|
|
288
|
-
return p.app_url;
|
|
421
|
+
return withRef(p.app_url);
|
|
289
422
|
const id = p?.id;
|
|
290
423
|
if (!id)
|
|
291
424
|
return null;
|
|
292
425
|
const status = String(p?.status || "").toLowerCase();
|
|
293
426
|
const isEditable = status === "draft" || status === "scheduled" || status === "" || status === "failed";
|
|
294
|
-
return isEditable
|
|
427
|
+
return withRef(isEditable
|
|
295
428
|
? `https://app.omnisocials.com/create-post/${id}`
|
|
296
|
-
: `https://app.omnisocials.com/posts/${id}
|
|
429
|
+
: `https://app.omnisocials.com/posts/${id}`);
|
|
430
|
+
}
|
|
431
|
+
// `?ref=mcp` on every app link from an agent's answer, so the visits can be
|
|
432
|
+
// counted (the app ignores the parameter). Same rule as the hosted server.
|
|
433
|
+
function withRef(url) {
|
|
434
|
+
try {
|
|
435
|
+
const parsed = new URL(url);
|
|
436
|
+
parsed.searchParams.set("ref", "mcp");
|
|
437
|
+
return parsed.toString();
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return url;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
// The "Open in OmniSocials" table row, or nothing without an id.
|
|
444
|
+
function appLinkRow(p) {
|
|
445
|
+
const url = postAppUrl(p);
|
|
446
|
+
return url ? `| **Open in OmniSocials** | ${url} |\n` : "";
|
|
297
447
|
}
|
|
298
448
|
export function registerPostTools(server, getClient) {
|
|
299
449
|
server.tool("list_posts", "List all posts in the workspace. Optionally filter by status. The content column shows a preview of the default caption; if a post has per-platform overrides (e.g. a custom X version), the preview is suffixed with *(per-platform)* — call `get_post` to see every variant.", {
|
|
@@ -531,6 +681,78 @@ export function registerPostTools(server, getClient) {
|
|
|
531
681
|
}
|
|
532
682
|
md += `\n_Duration: ${poll.duration}_\n`;
|
|
533
683
|
}
|
|
684
|
+
// Polls on X, Threads and Mastodon: the post text is the question.
|
|
685
|
+
for (const channel of ["x", "threads", "mastodon"]) {
|
|
686
|
+
const poll = p[channel]?.poll;
|
|
687
|
+
if (!poll || !Array.isArray(poll.options) || poll.options.length < 2)
|
|
688
|
+
continue;
|
|
689
|
+
const label = channel === "x" ? "X" : capitalize(channel);
|
|
690
|
+
md += `\n\n### ${label} Poll\n\n`;
|
|
691
|
+
for (const option of poll.options)
|
|
692
|
+
md += `- ${option}\n`;
|
|
693
|
+
const runs = channel === "x"
|
|
694
|
+
? `${poll.duration_minutes ?? 1440} minutes`
|
|
695
|
+
: channel === "mastodon"
|
|
696
|
+
? `${poll.expires_in ?? 86400} seconds${poll.multiple ? ", several answers" : ""}`
|
|
697
|
+
: "24 hours (set by Threads)";
|
|
698
|
+
md += `\n_Runs for: ${runs}_\n`;
|
|
699
|
+
}
|
|
700
|
+
const tg = (p.telegram && typeof p.telegram === "object" ? p.telegram : null);
|
|
701
|
+
if (tg) {
|
|
702
|
+
const flagKeys = ["silent", "link_preview", "pin", "protect_content", "spoiler", "caption_above_media"];
|
|
703
|
+
const flags = flagKeys.filter((k) => typeof tg[k] === "boolean").map((k) => `${k}: ${tg[k]}`);
|
|
704
|
+
const buttons = Array.isArray(tg.buttons) ? tg.buttons : [];
|
|
705
|
+
const pollOptions = tg.poll && Array.isArray(tg.poll.options) ? tg.poll.options : [];
|
|
706
|
+
const poll = pollOptions.length >= 2 ? tg.poll : null;
|
|
707
|
+
const result = tg.result && typeof tg.result === "object" ? tg.result : null;
|
|
708
|
+
if (flags.length || buttons.length || poll || result) {
|
|
709
|
+
md += `\n\n### Telegram\n\n`;
|
|
710
|
+
if (flags.length)
|
|
711
|
+
md += `${flags.join(" · ")}\n`;
|
|
712
|
+
for (const b of buttons)
|
|
713
|
+
md += `- Button: ${b.text ?? ""} → ${b.url ?? ""}\n`;
|
|
714
|
+
if (poll) {
|
|
715
|
+
md += `\n**Poll:**\n`;
|
|
716
|
+
for (const option of pollOptions)
|
|
717
|
+
md += `- ${option}\n`;
|
|
718
|
+
md += `\n_Open: ${poll.open_period ? `${poll.open_period} seconds` : "until closed"}${poll.multiple ? ", several answers" : ""}_\n`;
|
|
719
|
+
}
|
|
720
|
+
if (result) {
|
|
721
|
+
const count = Array.isArray(result.message_ids) ? result.message_ids.length : 0;
|
|
722
|
+
const link = result.link_public || result.link_private;
|
|
723
|
+
md += `\nSent: ${count} message${count === 1 ? "" : "s"}${result.pinned ? ", pinned" : result.pin_error ? ` (not pinned: ${result.pin_error})` : ""}${link ? ` · ${link}` : ""}\n`;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const dc = (p.discord && typeof p.discord === "object" ? p.discord : null);
|
|
728
|
+
if (dc) {
|
|
729
|
+
const flagKeys = ["silent", "link_preview", "allow_mentions", "spoiler"];
|
|
730
|
+
const flags = flagKeys.filter((k) => typeof dc[k] === "boolean").map((k) => `${k}: ${dc[k]}`);
|
|
731
|
+
const buttons = Array.isArray(dc.buttons) ? dc.buttons : [];
|
|
732
|
+
const pollOptions = dc.poll && Array.isArray(dc.poll.options) ? dc.poll.options : [];
|
|
733
|
+
const poll = pollOptions.length >= 2 ? dc.poll : null;
|
|
734
|
+
const result = dc.result && typeof dc.result === "object" ? dc.result : null;
|
|
735
|
+
if (flags.length || dc.thread_name || buttons.length || poll || result) {
|
|
736
|
+
md += `\n\n### Discord\n\n`;
|
|
737
|
+
if (flags.length)
|
|
738
|
+
md += `${flags.join(" · ")}\n`;
|
|
739
|
+
if (dc.thread_name)
|
|
740
|
+
md += `Forum post title: ${dc.thread_name}\n`;
|
|
741
|
+
for (const b of buttons)
|
|
742
|
+
md += `- Button: ${b.text ?? ""} → ${b.url ?? ""}\n`;
|
|
743
|
+
if (poll) {
|
|
744
|
+
md += `\n**Poll:**\n`;
|
|
745
|
+
for (const option of pollOptions)
|
|
746
|
+
md += `- ${option}\n`;
|
|
747
|
+
md += `\n_Open: ${poll.duration_hours ?? 24} hours${poll.multiple ? ", several answers" : ""}_\n`;
|
|
748
|
+
}
|
|
749
|
+
if (result) {
|
|
750
|
+
const links = Array.isArray(result.video_links) ? result.video_links.length : 0;
|
|
751
|
+
const titleDropped = Array.isArray(result.warnings) && result.warnings.includes("thread_name_ignored");
|
|
752
|
+
md += `\nSent${result.url ? `: ${result.url}` : ""}${links ? ` · ${links} video${links === 1 ? "" : "s"} sent as a link (over Discord's 20 MB file limit)` : ""}${titleDropped ? " · the post title was left out (the channel is not a forum)" : ""}\n`;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
}
|
|
534
756
|
const publishedUrls = (p.published_urls && typeof p.published_urls === "object" && !Array.isArray(p.published_urls))
|
|
535
757
|
? p.published_urls
|
|
536
758
|
: {};
|
|
@@ -575,7 +797,7 @@ export function registerPostTools(server, getClient) {
|
|
|
575
797
|
}
|
|
576
798
|
// First comments: nested under each platform object by the API. Show the
|
|
577
799
|
// configured text plus, once published, the outcome (posted/failed/skipped).
|
|
578
|
-
const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok"];
|
|
800
|
+
const firstCommentPlatforms = ["instagram", "facebook", "linkedin", "linkedin_page", "youtube", "tiktok", "telegram"];
|
|
579
801
|
const firstCommentRows = [];
|
|
580
802
|
for (const platform of firstCommentPlatforms) {
|
|
581
803
|
const opts = p[platform];
|
|
@@ -747,6 +969,7 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
747
969
|
- TikTok posts: ALWAYS require at least one image or video.
|
|
748
970
|
- Pinterest posts: ALWAYS require an image AND a board_id.
|
|
749
971
|
- Other platforms (LinkedIn, LinkedIn Page, X, Bluesky, etc.): Media is optional.
|
|
972
|
+
- **Image fit (aspect ratio)**: a feed image outside what a channel shows uncropped is handled PER CHANNEL. Instagram shows feed images between 4:5 and 1.91:1: a 9:16 (story-style) image posted to the Instagram feed is CROPPED to 4:5 around the subject by default, because Instagram cuts it to that window anyway and Meta cannot boost a feed post outside it. Pinterest keeps a Pin taller than 2:3 and reports it. Before posting a tall image to Instagram, run \`check_media_compatibility\` (its fit rows say per connected channel whether the image fits and what the default does) and ASK the user: crop to 4:5 (recommended: Meta can boost it) or keep the original (Instagram cuts it itself, no boost)? Pass the answer as \`media_fit: { instagram: "crop" | "keep" }\`. If the user gave no preference, let the default run and RELAY the \`image_fit\` warning from the response (it names the crop and how to undo it with update_post). Stories, reels and videos are never cropped; 'default' and the other channels keep the original. Never drop a channel over this.
|
|
750
973
|
|
|
751
974
|
**Per-platform max media items (enforced at submit, returns 400 validation_error if exceeded):**
|
|
752
975
|
- Bluesky, X, Mastodon — max **4** items per post; cannot mix images and video
|
|
@@ -754,10 +977,12 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
754
977
|
- TikTok — max **35** photos per photo-post; cannot mix photos and videos in one post
|
|
755
978
|
- Pinterest carousel — 2–5 images, all same aspect ratio (a video splits off into its own video pin)
|
|
756
979
|
- Facebook, LinkedIn, LinkedIn Page — cannot mix images and video in one post (images only, or a single video)
|
|
980
|
+
- Telegram — max **10** items per album; photos and videos CAN be mixed, a GIF goes on its own, files do not mix with photos
|
|
981
|
+
- Discord — max **10** files per message; images, GIFs and videos CAN be mixed
|
|
757
982
|
|
|
758
983
|
**Per-platform video caps (duration + size, checked via ffprobe at submit; exceeding either returns a 400 validation_error stating the exact cap and the file's value, e.g. "X only allows videos up to 2min 20s; yours is 2min 35s. Trim the video or deselect X."):**
|
|
759
984
|
- X — **140s (2min 20s)** · **512MB** on free/Basic accounts; when the connected X account has **Premium or Premium+** the caps rise to **125min / 16GB** automatically (tier is read from the connection; reconnect X after upgrading). X also can't mix a video with images in one tweet: a tweet is either 1 video OR up to 4 images
|
|
760
|
-
- Bluesky
|
|
985
|
+
- Bluesky 10min · Threads 5min · Instagram 15min · TikTok 10min · YouTube Short 3min · LinkedIn 30min · Pinterest 15min · Facebook 240min (Post + Reel; the old 90s Reel cap is gone) · Telegram no duration cap, **50MB** per file (photos 10MB) · Discord no duration cap; a video or GIF over **20MB** goes as a link in the message instead of a file
|
|
761
986
|
Pick a file within the cap up front; if the user's video is over, tell them the limit so they can trim it rather than retrying blindly.
|
|
762
987
|
|
|
763
988
|
When attaching more than these caps to a selected platform, EITHER trim the array OR move to a flat \`media_urls: [...]\` and let the user know which platform will be over the limit so they can split into multiple posts.
|
|
@@ -769,8 +994,10 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
769
994
|
If the user named a board ("post to my Marketing board") or specified one in the request, use that one instead of the first — match on name (case-insensitive) against the boards list.
|
|
770
995
|
- YouTube: Title, privacy status, tags?
|
|
771
996
|
- TikTok: Privacy level?
|
|
772
|
-
- **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free
|
|
773
|
-
- **
|
|
997
|
+
- **X threads vs long-form**: A chained "thread" (the user explicitly asks for one) → pass \`x.thread_parts\` as an array of 2–25 \`{ text }\` objects (each ≤ 280 chars on free accounts, 25,000 on Basic, Premium and Premium+); the \`content\` field is then ignored for X. A single **long-form** post on a Basic, Premium or Premium+ account → just put the full text (up to 25,000 chars) in \`content\` — no threading needed (check \`platform_details.subscription_type\` via list_accounts). On a free account, X caps a single post at 280 chars, so either split into a thread or shorten. Never cram "1/", "2/" prefixes into \`content\` — that posts one tweet, not a thread.
|
|
998
|
+
- **Telegram** (a channel or group the user connected; the OmniSocials bot posts it): options under \`telegram\`: \`silent\`, \`link_preview\`, \`pin\`, \`protect_content\`, \`spoiler\`, \`caption_above_media\`, up to 3 \`buttons\` (\`{ text, url }\`, not on an album or a poll), a native \`poll\` (2 to 12 options; the post text is the question, max 300 chars; no media) and \`first_comment\` (a reply under the post). Text up to 4,096 characters; with media, text over 1,024 characters is sent as a reply under the media. No Stories. Telegram gives bots no view counts: statistics are reactions and the subscriber count.
|
|
999
|
+
- **Discord** (one Discord channel the user connected; the message is sent through the channel's webhook and shows the webhook's name and picture): options under \`discord\`: \`silent\`, \`link_preview\`, \`allow_mentions\` (off by default, so @everyone and role mentions in the text ping nobody; turn it on only when the user asks to ping), \`spoiler\`, \`thread_name\` (the post title, required in a forum or media channel; a text channel takes none, so the post goes out there without it), up to 5 \`buttons\` (\`{ text, url }\`, not with a poll) and a native \`poll\` (2 to 10 options of up to 55 chars, \`duration_hours\` 1 to 768, default 24; the post text is the question, max 300 chars; no media). Text up to 2,000 characters. No Stories; a Reel is one video message. Discord gives webhooks no view counts: statistics are reactions and the poll votes. A sent message can be deleted at any time.
|
|
1000
|
+
- **Bluesky, Mastodon and Threads chains**: the same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (each an array of 2 to 25 \`{ text, media_ids?, media_urls? }\` objects); parts after the first publish as replies to the previous part and \`content\` is then ignored for that platform. Threads: 2 to 25 parts, 500 characters per part, up to 20 media per part (images and videos can be mixed). Bluesky: 300 characters per part, 10 images or 1 video. Mastodon: 500 characters per part (instance-dependent), 4 media.
|
|
774
1001
|
- **X posts containing a link cost credits**: X's API bills posts whose text contains a URL at a premium, and OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet; threads are charged per part that contains a link). Only a link written with http:// or https:// counts; a bare domain (brand.com) or a www. link is free. The create response includes a \`warnings\` entry (\`x_url_post_credits\`) with the cost and current balance — relay it to the user. Credits are only deducted after the post successfully publishes; a failed publish is never charged. If the balance can't cover it at publish time, only the X target fails (message says to top up at https://app.omnisocials.com/credits) and the post can be retried after topping up. Posts without links stay free — never remove a user's link to dodge the fee without asking them. Scheduling is also gated up front: every scheduled X link post reserves its cost, and a create/schedule that would push the reserved total past the balance is refused with a 402 \`x_credits_insufficient\` error (details carry credits_required / credits_balance / credits_reserved) — tell the user to top up or remove the link, don't silently retry.
|
|
775
1002
|
|
|
776
1003
|
Do NOT call this tool without media when creating stories, reels, Instagram posts, TikTok posts, or Pinterest posts — it will fail.
|
|
@@ -778,7 +1005,8 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
778
1005
|
**When the user shares an image in chat but you cannot upload it** (e.g. no public URL available): save the post as a draft with the caption, then tell the user: "I saved your post as a draft in OmniSocials. Open it there to add your image and schedule it when ready." Include a link to https://app.omnisocials.com. Do NOT tell the user it's impossible — always save the draft so their caption isn't lost.`, {
|
|
779
1006
|
content: z.union([z.string(), z.record(z.string(), z.string())]).describe("Post caption. String for same text on all channels, or object with platform keys for per-channel captions: { \"default\": \"fallback\", \"linkedin\": \"long version\", \"threads\": \"short version\" }. The \"default\" key is used for any selected channel without its own key."),
|
|
780
1007
|
channels: z.array(z.string()).optional().describe("Array of channel IDs to post to. Get the available channel IDs from list_accounts. Each entry may be a bare platform name (e.g. `\"youtube\"`) or the composite `\"<workspace_id>_<platform>\"` form from list_accounts (e.g. `\"844008_youtube\"`); always source these from list_accounts for the API key in use — composite IDs with an unknown or mismatched workspace prefix are rejected as unknown accounts. Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels. A workspace can have both connected and post to each separately."),
|
|
781
|
-
scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing"),
|
|
1008
|
+
scheduled_at: z.string().optional().describe("ISO 8601 date for scheduled publishing. With `draft: true` the time is kept but the post stays a draft (a planned draft on the user's calendar)."),
|
|
1009
|
+
draft: z.boolean().optional().describe("Planned draft. With scheduled_at, the post is stored as a draft that keeps that time: it shows on the user's calendar at that slot with a dashed outline, is never published by the scheduler, and the user schedules it from the composer (or you do with update_post and draft: false). Use it when you propose a time slot that the user should confirm, e.g. 'put a draft on Thursday 10:00 for me to review'. Not allowed with publish_now or approval_workflow_id. Without scheduled_at it changes nothing."),
|
|
782
1010
|
media_ids: z.union([
|
|
783
1011
|
z.array(mediaIdEntry),
|
|
784
1012
|
z.record(z.string(), z.array(mediaIdEntry)),
|
|
@@ -795,13 +1023,14 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
795
1023
|
location_id: z.string().optional().describe("Instagram only. Facebook Place ID of a single physical venue (with a street address) to tag the post's location. Applied to single-image and carousel Instagram feed posts. To get a valid ID, call the `search_locations` tool with the place name and let the user pick. Ignored by other platforms."),
|
|
796
1024
|
collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors (the 'Collab' feature). Works on image, carousel, and reel posts — NOT Stories. Invited users get an invite in the Instagram app; once they accept, the post also appears on their profile and feed. A leading '@' is stripped; usernames are case-insensitive. Private or non-existent usernames are rejected by Instagram at publish time with a clear error. Ignored by other platforms."),
|
|
797
1025
|
user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). For a single image omit image_index; for a carousel, set image_index to the slide each tag belongs to. Private/non-existent usernames are rejected at publish time. Ignored by other platforms."),
|
|
798
|
-
hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's
|
|
799
|
-
hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube/TikTok (TikTok only when the workspace enabled TikTok comments) (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
|
|
1026
|
+
hashtag_set: z.string().optional().describe("Name of a saved hashtag set (from list_hashtag_sets, matched case-insensitively) to apply. The set's tags are merged in ONCE at create time — tags already in a caption are skipped, and Instagram's 5-hashtag cap returns a clear hashtag_limit_exceeded error. When the user says 'add my usual hashtags', check list_hashtag_sets first."),
|
|
1027
|
+
hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("Where the set's tags land. caption_append (default): appended to each target caption after a blank line. first_comment: posted as the auto first comment on Instagram/Facebook/LinkedIn/LinkedIn Page/YouTube/TikTok/Telegram (TikTok only when the workspace enabled TikTok comments) (appended after any explicit first_comment); platforms without a comment API fall back to caption_append. Stories always use captions."),
|
|
800
1028
|
hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to (e.g. [\"instagram\", \"tiktok\"]). Defaults to all selected channels."),
|
|
801
1029
|
video_cover: VIDEO_COVER_OPTIONS,
|
|
1030
|
+
media_fit: MEDIA_FIT_OPTIONS,
|
|
802
1031
|
pinterest: PINTEREST_OPTIONS,
|
|
803
1032
|
youtube: z.object({
|
|
804
|
-
title: z.string().optional().describe("Short title shown on YouTube.
|
|
1033
|
+
title: z.string().optional().describe("Short title shown on YouTube (max 100 characters). When omitted, the first line of the caption is used; \"YouTube Short\" only when there is no caption either."),
|
|
805
1034
|
tags: z.array(z.string()).optional(),
|
|
806
1035
|
privacy_status: z.enum(["public", "private", "unlisted"]).optional(),
|
|
807
1036
|
category_id: z.string().optional(),
|
|
@@ -832,20 +1061,26 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
832
1061
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
833
1062
|
paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
|
|
834
1063
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
1064
|
+
poll: X_POLL_OPTIONS,
|
|
835
1065
|
thread_parts: z.array(X_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained X thread instead of a single tweet. Provide 2–25 parts; each is posted in order via in_reply_to_tweet_id. Attach media to any part (first tweet or reply) via media_ids (from upload_media) or media_urls — max 4 per part. For a single tweet, omit thread_parts and use content."),
|
|
836
1066
|
}).optional().describe("X (Twitter) options"),
|
|
837
1067
|
bluesky: z.object({
|
|
838
|
-
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to
|
|
1068
|
+
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread instead of a single post. Provide 2–25 parts; each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids (from upload_media) or media_urls — a part is one video OR up to 10 images. Links, mentions and hashtags are made clickable automatically. For a single post, omit thread_parts and use content."),
|
|
839
1069
|
}).optional().describe("Bluesky options"),
|
|
840
1070
|
mastodon: z.object({
|
|
1071
|
+
poll: MASTODON_POLL_OPTIONS,
|
|
841
1072
|
thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread instead of a single status. Provide 2–25 parts; each is posted in order as a native reply to the previous status (in_reply_to_id). Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part. For a single status, omit thread_parts and use content."),
|
|
842
1073
|
}).optional().describe("Mastodon options"),
|
|
843
1074
|
threads: z.object({
|
|
1075
|
+
poll: THREADS_POLL_OPTIONS,
|
|
844
1076
|
location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
|
|
845
1077
|
thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread instead of a single post. Provide 2 to 25 parts; parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids (from upload_media) or media_urls. When set, the Threads caption is taken from part 1 and content is ignored for Threads. For a single post, omit thread_parts and use content."),
|
|
846
1078
|
}).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
|
|
847
1079
|
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
1080
|
+
telegram: TELEGRAM_OPTIONS,
|
|
1081
|
+
discord: DISCORD_OPTIONS,
|
|
848
1082
|
approval_workflow_id: z.string().optional().describe("Route the post through a saved approval workflow (id from list_approval_workflows). The post is created as `in_approval` instead of `scheduled`: its approvers are notified, they review it on the Approvals page, and it publishes at scheduled_at only once the workflow's last step approves it (or approve_post). Use this whenever the user wants to review/approve agent-created posts before they go out. Requires scheduled_at; not allowed with publish_now (use create_post, never create_and_publish_post). A workflow with no approvers on its first step is rejected with validation_error."),
|
|
1083
|
+
comment_dm_automation: COMMENT_DM_AUTOMATION.optional(),
|
|
849
1084
|
}, { title: "Create Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async (params) => {
|
|
850
1085
|
const result = await getClient().createPost({ ...params, source: "mcp" });
|
|
851
1086
|
if (result.error) {
|
|
@@ -869,9 +1104,14 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
869
1104
|
const schedAt = p.schedule_at ?? p.scheduled_at;
|
|
870
1105
|
const channels = selectedChannels(p);
|
|
871
1106
|
const inApproval = p.status === "in_approval";
|
|
1107
|
+
// A draft that carries a time is a planned draft: on the calendar,
|
|
1108
|
+
// not scheduled. Say so, or the agent reports it as scheduled.
|
|
1109
|
+
const planned = p.status === "draft" && !!schedAt;
|
|
872
1110
|
let md = inApproval
|
|
873
1111
|
? `## Post Submitted for Approval\n\n`
|
|
874
|
-
:
|
|
1112
|
+
: planned
|
|
1113
|
+
? `## Draft Planned (not scheduled yet)\n\n`
|
|
1114
|
+
: `## Draft Created (not yet published)\n\n`;
|
|
875
1115
|
md += `| Field | Value |\n`;
|
|
876
1116
|
md += `|-------|-------|\n`;
|
|
877
1117
|
md += `| **ID** | \`${p.id}\` |\n`;
|
|
@@ -879,15 +1119,19 @@ Do NOT call this tool without media when creating stories, reels, Instagram post
|
|
|
879
1119
|
if (inApproval)
|
|
880
1120
|
md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
|
|
881
1121
|
if (schedAt)
|
|
882
|
-
md += `| **${inApproval ? "Publishes (once approved)" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
|
|
1122
|
+
md += `| **${inApproval ? "Publishes (once approved)" : planned ? "Planned for" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
|
|
883
1123
|
if (channels.length)
|
|
884
1124
|
md += `| **Channels** | ${channels.join(", ")} |\n`;
|
|
1125
|
+
md += commentDmLine(p);
|
|
885
1126
|
if (appUrl)
|
|
886
1127
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
887
1128
|
md += `\n**Content:** ${truncate(p.content, 100)}\n\n`;
|
|
888
1129
|
if (inApproval) {
|
|
889
1130
|
md += `_The approvers have been notified. The post publishes at the time above as soon as the workflow's last step approves it (Approvals page in OmniSocials, or approve_post). Nothing else is needed from the user until then._\n\n`;
|
|
890
1131
|
}
|
|
1132
|
+
else if (planned) {
|
|
1133
|
+
md += `_The draft sits on the user's calendar at that time with a dashed outline. It does not publish until someone schedules it: the user from the composer, or you with update_post and draft: false._\n\n`;
|
|
1134
|
+
}
|
|
891
1135
|
if (Array.isArray(result.warnings) && result.warnings.length > 0) {
|
|
892
1136
|
for (const w of result.warnings) {
|
|
893
1137
|
md += `⚠️ ${w.message}\n`;
|
|
@@ -928,10 +1172,12 @@ IMPORTANT — Before calling this tool, make sure you have all required informat
|
|
|
928
1172
|
- Instagram/TikTok posts: ALWAYS need at least one image or video.
|
|
929
1173
|
- Pinterest: ALWAYS need an image + board_id.
|
|
930
1174
|
- Ask the user which media to use. Use list_media to show options.
|
|
931
|
-
- **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
|
|
932
|
-
- **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky
|
|
1175
|
+
- **Max items per platform** (same caps as create_post — exceeding returns 400): Bluesky/X/Mastodon ≤4, Instagram/Threads/Telegram/Discord ≤10, TikTok ≤35 photos, Pinterest carousel 2–5 same-ratio images. Mixing images+video is rejected on Facebook/LinkedIn/X/Bluesky/Mastodon/TikTok; Instagram and Threads mixed carousels are allowed.
|
|
1176
|
+
- **Video duration/size caps** (ffprobe at submit — over either returns 400 validation_error with the exact cap + the file's value): X **140s/512MB** free/Basic, **125min/16GB** when the connected X account has Premium/Premium+ (auto-detected; no video+image mix in one tweet), Bluesky 10min, Threads 5min, Instagram 15min, TikTok 10min, YouTube Short 3min, LinkedIn 30min, Facebook 240min (Post + Reel; the old 90s Reel cap is gone), Telegram 50MB per file, Discord: a video or GIF over 20MB goes as a link in the message.
|
|
1177
|
+
- **Telegram**: options under \`telegram\` (silent, link_preview, pin, protect_content, spoiler, caption_above_media, buttons, poll, first_comment), same shape as create_post. No Stories.
|
|
1178
|
+
- **Discord**: options under \`discord\` (silent, link_preview, allow_mentions, spoiler, thread_name, buttons, poll), same shape as create_post. No Stories. \`allow_mentions\` stays off unless the user asks to ping @everyone or a role.
|
|
933
1179
|
4. **Pinterest board (auto-default to first board)**: If Pinterest is in \`channels\` and \`pinterest.board_id\` is NOT provided, do NOT block on asking — and do NOT skip Pinterest. Call \`get_account\` on the Pinterest account, take the FIRST board from the returned boards list, and pass its \`id\` as \`pinterest.board_id\`. In your reply, mention which board you used (e.g. "Published to your 'Marketing' board on Pinterest — let me know if you'd prefer a different one.") so the user can redirect. If the user named a specific board in the request, match it (case-insensitive) against the list and use that one instead.
|
|
934
|
-
5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free
|
|
1180
|
+
5. **X threads vs long-form**: A chained "thread" → pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free accounts, 25,000 on Basic, Premium and Premium+). A single long-form post on a Basic, Premium or Premium+ account → put the full text (up to 25,000 chars) in \`content\`; no threading needed. On a free account, X caps a single post at 280 chars. Do NOT split into "1/", "2/" inside \`content\` — that produces a single tweet, not a thread. The same \`thread_parts\` shape works under \`bluesky\`, \`mastodon\` and \`threads\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; parts after the first publish as replies to the previous part).
|
|
935
1181
|
6. **X posts containing a link cost credits**: X bills API posts whose text contains a URL at a premium; OmniSocials passes that through as prepaid credits at X's exact rate (20 credits ≈ $0.20 per URL-containing tweet, threads charged per link-containing part; only a link written with http:// or https:// counts, a bare domain or www. link is free). Because this tool publishes IMMEDIATELY, the debit happens right away — if the company's balance can't cover it, the X target fails with a top-up message while other platforms still publish. Relay any \`x_url_post_credits\` warning and X publish failure to the user; never strip their link to avoid the fee without asking. If the balance (minus credits reserved by scheduled X link posts) can't cover this post, the request is refused up front with a 402 \`x_credits_insufficient\` error instead of failing at publish.
|
|
936
1182
|
|
|
937
1183
|
Do NOT call without required media — it will fail.`, {
|
|
@@ -957,6 +1203,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
957
1203
|
hashtag_placement: z.enum(["caption_append", "first_comment"]).optional().describe("caption_append (default) appends tags to the captions; first_comment posts them as the auto first comment on comment-capable platforms (others fall back to caption)."),
|
|
958
1204
|
hashtag_platforms: z.array(z.string()).optional().describe("Optional subset of the post's channels to apply the hashtag set to. Defaults to all selected channels."),
|
|
959
1205
|
video_cover: VIDEO_COVER_OPTIONS,
|
|
1206
|
+
media_fit: MEDIA_FIT_OPTIONS,
|
|
960
1207
|
pinterest: PINTEREST_OPTIONS,
|
|
961
1208
|
youtube: z.object({
|
|
962
1209
|
title: z.string().optional().describe("Short title shown on YouTube."),
|
|
@@ -990,19 +1237,25 @@ Do NOT call without required media — it will fail.`, {
|
|
|
990
1237
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
991
1238
|
paid_partnership: z.boolean().optional().describe("Mark as paid partnership disclosure"),
|
|
992
1239
|
made_with_ai: z.boolean().optional().describe("Mark as AI-generated content"),
|
|
1240
|
+
poll: X_POLL_OPTIONS,
|
|
993
1241
|
thread_parts: z.array(X_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained X thread (2–25 parts). Each is posted in order via in_reply_to_tweet_id. Attach media to any part via media_ids (from upload_media) or media_urls — max 4 per part."),
|
|
994
1242
|
}).optional().describe("X (Twitter) options"),
|
|
995
1243
|
bluesky: z.object({
|
|
996
|
-
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to
|
|
1244
|
+
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Bluesky thread (2–25 parts). Each is posted in order via AT Protocol reply refs (root + parent). Attach media to any part via media_ids or media_urls — one video OR up to 10 images per part. Links, mentions and hashtags are made clickable automatically."),
|
|
997
1245
|
}).optional().describe("Bluesky options"),
|
|
998
1246
|
mastodon: z.object({
|
|
1247
|
+
poll: MASTODON_POLL_OPTIONS,
|
|
999
1248
|
thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Mastodon thread (2–25 parts). Each is posted in order as a native reply (in_reply_to_id). Attach media to any part via media_ids or media_urls — max 4 per part."),
|
|
1000
1249
|
}).optional().describe("Mastodon options"),
|
|
1001
1250
|
threads: z.object({
|
|
1251
|
+
poll: THREADS_POLL_OPTIONS,
|
|
1002
1252
|
location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
|
|
1003
1253
|
thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).optional().describe("Publish as a chained Threads (Meta) thread (2 to 25 parts). Parts after the first are posted as replies to the previous part. Each part holds up to 500 characters and up to 10 media (images and videos can be mixed) via media_ids or media_urls. The Threads caption is taken from part 1."),
|
|
1004
1254
|
}).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
|
|
1005
1255
|
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
1256
|
+
telegram: TELEGRAM_OPTIONS,
|
|
1257
|
+
discord: DISCORD_OPTIONS,
|
|
1258
|
+
comment_dm_automation: COMMENT_DM_AUTOMATION.optional(),
|
|
1006
1259
|
}, { title: "Create and Publish Post", readOnlyHint: false, destructiveHint: true, openWorldHint: true }, async (params) => {
|
|
1007
1260
|
const result = await getClient().createAndPublishPost({ ...params, source: "mcp" });
|
|
1008
1261
|
if (result.error) {
|
|
@@ -1049,12 +1302,13 @@ Do NOT call without required media — it will fail.`, {
|
|
|
1049
1302
|
});
|
|
1050
1303
|
server.tool("update_post", `Update an existing post. Only draft and scheduled posts can be updated.
|
|
1051
1304
|
|
|
1052
|
-
**Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`, \`google_business\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
|
|
1305
|
+
**Per-platform options (\`youtube\`, \`pinterest\`, \`instagram\`, \`tiktok\`, \`google_business\`, \`telegram\`, \`discord\`)** are accepted here, same shape as in \`create_post\`. Pass an object — never a JSON-encoded string. For YouTube Shorts, the **title** lives at \`youtube.title\`; the **video description** lives in \`content\` (or \`content.youtube\` for a per-platform override). They are not the same field — changing the caption does NOT rename the Short.
|
|
1053
1306
|
|
|
1054
|
-
**X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free
|
|
1307
|
+
**X threads**: To convert an existing draft into a chained X thread, pass \`x.thread_parts\` as a 2–25 entry array of \`{ text }\` objects (each ≤ 280 chars on free accounts, 25,000 on Basic, Premium and Premium+). Pass \`null\` to revert to single-tweet mode. Do NOT shove "1/", "2/" into \`content\` — that's a single tweet, not a thread. The same applies to \`bluesky.thread_parts\`, \`mastodon.thread_parts\` and \`threads.thread_parts\` (Threads: 2 to 25 parts, 500 characters per part, up to 10 media per part; \`null\` reverts to a single post).`, {
|
|
1055
1308
|
id: z.string().describe("The post ID to update"),
|
|
1056
1309
|
content: z.union([z.string(), z.record(z.string(), z.string())]).optional().describe("Updated post caption / body text. For YouTube Shorts this becomes the video description, NOT the title — to rename the Short, use `youtube.title`. String or object with platform keys: { \"default\": \"fallback\", \"linkedin\": \"long\" }."),
|
|
1057
|
-
scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601, must be in the future). A draft becomes scheduled. A post waiting for approval keeps its in_approval status; only its time moves."),
|
|
1310
|
+
scheduled_at: z.string().optional().describe("Updated scheduled date (ISO 8601, must be in the future). A draft becomes scheduled (or stays a planned draft when draft: true is sent with it). A post waiting for approval keeps its in_approval status; only its time moves."),
|
|
1311
|
+
draft: z.boolean().optional().describe("Planned-draft switch. false without scheduled_at schedules a planned draft at the time it already carries (validation_error when it has no time or the time is in the past). true without scheduled_at unschedules a scheduled post: it keeps its time, stays on the calendar as a dashed draft and nothing publishes; a post waiting for approval leaves its workflow. true with scheduled_at moves the time and keeps the post a draft."),
|
|
1058
1312
|
channels: z.array(z.string()).optional().describe("Updated channel IDs. Bare platform name or composite `\"<workspace_id>_<platform>\"`; always source from list_accounts for this API key (unknown or mismatched workspace prefixes are rejected as unknown accounts). Note: `linkedin` (personal profile) and `linkedin_page` (company page) are independent channels."),
|
|
1059
1313
|
media_ids: z.union([
|
|
1060
1314
|
z.array(mediaIdEntry),
|
|
@@ -1072,6 +1326,7 @@ Do NOT call without required media — it will fail.`, {
|
|
|
1072
1326
|
collaborators: z.array(z.string()).max(3).optional().describe("Instagram only. Up to 3 public Instagram usernames to invite as co-authors. Replaces the existing collaborator list. Send an empty array to clear collaborators. Works on image, carousel, and reel posts — NOT Stories. Ignored by other platforms."),
|
|
1073
1327
|
user_tags: z.array(USER_TAG_ENTRY).optional().describe("Instagram only. Tag public accounts at x/y positions on a PHOTO (not video/reels/stories). Replaces the existing tag list. Send an empty array to clear. For carousels set image_index per tag. Ignored by other platforms."),
|
|
1074
1328
|
video_cover: VIDEO_COVER_OPTIONS.nullable().describe("Replaces the stored video cover wholesale; pass null to remove it; omit to leave it untouched. facebook.thumbnail_type / thumb_offset / cover_url merge into overrides.facebook on their own."),
|
|
1329
|
+
media_fit: MEDIA_FIT_OPTIONS,
|
|
1075
1330
|
youtube: z.object({
|
|
1076
1331
|
title: z.string().optional().describe("Short title shown on YouTube. To change the Short's title on an existing draft, set this — do NOT use `content` (which is the description)."),
|
|
1077
1332
|
tags: z.array(z.string()).optional(),
|
|
@@ -1105,19 +1360,25 @@ Do NOT call without required media — it will fail.`, {
|
|
|
1105
1360
|
reply_settings: z.enum(["", "following", "mentionedUsers"]).optional(),
|
|
1106
1361
|
paid_partnership: z.boolean().optional(),
|
|
1107
1362
|
made_with_ai: z.boolean().optional(),
|
|
1363
|
+
poll: X_POLL_OPTIONS,
|
|
1108
1364
|
thread_parts: z.array(X_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the X thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls, max 4 per part), or `null` to revert to single-tweet mode."),
|
|
1109
1365
|
}).optional().describe("X (Twitter) options"),
|
|
1110
1366
|
bluesky: z.object({
|
|
1111
|
-
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Bluesky thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; one video OR up to
|
|
1367
|
+
thread_parts: z.array(BLUESKY_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Bluesky thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; one video OR up to 10 images per part), or `null` to revert to single-post mode."),
|
|
1112
1368
|
}).optional().describe("Bluesky options"),
|
|
1113
1369
|
mastodon: z.object({
|
|
1370
|
+
poll: MASTODON_POLL_OPTIONS,
|
|
1114
1371
|
thread_parts: z.array(MASTODON_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Mastodon thread shape on this post. Pass an array (2–25 parts) to update/create the thread (attach media to any part via media_ids or media_urls; max 4 per part), or `null` to revert to single-status mode."),
|
|
1115
1372
|
}).optional().describe("Mastodon options"),
|
|
1116
1373
|
threads: z.object({
|
|
1374
|
+
poll: THREADS_POLL_OPTIONS,
|
|
1117
1375
|
location_id: z.string().nullable().optional().describe("Tag a location on the Threads post. Use an `id` from search_locations with platform \"threads\" (Threads location ids are NOT Facebook Place IDs, so never reuse the Instagram location_id here). On a multi-post thread the tag goes on the first post. Needs the workspace's Threads connection to have the threads_location_tagging permission; the API returns a clear 400 asking to reconnect Threads when it is missing, and a clear 400 while location tagging is still rolling out (Meta has not approved the permission yet). On update_post, pass null to remove the tag."),
|
|
1118
1376
|
thread_parts: z.array(THREADS_THREAD_PART).min(2).max(25).nullable().optional().describe("Replace the Threads (Meta) thread shape on this post. Pass an array (2 to 25 parts, 500 characters per part) to update/create the thread (attach up to 10 media to any part via media_ids or media_urls; images and videos can be mixed), or `null` to revert to single-post mode."),
|
|
1119
1377
|
}).optional().describe("Threads (Meta) options: thread mode via `thread_parts`, location tag via `location_id`."),
|
|
1120
1378
|
google_business: GOOGLE_BUSINESS_OPTIONS,
|
|
1379
|
+
telegram: TELEGRAM_OPTIONS,
|
|
1380
|
+
discord: DISCORD_OPTIONS,
|
|
1381
|
+
comment_dm_automation: COMMENT_DM_AUTOMATION.nullable().optional().describe("Replaces the post's comment-to-DM setup (keywords, DM text, public replies, limits, platforms); a channel no longer listed in platforms loses its automation; pass null to remove them all; omit to leave them untouched. Needs the opt-in automations:write scope."),
|
|
1121
1382
|
}, { title: "Update Post", readOnlyHint: false, destructiveHint: false, openWorldHint: false }, async ({ id, ...data }) => {
|
|
1122
1383
|
const result = await getClient().updatePost(id, data);
|
|
1123
1384
|
if (result.error) {
|
|
@@ -1139,9 +1400,12 @@ Do NOT call without required media — it will fail.`, {
|
|
|
1139
1400
|
md += `| **ID** | \`${p.id}\` |\n`;
|
|
1140
1401
|
md += `| **Status** | ${capitalize(p.status || "—")} |\n`;
|
|
1141
1402
|
if (schedAt)
|
|
1142
|
-
md += `|
|
|
1403
|
+
md += `| **${p.status === "draft" ? "Planned for" : "Scheduled"}** | ${formatDateTime(schedAt)} |\n`;
|
|
1143
1404
|
if (appUrl)
|
|
1144
1405
|
md += `| **Open in OmniSocials** | ${appUrl} |\n`;
|
|
1406
|
+
if (p.status === "draft" && schedAt) {
|
|
1407
|
+
md += `\n_A planned draft: on the user's calendar at that time, not published until it is scheduled (composer, or update_post with draft: false)._\n`;
|
|
1408
|
+
}
|
|
1145
1409
|
return {
|
|
1146
1410
|
content: [{ type: "text", text: md }],
|
|
1147
1411
|
};
|
|
@@ -1176,6 +1440,8 @@ IMPORTANT: Before publishing, verify the post has all required media. If publish
|
|
|
1176
1440
|
md += `|-------|-------|\n`;
|
|
1177
1441
|
md += `| **ID** | \`${p.id}\` |\n`;
|
|
1178
1442
|
md += `| **Status** | ${capitalize(p.status || "publishing")} |\n`;
|
|
1443
|
+
md += appLinkRow({ ...p, id: p.id || id });
|
|
1444
|
+
md += `\n_Publishing runs in the background, usually within a minute. Share the "Open in OmniSocials" link with the user; \`get_post\` lists the live links once each platform has posted._`;
|
|
1179
1445
|
return {
|
|
1180
1446
|
content: [{ type: "text", text: md }],
|
|
1181
1447
|
};
|
|
@@ -1205,6 +1471,7 @@ The retry runs asynchronously (usually within a few minutes). Poll \`get_post\`
|
|
|
1205
1471
|
if (Array.isArray(p.platforms) && p.platforms.length) {
|
|
1206
1472
|
md += `| **Retrying** | ${p.platforms.map((x) => capitalize(x.replace(/_/g, " "))).join(", ")} |\n`;
|
|
1207
1473
|
}
|
|
1474
|
+
md += appLinkRow({ ...p, id: p.id || id });
|
|
1208
1475
|
md += `\n_Platforms that already succeeded are never re-published. Check \`get_post\` in a minute or two for the outcome._`;
|
|
1209
1476
|
return {
|
|
1210
1477
|
content: [{ type: "text", text: md }],
|
|
@@ -1267,6 +1534,7 @@ If this is the workflow's last step, the post is finalized immediately: it moves
|
|
|
1267
1534
|
md += `| **ID** | \`${p.id}\` |\n`;
|
|
1268
1535
|
md += `| **Status** | ${capitalize(p.status || "in_approval")} |\n`;
|
|
1269
1536
|
md += `| **Approval status** | ${capitalize(p.approval_status || "pending")} |\n`;
|
|
1537
|
+
md += appLinkRow({ ...p, id: p.id || id });
|
|
1270
1538
|
if (p.message)
|
|
1271
1539
|
md += `\n${p.message}\n`;
|
|
1272
1540
|
return {
|
|
@@ -1295,6 +1563,7 @@ IMPORTANT: this only succeeds if the connected user is a listed approver for the
|
|
|
1295
1563
|
md += `|-------|-------|\n`;
|
|
1296
1564
|
md += `| **ID** | \`${p?.id || id}\` |\n`;
|
|
1297
1565
|
md += `| **Status** | Rejected |\n`;
|
|
1566
|
+
md += appLinkRow({ id: p?.id || id, status: "rejected" });
|
|
1298
1567
|
md += `\n_The approval workflow has stopped. This post will not be published unless it's edited and resubmitted for approval._`;
|
|
1299
1568
|
return {
|
|
1300
1569
|
content: [{ type: "text", text: md }],
|
|
@@ -1514,9 +1783,9 @@ Notes:
|
|
|
1514
1783
|
md += `No product Pins in this part of the Pin list. Call again with bookmark \`${result.bookmark}\` to scan the next part.\n`;
|
|
1515
1784
|
}
|
|
1516
1785
|
else if (fromCatalog) {
|
|
1517
|
-
md += groups.length
|
|
1518
|
-
? "
|
|
1519
|
-
: "
|
|
1786
|
+
md += result?.no_catalog || !groups.length
|
|
1787
|
+
? "The Pinterest account has no catalog yet. The user adds products at https://www.pinterest.com/business/catalogs/ (then call again). Until then, try source \"pins\", or pass a product Pin link as check_pin.\n"
|
|
1788
|
+
: "This product group has no products.\n";
|
|
1520
1789
|
}
|
|
1521
1790
|
else {
|
|
1522
1791
|
md += "No product Pins found. A product Pin comes from the Pinterest catalog, or links to a product page on a website the account claimed on Pinterest.\n";
|