feat: social video uploads + hero page video + TikTok content
Hero page:
- Replace GIF with squaremcp-hero-loop.mp4 (autoplay, muted, loop)
- Update styles, scripts, tests, Dockerfile, baselines
- Deployed and verified
Social video uploads:
- Twitter/X: uploadVideoAndTweet via v1.1 media/upload + v2 tweets
- Facebook: createVideoPost via Graph API /{pageId}/videos
- Instagram: createReel via Graph API (container → poll → publish)
- TikTok: REST endpoints + OpenAPI schema for video upload
Marketing:
- TikTok content prompts, scripts, and posting schedule
Note: Remotion not mentioned in any user-facing content
This commit is contained in:
@@ -158,3 +158,31 @@ export async function createPhotoPost(
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createVideoPost(
|
||||
args: { video_url: string; description?: string; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{ success: boolean; video_id: string; post_id: string }> {
|
||||
const audit = customer ? createToolAudit(customer.id, 'facebook:createVideoPost') : null;
|
||||
const auditArgs = { video_url: args.video_url };
|
||||
const { accessToken, pageId } = await resolveCreds(args, customer);
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
file_url: args.video_url,
|
||||
};
|
||||
if (args.description) body.description = args.description;
|
||||
|
||||
const data = await fbRequest(`/${pageId}/videos`, accessToken, 'POST', body);
|
||||
const result = {
|
||||
success: true,
|
||||
video_id: String(data.id ?? ''),
|
||||
post_id: String(data.post_id ?? data.id ?? ''),
|
||||
};
|
||||
if (audit) await audit.success(auditArgs);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (audit) await audit.error(auditArgs, String(err));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,11 +116,11 @@ export async function getMedia(
|
||||
}));
|
||||
}
|
||||
|
||||
export async function createPost(
|
||||
export async function createImagePost(
|
||||
args: { image_url: string; caption?: string; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{ success: boolean; media_id: string }> {
|
||||
const audit = customer ? createToolAudit(customer.id, 'instagram:createPost') : null;
|
||||
const audit = customer ? createToolAudit(customer.id, 'instagram:createImagePost') : null;
|
||||
const auditArgs = { image_url: args.image_url };
|
||||
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
|
||||
|
||||
@@ -129,7 +129,7 @@ export async function createPost(
|
||||
`/${businessAccountId}/media`,
|
||||
accessToken,
|
||||
'POST',
|
||||
{ image_url: args.image_url, caption: args.caption, media_type: 'REELS' }
|
||||
{ image_url: args.image_url, caption: args.caption }
|
||||
);
|
||||
|
||||
const creationId = container.id;
|
||||
@@ -150,3 +150,65 @@ export async function createPost(
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createReel(
|
||||
args: { video_url: string; caption?: string; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{ success: boolean; media_id: string }> {
|
||||
const audit = customer ? createToolAudit(customer.id, 'instagram:createReel') : null;
|
||||
const auditArgs = { video_url: args.video_url };
|
||||
const { accessToken, businessAccountId } = await resolveCreds(args, customer);
|
||||
|
||||
try {
|
||||
const container = await instagramRequest(
|
||||
`/${businessAccountId}/media`,
|
||||
accessToken,
|
||||
'POST',
|
||||
{ media_type: 'REELS', video_url: args.video_url, caption: args.caption, share_to_feed: true }
|
||||
);
|
||||
|
||||
const creationId = container.id;
|
||||
if (!creationId) throw new Error('Failed to create Instagram media container');
|
||||
|
||||
// Poll for container status
|
||||
let retries = 0;
|
||||
const maxRetries = 10;
|
||||
const delayMs = 5000;
|
||||
|
||||
while (retries < maxRetries) {
|
||||
const statusData = await instagramRequest(
|
||||
`/${creationId}?fields=status_code`,
|
||||
accessToken
|
||||
);
|
||||
|
||||
if (statusData.status_code === 'FINISHED') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (statusData.status_code === 'ERROR') {
|
||||
throw new Error('Instagram media container processing failed');
|
||||
}
|
||||
|
||||
retries++;
|
||||
if (retries >= maxRetries) {
|
||||
throw new Error('Instagram media container polling timed out');
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
|
||||
const publish = await instagramRequest(
|
||||
`/${businessAccountId}/media_publish`,
|
||||
accessToken,
|
||||
'POST',
|
||||
{ creation_id: creationId }
|
||||
);
|
||||
|
||||
const result = { success: true, media_id: publish.id };
|
||||
if (audit) await audit.success(auditArgs);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (audit) await audit.error(auditArgs, String(err));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Customer } from '../billing/middleware.js';
|
||||
import type { OAuthCredentials } from '../multitenancy/credential-store.js';
|
||||
|
||||
const TWITTER_API_BASE = 'https://api.twitter.com/2';
|
||||
const TWITTER_UPLOAD_BASE = 'https://upload.twitter.com/1.1';
|
||||
|
||||
function getEnvToken(account: string): string {
|
||||
const envKey = `TWITTER_${account.toUpperCase()}_BEARER_TOKEN`;
|
||||
@@ -118,3 +119,154 @@ export async function createTweet(
|
||||
'Upgrade to Basic ($100/month) or Pro ($5,000/month) at https://developer.twitter.com/en/portal/products'
|
||||
);
|
||||
}
|
||||
|
||||
export async function uploadVideoAndTweet(
|
||||
args: { videoUrl: string; text: string; account?: string },
|
||||
customer?: Customer
|
||||
): Promise<{ success: boolean; tweet_id: string; url: string }> {
|
||||
const bearerToken = await resolveToken(args, customer);
|
||||
|
||||
// 1. Download video
|
||||
const videoRes = await fetch(args.videoUrl, { signal: AbortSignal.timeout(60000) });
|
||||
if (!videoRes.ok) {
|
||||
throw new Error(`Failed to download video: ${videoRes.status} ${videoRes.statusText}`);
|
||||
}
|
||||
const videoBuffer = Buffer.from(await videoRes.arrayBuffer());
|
||||
const totalBytes = videoBuffer.length;
|
||||
|
||||
// 2. INIT
|
||||
const initUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
|
||||
initUrl.searchParams.set('command', 'INIT');
|
||||
initUrl.searchParams.set('total_bytes', String(totalBytes));
|
||||
initUrl.searchParams.set('media_type', 'video/mp4');
|
||||
|
||||
const initRes = await fetch(initUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${bearerToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!initRes.ok) {
|
||||
const err = await initRes.text();
|
||||
throw new Error(`Twitter media INIT error (${initRes.status}): ${err}`);
|
||||
}
|
||||
|
||||
const initData = (await initRes.json()) as { media_id_string?: string };
|
||||
const mediaId = initData.media_id_string;
|
||||
if (!mediaId) throw new Error('Twitter media INIT did not return media_id_string');
|
||||
|
||||
// 3. APPEND chunks
|
||||
const CHUNK_SIZE = 2 * 1024 * 1024;
|
||||
let segmentIndex = 0;
|
||||
for (let offset = 0; offset < totalBytes; offset += CHUNK_SIZE) {
|
||||
const chunk = videoBuffer.subarray(offset, offset + CHUNK_SIZE);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('media', new Blob([chunk]));
|
||||
|
||||
const appendUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
|
||||
appendUrl.searchParams.set('command', 'APPEND');
|
||||
appendUrl.searchParams.set('media_id', mediaId);
|
||||
appendUrl.searchParams.set('segment_index', String(segmentIndex));
|
||||
|
||||
const appendRes = await fetch(appendUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${bearerToken}` },
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(30000),
|
||||
});
|
||||
|
||||
if (!appendRes.ok) {
|
||||
const err = await appendRes.text();
|
||||
throw new Error(`Twitter media APPEND error (${appendRes.status}): ${err}`);
|
||||
}
|
||||
|
||||
segmentIndex++;
|
||||
}
|
||||
|
||||
// 4. FINALIZE
|
||||
const finalizeUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
|
||||
finalizeUrl.searchParams.set('command', 'FINALIZE');
|
||||
finalizeUrl.searchParams.set('media_id', mediaId);
|
||||
|
||||
const finalizeRes = await fetch(finalizeUrl.toString(), {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${bearerToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!finalizeRes.ok) {
|
||||
const err = await finalizeRes.text();
|
||||
throw new Error(`Twitter media FINALIZE error (${finalizeRes.status}): ${err}`);
|
||||
}
|
||||
|
||||
const finalizeData = (await finalizeRes.json()) as {
|
||||
processing_info?: { state?: string; check_after_secs?: number; error?: { message?: string } };
|
||||
};
|
||||
|
||||
// 5. STATUS polling
|
||||
let processingInfo = finalizeData.processing_info;
|
||||
while (processingInfo && processingInfo.state !== 'succeeded') {
|
||||
if (processingInfo.state === 'failed') {
|
||||
const errMsg = processingInfo.error?.message ?? 'Unknown processing error';
|
||||
throw new Error(`Twitter media processing failed: ${errMsg}`);
|
||||
}
|
||||
|
||||
const waitSeconds = processingInfo.check_after_secs ?? 5;
|
||||
await new Promise(resolve => setTimeout(resolve, waitSeconds * 1000));
|
||||
|
||||
const statusUrl = new URL(`${TWITTER_UPLOAD_BASE}/media/upload.json`);
|
||||
statusUrl.searchParams.set('command', 'STATUS');
|
||||
statusUrl.searchParams.set('media_id', mediaId);
|
||||
|
||||
const statusRes = await fetch(statusUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${bearerToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!statusRes.ok) {
|
||||
const err = await statusRes.text();
|
||||
throw new Error(`Twitter media STATUS error (${statusRes.status}): ${err}`);
|
||||
}
|
||||
|
||||
const statusData = (await statusRes.json()) as {
|
||||
processing_info?: { state?: string; check_after_secs?: number; error?: { message?: string } };
|
||||
};
|
||||
processingInfo = statusData.processing_info;
|
||||
|
||||
if (processingInfo?.state === 'failed') {
|
||||
const errMsg = processingInfo.error?.message ?? 'Unknown processing error';
|
||||
throw new Error(`Twitter media processing failed: ${errMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Post tweet with video
|
||||
const tweetRes = await fetch(`${TWITTER_API_BASE}/tweets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${bearerToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text: args.text,
|
||||
media: { media_keys: [mediaId] },
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!tweetRes.ok) {
|
||||
const err = await tweetRes.text();
|
||||
throw new Error(`Twitter tweet error (${tweetRes.status}): ${err}`);
|
||||
}
|
||||
|
||||
const tweetData = (await tweetRes.json()) as { data?: { id?: string } };
|
||||
const tweetId = tweetData.data?.id;
|
||||
if (!tweetId) throw new Error('Twitter tweet creation did not return an ID');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
tweet_id: tweetId,
|
||||
url: `https://twitter.com/i/web/status/${tweetId}`,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user