@xreplyai/mcp 0.3.10 → 0.3.16
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 +11 -19
- package/dist/index.js +0 -0
- package/dist/server.js +2 -2
- package/dist/server.js.map +1 -1
- package/dist/tools/communities.d.ts.map +1 -1
- package/dist/tools/communities.js +1 -7
- package/dist/tools/communities.js.map +1 -1
- package/dist/tools/context.d.ts.map +1 -1
- package/dist/tools/context.js +117 -7
- package/dist/tools/context.js.map +1 -1
- package/dist/tools/helpers.d.ts +4 -0
- package/dist/tools/helpers.d.ts.map +1 -0
- package/dist/tools/helpers.js +8 -0
- package/dist/tools/helpers.js.map +1 -0
- package/dist/tools/media.d.ts +6 -0
- package/dist/tools/media.d.ts.map +1 -0
- package/dist/tools/media.js +195 -0
- package/dist/tools/media.js.map +1 -0
- package/dist/tools/posts.d.ts.map +1 -1
- package/dist/tools/posts.js +301 -50
- package/dist/tools/posts.js.map +1 -1
- package/dist/tools/publish.d.ts.map +1 -1
- package/dist/tools/publish.js +1 -7
- package/dist/tools/publish.js.map +1 -1
- package/dist/types.d.ts +22 -30
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/dist/tools/viral-library.d.ts +0 -3
- package/dist/tools/viral-library.d.ts.map +0 -1
- package/dist/tools/viral-library.js +0 -92
- package/dist/tools/viral-library.js.map +0 -1
package/dist/tools/posts.js
CHANGED
|
@@ -2,39 +2,44 @@ import { z } from "zod";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { validateScheduledAt } from "../validation.js";
|
|
5
|
-
|
|
6
|
-
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
7
|
-
}
|
|
8
|
-
function err(error) {
|
|
9
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
10
|
-
return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
|
|
11
|
-
}
|
|
5
|
+
import { ok, err } from "./helpers.js";
|
|
12
6
|
const PostContentSchema = z.object({
|
|
13
|
-
platform: z.enum(["twitter", "linkedin"]).describe("Target platform"),
|
|
14
|
-
body: z.string().min(1).max(3000).describe("Post body text (max 280 for X, max 3000 for LinkedIn)"),
|
|
7
|
+
platform: z.enum(["twitter", "linkedin", "youtube", "threads", "instagram", "bluesky", "facebook", "google_business"]).describe("Target platform"),
|
|
8
|
+
body: z.string().min(1).max(3000).optional().describe("Post body text (max 280 for X, max 3000 for LinkedIn, max 500 for Threads, max 300 for Bluesky, max 1500 for Google Business). Optional for YouTube video posts (used as video description)."),
|
|
15
9
|
content_type: z
|
|
16
|
-
.enum(["text", "single_image", "multi_image", "video"])
|
|
10
|
+
.enum(["text", "single_image", "multi_image", "video", "document"])
|
|
17
11
|
.optional()
|
|
18
|
-
.describe("Content type (default: text). Use video for LinkedIn video posts."),
|
|
12
|
+
.describe("Content type (default: text). Use video for LinkedIn and YouTube video posts. Use document for LinkedIn carousel posts (PDF)."),
|
|
19
13
|
metadata: z
|
|
20
14
|
.object({
|
|
21
|
-
asset_urn: z.string().optional(),
|
|
22
|
-
asset_urns: z.array(z.string()).optional(),
|
|
23
|
-
media_id: z.string().optional().describe("X only — media_id from xreply_media_upload"),
|
|
15
|
+
asset_urn: z.string().optional().describe("LinkedIn only — asset_urn from xreply_media_upload or xreply_video_upload"),
|
|
16
|
+
asset_urns: z.array(z.string()).optional().describe("LinkedIn only — multiple asset_urns from xreply_media_upload"),
|
|
17
|
+
media_id: z.string().optional().describe("X only — media_id from xreply_media_upload (single image)"),
|
|
24
18
|
media_ids: z.array(z.string()).optional().describe("X only — multiple media_ids from xreply_media_upload"),
|
|
25
19
|
auto_rt_hours: z.number().int().positive().optional().describe("X only — hours after publishing to auto-retweet"),
|
|
26
20
|
community_id: z.string().regex(/^\d+$/).optional().describe("X only — numeric community ID to post into a community feed"),
|
|
21
|
+
title: z.string().optional().describe("YouTube only — video title (required for YouTube)"),
|
|
22
|
+
privacy_status: z.enum(["public", "private", "unlisted"]).optional().describe("YouTube only — video privacy (default: private)"),
|
|
23
|
+
category_id: z.string().optional().describe("YouTube only — numeric category ID (default: 22 = People & Blogs)"),
|
|
24
|
+
tags: z.array(z.string()).optional().describe("YouTube only — array of tags"),
|
|
25
|
+
thumbnail_url: z.string().optional().describe("YouTube only — URL of thumbnail image to set after upload"),
|
|
26
|
+
image_url: z.string().optional().describe("Threads only — public HTTPS URL of image to attach (required for single_image content_type on Threads). Instagram only — public JPEG URL for single_image posts"),
|
|
27
|
+
image_urls: z.array(z.string()).optional().describe("Instagram only — 2-10 public JPEG URLs for carousel (multi_image) posts"),
|
|
28
|
+
video_url: z.string().optional().describe("Instagram only — public MOV/MP4 URL for Reels (video) posts"),
|
|
29
|
+
cover_url: z.string().optional().describe("Instagram only — custom thumbnail URL for Reels"),
|
|
30
|
+
share_to_feed: z.boolean().optional().describe("Instagram only — true to share Reel to Feed tab as well as Reels tab"),
|
|
31
|
+
document_urn: z.string().optional().describe("LinkedIn only — document_urn from xreply_linkedin_document_upload, for carousel (document) posts"),
|
|
27
32
|
})
|
|
28
33
|
.optional()
|
|
29
|
-
.describe("Metadata for the post content. For X images: media_id or media_ids
|
|
34
|
+
.describe("Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: asset_urn. For LinkedIn carousel: document_urn. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For Threads image: image_url (public HTTPS URL). For X auto-retweet: auto_rt_hours. For X communities: community_id. For Instagram single_image: image_url. For Instagram multi_image carousel: image_urls (2-10 URLs). For Instagram video/reel: video_url, cover_url, share_to_feed."),
|
|
30
35
|
});
|
|
31
36
|
const CreatePostSchema = z.object({
|
|
32
|
-
body: z.string().min(1).max(280).optional().describe("Post body text for X only (max 280 chars). Use post_contents for LinkedIn or cross-posting."),
|
|
37
|
+
body: z.string().min(1).max(280).optional().describe("Post body text for X only (max 280 chars). Use post_contents for LinkedIn, Threads, Instagram, or cross-posting."),
|
|
33
38
|
post_contents: z
|
|
34
39
|
.array(PostContentSchema)
|
|
35
40
|
.min(1)
|
|
36
41
|
.optional()
|
|
37
|
-
.describe("Per-platform content. Use this for LinkedIn posts or when posting different text to multiple platforms."),
|
|
42
|
+
.describe("Per-platform content. Use this for LinkedIn, Threads, or Instagram posts, or when posting different text to multiple platforms."),
|
|
38
43
|
account_id: z
|
|
39
44
|
.number()
|
|
40
45
|
.int()
|
|
@@ -46,6 +51,7 @@ const CreatePostSchema = z.object({
|
|
|
46
51
|
.optional()
|
|
47
52
|
.describe("Array of social account IDs to post from. Takes precedence over account_id."),
|
|
48
53
|
});
|
|
54
|
+
const PLATFORM_ENUM = ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky", "facebook", "google_business"];
|
|
49
55
|
const GeneratePostSchema = z.object({
|
|
50
56
|
topic: z
|
|
51
57
|
.string()
|
|
@@ -57,13 +63,19 @@ const GeneratePostSchema = z.object({
|
|
|
57
63
|
.optional()
|
|
58
64
|
.describe("Writing angle for the post"),
|
|
59
65
|
platform: z
|
|
60
|
-
.enum(["twitter", "linkedin"])
|
|
66
|
+
.enum(["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"])
|
|
61
67
|
.optional()
|
|
62
|
-
.describe("Target platform —
|
|
68
|
+
.describe("Target platform for a single-platform post — controls output length and style. Use platforms (array) instead to generate for multiple platforms at once."),
|
|
69
|
+
platforms: z
|
|
70
|
+
.array(z.enum(PLATFORM_ENUM))
|
|
71
|
+
.min(2)
|
|
72
|
+
.max(5)
|
|
73
|
+
.optional()
|
|
74
|
+
.describe("Generate platform-native variants for multiple platforms at once (2-5 platforms). Each variant counts as 1 quota. Returns variants array instead of body."),
|
|
63
75
|
});
|
|
64
76
|
const GenerateBatchSchema = z.object({
|
|
65
77
|
category: z
|
|
66
|
-
.enum(["personalized", "trending"
|
|
78
|
+
.enum(["personalized", "trending"])
|
|
67
79
|
.describe("Category of posts to generate"),
|
|
68
80
|
count: z
|
|
69
81
|
.number()
|
|
@@ -71,10 +83,14 @@ const GenerateBatchSchema = z.object({
|
|
|
71
83
|
.min(1)
|
|
72
84
|
.max(9)
|
|
73
85
|
.describe("Number of posts to generate (1-9). Cannot exceed your remaining daily quota."),
|
|
86
|
+
platform: z
|
|
87
|
+
.enum(PLATFORM_ENUM)
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("Target platform — controls output length and style (default: twitter)."),
|
|
74
90
|
});
|
|
75
91
|
const EditPostSchema = z.object({
|
|
76
92
|
id: z.number().int().positive().describe("Post ID"),
|
|
77
|
-
body: z.string().min(1).max(280).optional().describe("New X post body text (max 280 chars). Use post_contents to update LinkedIn or multiple platforms."),
|
|
93
|
+
body: z.string().min(1).max(280).optional().describe("New X post body text (max 280 chars). Use post_contents to update LinkedIn, Threads, Instagram, or multiple platforms."),
|
|
78
94
|
post_contents: z
|
|
79
95
|
.array(PostContentSchema)
|
|
80
96
|
.min(1)
|
|
@@ -89,6 +105,12 @@ const EditPostSchema = z.object({
|
|
|
89
105
|
const DeletePostSchema = z.object({
|
|
90
106
|
id: z.number().int().positive().describe("Post ID to delete"),
|
|
91
107
|
});
|
|
108
|
+
const CarouselGenerateSchema = z.object({
|
|
109
|
+
topic: z.string().max(280).optional(),
|
|
110
|
+
slide_count: z.number().int().min(3).max(12).optional(),
|
|
111
|
+
theme: z.enum(["dark", "light", "blue", "green"]).optional(),
|
|
112
|
+
cta_text: z.string().max(100).optional(),
|
|
113
|
+
});
|
|
92
114
|
export const postTools = [
|
|
93
115
|
{
|
|
94
116
|
name: "xreply_posts_list",
|
|
@@ -110,7 +132,14 @@ export const postTools = [
|
|
|
110
132
|
},
|
|
111
133
|
{
|
|
112
134
|
name: "xreply_posts_create",
|
|
113
|
-
description: "Create a new post draft. Use `body` for a simple X-only post, or `post_contents` to specify per-platform content (e.g. different text for X and LinkedIn, or a
|
|
135
|
+
description: "Create a new post draft. Use `body` for a simple X-only post, or `post_contents` to specify per-platform content (e.g. different text for X and LinkedIn, or a YouTube-only post). The post is not published until you call xreply_posts_publish. " +
|
|
136
|
+
"For X images: call xreply_media_upload first, include media_id in metadata. " +
|
|
137
|
+
"For LinkedIn images: call xreply_media_upload first, include asset_urn in metadata. " +
|
|
138
|
+
"For LinkedIn video: call xreply_video_upload first, include asset_urn in metadata, set content_type to 'video'. " +
|
|
139
|
+
"For YouTube video: set platform to 'youtube', content_type to 'video', include title in metadata (required), privacy_status (default: private), and optionally tags/category_id/thumbnail_url. Body is used as the video description. After creating the draft, call xreply_youtube_upload with the post_id and video file path BEFORE publishing — then call xreply_posts_publish. " +
|
|
140
|
+
"For Threads: set platform to 'threads', body (max 500 chars). Image posts are supported via image_url in metadata. " +
|
|
141
|
+
"For Bluesky: set platform to 'bluesky', body (max 300 chars). Text-only posts supported. " +
|
|
142
|
+
"For Facebook Page: set platform to 'facebook', body (max 63206 chars). Supports text and single_image (image_url in metadata).",
|
|
114
143
|
inputSchema: {
|
|
115
144
|
type: "object",
|
|
116
145
|
properties: {
|
|
@@ -118,32 +147,42 @@ export const postTools = [
|
|
|
118
147
|
type: "string",
|
|
119
148
|
minLength: 1,
|
|
120
149
|
maxLength: 280,
|
|
121
|
-
description: "Post body text for X only (max 280 chars). Use post_contents for LinkedIn or cross-posting.",
|
|
150
|
+
description: "Post body text for X only (max 280 chars). Use post_contents for LinkedIn, Threads, or cross-posting.",
|
|
122
151
|
},
|
|
123
152
|
post_contents: {
|
|
124
153
|
type: "array",
|
|
125
154
|
minItems: 1,
|
|
126
|
-
description: "Per-platform content. Use this for LinkedIn posts or when posting different text to multiple platforms.",
|
|
155
|
+
description: "Per-platform content. Use this for LinkedIn or Threads posts, or when posting different text to multiple platforms.",
|
|
127
156
|
items: {
|
|
128
157
|
type: "object",
|
|
129
158
|
properties: {
|
|
130
|
-
platform: { type: "string", enum: ["twitter", "linkedin"], description: "Target platform" },
|
|
131
|
-
body: { type: "string", minLength: 1, maxLength:
|
|
132
|
-
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video"], description: "Content type (default: text). Use video for LinkedIn video posts." },
|
|
159
|
+
platform: { type: "string", enum: ["twitter", "linkedin", "youtube", "threads", "instagram", "bluesky", "facebook"], description: "Target platform" },
|
|
160
|
+
body: { type: "string", minLength: 1, maxLength: 63206, description: "Post body (max 280 for X, max 3000 for LinkedIn, max 500 for Threads, max 300 for Bluesky, max 63206 for Facebook). For YouTube, used as video description (optional)." },
|
|
161
|
+
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video", "document", "poll"], description: "Content type (default: text). Use video for LinkedIn and YouTube video posts. Use document for LinkedIn carousel posts (PDF). Use poll for LinkedIn polls." },
|
|
133
162
|
metadata: {
|
|
134
163
|
type: "object",
|
|
135
|
-
description: "Metadata for the post content. For X images: media_id or media_ids
|
|
164
|
+
description: "Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: set content_type to video. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For Threads image: image_url (public HTTPS URL — no upload step needed). For X auto-retweet: auto_rt_hours. For X communities: community_id. For LinkedIn polls: question (max 140 chars), options (2-4 strings, max 30 chars each), duration (ONE_DAY, THREE_DAYS, SEVEN_DAYS, or FOURTEEN_DAYS).",
|
|
136
165
|
properties: {
|
|
137
166
|
media_id: { type: "string", description: "X only — media_id from xreply_media_upload (single image)" },
|
|
138
167
|
media_ids: { type: "array", items: { type: "string" }, description: "X only — multiple media_ids from xreply_media_upload" },
|
|
139
|
-
asset_urn: { type: "string", description: "LinkedIn only — asset_urn from xreply_media_upload
|
|
168
|
+
asset_urn: { type: "string", description: "LinkedIn only — asset_urn from xreply_media_upload or xreply_video_upload" },
|
|
140
169
|
asset_urns: { type: "array", items: { type: "string" }, description: "LinkedIn only — multiple asset_urns from xreply_media_upload" },
|
|
141
170
|
auto_rt_hours: { type: "integer", minimum: 1, description: "X only — hours after publishing to auto-retweet" },
|
|
142
171
|
community_id: { type: "string", pattern: "^\\d+$", description: "X only — numeric community ID to post into a community feed (use xreply_list_twitter_communities to get IDs)" },
|
|
172
|
+
title: { type: "string", description: "YouTube only — video title (required for YouTube video posts)" },
|
|
173
|
+
privacy_status: { type: "string", enum: ["public", "private", "unlisted"], description: "YouTube only — video privacy setting (default: private)" },
|
|
174
|
+
category_id: { type: "string", description: "YouTube only — numeric category ID (default: 22 = People & Blogs)" },
|
|
175
|
+
tags: { type: "array", items: { type: "string" }, description: "YouTube only — video tags" },
|
|
176
|
+
thumbnail_url: { type: "string", description: "YouTube only — URL of thumbnail image to set after upload completes" },
|
|
177
|
+
image_url: { type: "string", description: "Threads only — public HTTPS URL of image to attach (required for single_image content_type on Threads)" },
|
|
178
|
+
question: { type: "string", maxLength: 140, description: "LinkedIn poll only — the poll question (max 140 chars)" },
|
|
179
|
+
options: { type: "array", items: { type: "string", maxLength: 30 }, minItems: 2, maxItems: 4, description: "LinkedIn poll only — poll answer options (2-4 items, each max 30 chars)" },
|
|
180
|
+
duration: { type: "string", enum: ["ONE_DAY", "THREE_DAYS", "SEVEN_DAYS", "FOURTEEN_DAYS"], description: "LinkedIn poll only — how long the poll stays open" },
|
|
181
|
+
linkedin_org_id: { type: "string", description: "LinkedIn only — numeric org ID to post as an organization page instead of personal profile" },
|
|
143
182
|
},
|
|
144
183
|
},
|
|
145
184
|
},
|
|
146
|
-
required: ["platform"
|
|
185
|
+
required: ["platform"],
|
|
147
186
|
},
|
|
148
187
|
},
|
|
149
188
|
account_id: {
|
|
@@ -184,7 +223,7 @@ export const postTools = [
|
|
|
184
223
|
},
|
|
185
224
|
{
|
|
186
225
|
name: "xreply_posts_generate",
|
|
187
|
-
description: "Generate
|
|
226
|
+
description: "Generate AI post(s) in the user's voice and auto-save as draft(s). Use platform (single) for one platform or platforms (array) for platform-native variants across multiple platforms at once. Each platform variant counts as 1 quota. Returns body + post for single-platform, variants + post for multi-platform.",
|
|
188
227
|
inputSchema: {
|
|
189
228
|
type: "object",
|
|
190
229
|
properties: {
|
|
@@ -200,8 +239,15 @@ export const postTools = [
|
|
|
200
239
|
},
|
|
201
240
|
platform: {
|
|
202
241
|
type: "string",
|
|
203
|
-
enum: ["twitter", "linkedin"],
|
|
204
|
-
description: "
|
|
242
|
+
enum: ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"],
|
|
243
|
+
description: "Single target platform. Use platforms (array) instead for multi-platform generation.",
|
|
244
|
+
},
|
|
245
|
+
platforms: {
|
|
246
|
+
type: "array",
|
|
247
|
+
items: { type: "string", enum: ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"] },
|
|
248
|
+
minItems: 2,
|
|
249
|
+
maxItems: 5,
|
|
250
|
+
description: "Generate for multiple platforms at once — each gets platform-native content. 2-5 platforms. Each counts as 1 quota.",
|
|
205
251
|
},
|
|
206
252
|
},
|
|
207
253
|
required: [],
|
|
@@ -211,16 +257,21 @@ export const postTools = [
|
|
|
211
257
|
const params = GeneratePostSchema.parse(args);
|
|
212
258
|
const billing = await client.get("/api/v1/billing/subscriptions/current");
|
|
213
259
|
const remaining = billing.quota.replies_remaining_today;
|
|
214
|
-
|
|
215
|
-
|
|
260
|
+
const platformCount = params.platforms ? params.platforms.length : 1;
|
|
261
|
+
if (remaining !== undefined && remaining !== null && remaining < platformCount) {
|
|
262
|
+
return err(`Insufficient quota: need ${platformCount} but only ${remaining} remaining today (${billing.quota.daily_limit}/day on ${billing.tier} plan). Resets at midnight.`);
|
|
263
|
+
}
|
|
264
|
+
if (params.platforms && params.platforms.length >= 2) {
|
|
265
|
+
const generated = await client.post("/api/v1/posts/generate", params);
|
|
266
|
+
const variants = generated.variants ?? [];
|
|
267
|
+
const postContents = variants.map((v) => ({ platform: v.platform, body: v.body, content_type: "text" }));
|
|
268
|
+
const saved = await client.post("/api/v1/posts", { post: {}, post_contents: postContents });
|
|
269
|
+
return ok({ variants, post: saved.post, quota_used: variants.length });
|
|
216
270
|
}
|
|
217
271
|
const generated = await client.post("/api/v1/posts/generate", params);
|
|
218
272
|
const platform = params.platform ?? "twitter";
|
|
219
273
|
const postContents = [{ platform, body: generated.body, content_type: "text" }];
|
|
220
|
-
const saved = await client.post("/api/v1/posts", {
|
|
221
|
-
post: {},
|
|
222
|
-
post_contents: postContents,
|
|
223
|
-
});
|
|
274
|
+
const saved = await client.post("/api/v1/posts", { post: {}, post_contents: postContents });
|
|
224
275
|
return ok({ body: generated.body, post: saved.post });
|
|
225
276
|
}
|
|
226
277
|
catch (e) {
|
|
@@ -230,13 +281,13 @@ export const postTools = [
|
|
|
230
281
|
},
|
|
231
282
|
{
|
|
232
283
|
name: "xreply_posts_generate_batch",
|
|
233
|
-
description: "Generate multiple AI posts at once in the user's voice. Use 'personalized' for posts tailored to your voice profile
|
|
284
|
+
description: "Generate multiple AI posts at once in the user's voice. Use 'personalized' for posts tailored to your voice profile or 'trending' for timely content. Returns an array of post body strings. Each post counts as 1 generation against the daily quota (5/day free, 100/day pro) — a single batch of 9 will exhaust a free account. Check xreply_billing_status first if quota is a concern.",
|
|
234
285
|
inputSchema: {
|
|
235
286
|
type: "object",
|
|
236
287
|
properties: {
|
|
237
288
|
category: {
|
|
238
289
|
type: "string",
|
|
239
|
-
enum: ["personalized", "trending"
|
|
290
|
+
enum: ["personalized", "trending"],
|
|
240
291
|
description: "Category of posts to generate",
|
|
241
292
|
},
|
|
242
293
|
count: {
|
|
@@ -245,12 +296,17 @@ export const postTools = [
|
|
|
245
296
|
maximum: 9,
|
|
246
297
|
description: "Number of posts to generate (1-9). Cannot exceed your remaining daily quota.",
|
|
247
298
|
},
|
|
299
|
+
platform: {
|
|
300
|
+
type: "string",
|
|
301
|
+
enum: ["twitter", "linkedin", "threads", "instagram", "youtube", "bluesky"],
|
|
302
|
+
description: "Target platform — controls output length and style (default: twitter).",
|
|
303
|
+
},
|
|
248
304
|
},
|
|
249
305
|
required: ["category", "count"],
|
|
250
306
|
},
|
|
251
307
|
async handler(args, client) {
|
|
252
308
|
try {
|
|
253
|
-
const { category, count } = GenerateBatchSchema.parse(args);
|
|
309
|
+
const { category, count, platform } = GenerateBatchSchema.parse(args);
|
|
254
310
|
const billing = await client.get("/api/v1/billing/subscriptions/current");
|
|
255
311
|
const remaining = billing.quota.replies_remaining_today;
|
|
256
312
|
if (remaining !== undefined && remaining !== null && remaining <= 0) {
|
|
@@ -267,7 +323,7 @@ export const postTools = [
|
|
|
267
323
|
const safeCount = remaining !== undefined && remaining !== null
|
|
268
324
|
? Math.min(count, remaining)
|
|
269
325
|
: count;
|
|
270
|
-
const result = await client.post("/api/v1/posts/generate_batch", { category, count: safeCount });
|
|
326
|
+
const result = await client.post("/api/v1/posts/generate_batch", { category, count: safeCount, ...(platform ? { platform } : {}) });
|
|
271
327
|
return ok(result);
|
|
272
328
|
}
|
|
273
329
|
catch (e) {
|
|
@@ -275,9 +331,87 @@ export const postTools = [
|
|
|
275
331
|
}
|
|
276
332
|
},
|
|
277
333
|
},
|
|
334
|
+
{
|
|
335
|
+
name: "xreply_carousel_generate",
|
|
336
|
+
description: "Generate an AI-written LinkedIn carousel post from a topic/prompt. The AI creates structured slides in the user's voice, renders them as a PDF, and auto-saves as a draft LinkedIn post. Returns the slides, post, and document_urn. Counts as 1 generation against the daily quota.",
|
|
337
|
+
inputSchema: {
|
|
338
|
+
type: "object",
|
|
339
|
+
properties: {
|
|
340
|
+
topic: {
|
|
341
|
+
type: "string",
|
|
342
|
+
maxLength: 280,
|
|
343
|
+
description: "Optional topic or prompt for the carousel. If omitted, AI picks from the user's voice profile.",
|
|
344
|
+
},
|
|
345
|
+
slide_count: {
|
|
346
|
+
type: "integer",
|
|
347
|
+
minimum: 3,
|
|
348
|
+
maximum: 12,
|
|
349
|
+
description: "Number of slides (3-12, default 3). Includes cover and CTA slides.",
|
|
350
|
+
},
|
|
351
|
+
theme: {
|
|
352
|
+
type: "string",
|
|
353
|
+
enum: ["dark", "light", "blue", "green"],
|
|
354
|
+
description: "Visual theme for the PDF (default: dark).",
|
|
355
|
+
},
|
|
356
|
+
cta_text: {
|
|
357
|
+
type: "string",
|
|
358
|
+
maxLength: 100,
|
|
359
|
+
description: "Custom text for the last slide (e.g. 'Follow me for more async tips'). If omitted, AI generates one.",
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
required: [],
|
|
363
|
+
},
|
|
364
|
+
async handler(args, client) {
|
|
365
|
+
try {
|
|
366
|
+
const billing = await client.get("/api/v1/billing/subscriptions/current");
|
|
367
|
+
const remaining = billing.quota.replies_remaining_today;
|
|
368
|
+
if (remaining !== undefined && remaining !== null && remaining < 1) {
|
|
369
|
+
return err(`Insufficient quota: 0 remaining today (${billing.quota.daily_limit}/day on ${billing.tier} plan). Resets at midnight.`);
|
|
370
|
+
}
|
|
371
|
+
const accounts = await client.get("/api/v1/social_accounts");
|
|
372
|
+
const linkedinAccount = accounts.social_accounts.find((a) => a.platform === "linkedin");
|
|
373
|
+
if (!linkedinAccount) {
|
|
374
|
+
return err("No LinkedIn account connected. Please connect LinkedIn in settings first.");
|
|
375
|
+
}
|
|
376
|
+
const parsed = CarouselGenerateSchema.parse(args);
|
|
377
|
+
const body = { social_account_id: linkedinAccount.id };
|
|
378
|
+
if (parsed.topic !== undefined)
|
|
379
|
+
body.topic = parsed.topic;
|
|
380
|
+
if (parsed.slide_count !== undefined)
|
|
381
|
+
body.slide_count = parsed.slide_count;
|
|
382
|
+
if (parsed.theme !== undefined)
|
|
383
|
+
body.theme = parsed.theme;
|
|
384
|
+
if (parsed.cta_text !== undefined)
|
|
385
|
+
body.cta_text = parsed.cta_text;
|
|
386
|
+
const generated = await client.post("/api/v1/posts/generate_carousel", body);
|
|
387
|
+
const saved = await client.post("/api/v1/posts", {
|
|
388
|
+
post: {},
|
|
389
|
+
post_contents: [{
|
|
390
|
+
platform: "linkedin",
|
|
391
|
+
body: generated.caption,
|
|
392
|
+
content_type: "document",
|
|
393
|
+
metadata: { document_urn: generated.document_urn, title: generated.title },
|
|
394
|
+
}],
|
|
395
|
+
});
|
|
396
|
+
return ok({
|
|
397
|
+
slides: generated.slides,
|
|
398
|
+
caption: generated.caption,
|
|
399
|
+
document_urn: generated.document_urn,
|
|
400
|
+
post: saved.post,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
return err(e);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
},
|
|
278
408
|
{
|
|
279
409
|
name: "xreply_posts_edit",
|
|
280
|
-
description: "Edit a post's content, scheduled time, or auto-retweet setting. Use `body` to update X-only text, or `post_contents` to update per-platform content (LinkedIn or cross-platform). Set scheduled_at to an ISO 8601 datetime to schedule, or null to move back to draft. Cannot edit posts that are processing or already posted.
|
|
410
|
+
description: "Edit a post's content, scheduled time, or auto-retweet setting. Use `body` to update X-only text, or `post_contents` to update per-platform content (LinkedIn, YouTube, Threads, or cross-platform). Set scheduled_at to an ISO 8601 datetime to schedule, or null to move back to draft. Cannot edit posts that are processing or already posted. " +
|
|
411
|
+
"For X images: call xreply_media_upload first, include media_id in metadata. " +
|
|
412
|
+
"For LinkedIn images: call xreply_media_upload first, include asset_urn in metadata. " +
|
|
413
|
+
"For LinkedIn video: call xreply_video_upload first, include asset_urn in metadata, set content_type to 'video'. " +
|
|
414
|
+
"For YouTube video: set platform to 'youtube', content_type to 'video', include title in metadata, privacy_status (default: private), and optionally tags/category_id/thumbnail_url.",
|
|
281
415
|
inputSchema: {
|
|
282
416
|
type: "object",
|
|
283
417
|
properties: {
|
|
@@ -290,23 +424,33 @@ export const postTools = [
|
|
|
290
424
|
items: {
|
|
291
425
|
type: "object",
|
|
292
426
|
properties: {
|
|
293
|
-
platform: { type: "string", enum: ["twitter", "linkedin"], description: "Target platform" },
|
|
294
|
-
body: { type: "string", minLength: 1, maxLength:
|
|
295
|
-
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video"], description: "Content type (default: text). Use video for LinkedIn video posts." },
|
|
427
|
+
platform: { type: "string", enum: ["twitter", "linkedin", "youtube", "threads", "instagram", "bluesky", "facebook"], description: "Target platform" },
|
|
428
|
+
body: { type: "string", minLength: 1, maxLength: 63206, description: "Post body (max 280 for X, max 3000 for LinkedIn, max 500 for Threads, max 300 for Bluesky, max 63206 for Facebook). For YouTube, used as video description (optional)." },
|
|
429
|
+
content_type: { type: "string", enum: ["text", "single_image", "multi_image", "video", "document", "poll"], description: "Content type (default: text). Use video for LinkedIn and YouTube video posts. Use document for LinkedIn carousel posts (PDF). Use poll for LinkedIn polls." },
|
|
296
430
|
metadata: {
|
|
297
431
|
type: "object",
|
|
298
|
-
description: "Metadata for the post content. For X images: media_id or media_ids
|
|
432
|
+
description: "Metadata for the post content. For X images: media_id or media_ids. For LinkedIn images: asset_urn or asset_urns. For LinkedIn/YouTube video: set content_type to video. For YouTube: title (required), privacy_status, category_id, tags, thumbnail_url. For Threads image: image_url (public HTTPS URL — no upload step needed). For X auto-retweet: auto_rt_hours. For X communities: community_id. For LinkedIn polls: question (max 140 chars), options (2-4 strings, max 30 chars each), duration (ONE_DAY, THREE_DAYS, SEVEN_DAYS, or FOURTEEN_DAYS).",
|
|
299
433
|
properties: {
|
|
300
434
|
media_id: { type: "string", description: "X only — media_id from xreply_media_upload (single image)" },
|
|
301
435
|
media_ids: { type: "array", items: { type: "string" }, description: "X only — multiple media_ids from xreply_media_upload" },
|
|
302
|
-
asset_urn: { type: "string", description: "LinkedIn only — asset_urn from xreply_media_upload
|
|
436
|
+
asset_urn: { type: "string", description: "LinkedIn only — asset_urn from xreply_media_upload or xreply_video_upload" },
|
|
303
437
|
asset_urns: { type: "array", items: { type: "string" }, description: "LinkedIn only — multiple asset_urns from xreply_media_upload" },
|
|
304
438
|
auto_rt_hours: { type: "integer", minimum: 1, description: "X only — hours after publishing to auto-retweet" },
|
|
305
439
|
community_id: { type: "string", pattern: "^\\d+$", description: "X only — numeric community ID to post into a community feed (use xreply_list_twitter_communities to get IDs)" },
|
|
440
|
+
title: { type: "string", description: "YouTube only — video title (required for YouTube video posts)" },
|
|
441
|
+
privacy_status: { type: "string", enum: ["public", "private", "unlisted"], description: "YouTube only — video privacy setting (default: private)" },
|
|
442
|
+
category_id: { type: "string", description: "YouTube only — numeric category ID (default: 22 = People & Blogs)" },
|
|
443
|
+
tags: { type: "array", items: { type: "string" }, description: "YouTube only — video tags" },
|
|
444
|
+
thumbnail_url: { type: "string", description: "YouTube only — URL of thumbnail image to set after upload completes" },
|
|
445
|
+
image_url: { type: "string", description: "Threads only — public HTTPS URL of image to attach (required for single_image content_type on Threads)" },
|
|
446
|
+
question: { type: "string", maxLength: 140, description: "LinkedIn poll only — the poll question (max 140 chars)" },
|
|
447
|
+
options: { type: "array", items: { type: "string", maxLength: 30 }, minItems: 2, maxItems: 4, description: "LinkedIn poll only — poll answer options (2-4 items, each max 30 chars)" },
|
|
448
|
+
duration: { type: "string", enum: ["ONE_DAY", "THREE_DAYS", "SEVEN_DAYS", "FOURTEEN_DAYS"], description: "LinkedIn poll only — how long the poll stays open" },
|
|
449
|
+
linkedin_org_id: { type: "string", description: "LinkedIn only — numeric org ID to post as an organization page instead of personal profile" },
|
|
306
450
|
},
|
|
307
451
|
},
|
|
308
452
|
},
|
|
309
|
-
required: ["platform"
|
|
453
|
+
required: ["platform"],
|
|
310
454
|
},
|
|
311
455
|
},
|
|
312
456
|
scheduled_at: {
|
|
@@ -440,6 +584,7 @@ export const postTools = [
|
|
|
440
584
|
"Reads the MP4 file at the given path, uploads it via the LinkedIn video upload API using your primary connected LinkedIn account, and returns the asset_urn to use in post_contents metadata. " +
|
|
441
585
|
"Pass the returned asset_urn in post_contents[].metadata.asset_urn and set content_type to 'video'. Body text is optional for LinkedIn video posts. " +
|
|
442
586
|
"Only LinkedIn is supported — X video upload requires OAuth 1.0a which is not currently supported. " +
|
|
587
|
+
"For YouTube video uploads, use xreply_youtube_upload instead. " +
|
|
443
588
|
"Supports MP4 only. Maximum file size: 100 MB. " +
|
|
444
589
|
"Note: requires filesystem access — works in Claude Code, Cursor, and mcporter (CLI). Not available in Claude.ai (no filesystem access); instead upload videos via the Posts dashboard at app.xreplyai.com/dashboard/posts.",
|
|
445
590
|
inputSchema: {
|
|
@@ -486,5 +631,111 @@ export const postTools = [
|
|
|
486
631
|
}
|
|
487
632
|
},
|
|
488
633
|
},
|
|
634
|
+
{
|
|
635
|
+
name: "xreply_youtube_upload",
|
|
636
|
+
description: "Upload a local video file for a YouTube post draft BEFORE publishing. " +
|
|
637
|
+
"Call this after xreply_posts_create (with platform 'youtube' and content_type 'video') but BEFORE xreply_posts_publish. " +
|
|
638
|
+
"The tool uploads the video to staging storage, then patches the post metadata with the storage key so the backend can finish the YouTube upload automatically after you publish. " +
|
|
639
|
+
"Once this succeeds, call xreply_posts_publish — the backend handles the rest asynchronously. " +
|
|
640
|
+
"Supports MP4, MOV, M4V, WebM, MPEG, 3GPP. Maximum file size: 50 MB. " +
|
|
641
|
+
"Note: requires filesystem access — works in Claude Code, Cursor, and mcporter (CLI). Not available in Claude.ai.",
|
|
642
|
+
inputSchema: {
|
|
643
|
+
type: "object",
|
|
644
|
+
properties: {
|
|
645
|
+
post_id: {
|
|
646
|
+
type: "integer",
|
|
647
|
+
description: "The id of the YouTube post draft (from xreply_posts_create)",
|
|
648
|
+
},
|
|
649
|
+
video_path: {
|
|
650
|
+
type: "string",
|
|
651
|
+
description: "Absolute or relative path to the video file on disk (max 50 MB)",
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
required: ["post_id", "video_path"],
|
|
655
|
+
},
|
|
656
|
+
async handler(args, client) {
|
|
657
|
+
try {
|
|
658
|
+
const { post_id, video_path } = z
|
|
659
|
+
.object({
|
|
660
|
+
post_id: z.number().int().positive(),
|
|
661
|
+
video_path: z.string().min(1),
|
|
662
|
+
})
|
|
663
|
+
.parse(args);
|
|
664
|
+
const resolvedPath = path.resolve(video_path);
|
|
665
|
+
const contentTypeMap = {
|
|
666
|
+
".mp4": "video/mp4",
|
|
667
|
+
".mov": "video/quicktime",
|
|
668
|
+
".m4v": "video/x-m4v",
|
|
669
|
+
".webm": "video/webm",
|
|
670
|
+
".mpeg": "video/mpeg",
|
|
671
|
+
".mpg": "video/mpeg",
|
|
672
|
+
".3gp": "video/3gpp",
|
|
673
|
+
};
|
|
674
|
+
const ext = path.extname(resolvedPath).toLowerCase();
|
|
675
|
+
const contentType = contentTypeMap[ext];
|
|
676
|
+
if (!contentType) {
|
|
677
|
+
return err(`Unsupported video format "${ext}". Supported: ${Object.keys(contentTypeMap).join(", ")}`);
|
|
678
|
+
}
|
|
679
|
+
let fileData;
|
|
680
|
+
try {
|
|
681
|
+
fileData = fs.readFileSync(resolvedPath);
|
|
682
|
+
}
|
|
683
|
+
catch (e) {
|
|
684
|
+
return err(`Cannot read file at ${resolvedPath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
685
|
+
}
|
|
686
|
+
const MAX_SIZE = 50 * 1024 * 1024;
|
|
687
|
+
if (fileData.byteLength > MAX_SIZE) {
|
|
688
|
+
return err(`Video file is ${(fileData.byteLength / 1024 / 1024).toFixed(1)} MB — maximum allowed is 50 MB.`);
|
|
689
|
+
}
|
|
690
|
+
// Get a presigned R2 URL from the backend
|
|
691
|
+
const presign = await client.post("/api/v1/youtube/uploads/presign", { content_type: contentType });
|
|
692
|
+
// PUT the video directly to R2
|
|
693
|
+
const uploadController = new AbortController();
|
|
694
|
+
const uploadTimer = setTimeout(() => uploadController.abort(), 300_000);
|
|
695
|
+
let uploadResponse;
|
|
696
|
+
try {
|
|
697
|
+
uploadResponse = await fetch(presign.upload_url, {
|
|
698
|
+
method: "PUT",
|
|
699
|
+
headers: { "Content-Type": contentType },
|
|
700
|
+
body: fileData,
|
|
701
|
+
signal: uploadController.signal,
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
catch (e) {
|
|
705
|
+
clearTimeout(uploadTimer);
|
|
706
|
+
if (e instanceof Error && e.name === "AbortError") {
|
|
707
|
+
return err("Video upload timed out after 5 minutes. Try a smaller file or check your connection.");
|
|
708
|
+
}
|
|
709
|
+
return err(`Video upload failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
710
|
+
}
|
|
711
|
+
clearTimeout(uploadTimer);
|
|
712
|
+
if (!uploadResponse.ok) {
|
|
713
|
+
const body = await uploadResponse.text().catch(() => "");
|
|
714
|
+
return err(`Video upload failed with HTTP ${uploadResponse.status}: ${body}`);
|
|
715
|
+
}
|
|
716
|
+
// Patch the post's YouTube content metadata with the r2_key so the backend
|
|
717
|
+
// can pick it up when the post is published
|
|
718
|
+
const post = await client.get(`/api/v1/posts/${post_id}`);
|
|
719
|
+
const ytContent = post.post?.post_contents?.find((pc) => pc.platform === "youtube");
|
|
720
|
+
const existingMeta = { ...ytContent?.metadata };
|
|
721
|
+
await client.patch(`/api/v1/posts/${post_id}`, {
|
|
722
|
+
post_contents: [
|
|
723
|
+
{
|
|
724
|
+
platform: "youtube",
|
|
725
|
+
metadata: { ...existingMeta, r2_key: presign.r2_key },
|
|
726
|
+
},
|
|
727
|
+
],
|
|
728
|
+
});
|
|
729
|
+
return ok({
|
|
730
|
+
success: true,
|
|
731
|
+
r2_key: presign.r2_key,
|
|
732
|
+
message: "Video uploaded to staging. Call xreply_posts_publish to complete the post.",
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
catch (e) {
|
|
736
|
+
return err(e);
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
},
|
|
489
740
|
];
|
|
490
741
|
//# sourceMappingURL=posts.js.map
|