The current cross-posting script has two significant limitations:
- Media visibility gap: Videos and non-imagedelivery.net images are appended as URLs, but Twitter does NOT unfurl direct media URLs (only webpages with OG/Twitter Card meta tags). This means videos and Cloudinary images never display inline.
- Discoverability gap: No hashtag support means cross-posted content has reduced reach on Twitter/X.
Enable native media uploads for all images and videos, and add an intelligent hashtag mapping system to improve content visibility on Twitter/X.
- All images (regardless of source) display inline in tweets
- Videos under 50MB display inline in tweets
- Videos over 50MB gracefully fallback to URL (with user-visible link)
- Relevant hashtags are automatically added based on content (max 3)
- Zero increase in failed cross-posts due to new features
| Feature | Description |
|---|---|
| Expanded image upload | Upload ANY image URL with image/* content-type (not just imagedelivery.net) |
| Native video upload | Download and upload videos to Twitter with chunked upload API |
| Video size guard | Skip upload for videos >50MB, use URL fallback |
| Hashtag mapping | Rule-based system to add/replace hashtags in tweet text |
- Changing existing reply filtering logic
- Multi-tweet threads for long content
- Retry mechanisms for transient failures
- External configuration (rules stay hardcoded)
- Quote tweets or retweet handling
- Twitter API v1.1 media upload endpoints (chunked upload for video)
twitter-api-v2npm package (already in use)- Farcaster embed metadata (content_type field)
| Constraint | Limit | Impact |
|---|---|---|
| Wall clock timeout | 1 min (free) / 10 min (pro) | Large video downloads may timeout |
| Memory | Not documented (estimate ~128-512MB) | Buffer entire video in memory |
| Chunk size | Twitter requires <=5MB chunks | Must implement chunked upload |
Risk mitigation: The 50MB video limit combined with HEAD request size checking ensures we fail fast before attempting downloads that would timeout.
| Media Type | Max Size | Upload Method |
|---|---|---|
| Images (JPEG/PNG/WebP) | 5MB | Simple upload |
| GIF | 15MB | Chunked upload |
| Video | 512MB (Twitter max) | Chunked upload |
Our limit: 50MB for videos (user requirement) to stay within Val Town timeout constraints.
// Existing - no changes needed
interface CastCreatedWebhook { /* ... */ }
// NEW: Hashtag rule configuration
interface HashtagRule {
word: string; // Word to match (case-insensitive)
hashtag: string; // Hashtag to use (without #)
mode: 'replace' | 'append';
}
// NEW: Video upload result
type VideoUploadResult =
| { success: true; mediaId: string }
| { success: false; fallbackUrl: string };
// NEW: Hashtag processing result
interface ProcessedText {
text: string;
hashtagsAdded: number;
}
// User-defined rules - add your own mappings here
const HASHTAG_RULES: HashtagRule[] = [
// Replace mode examples (uncomment/modify as needed):
// { word: 'farcaster', hashtag: 'farcaster', mode: 'replace' },
// Append mode examples (uncomment/modify as needed):
// { word: 'eth', hashtag: 'Ethereum', mode: 'append' },
];
Hashtag matching rules:
- Case-insensitive: "Farcaster", "farcaster", "FARCASTER" all match
- Word boundaries: "base" matches "base" but NOT "database"
- Max 3 hashtags per tweet
- Best effort: add hashtags until hitting 280 char limit
- No duplicates: same hashtag won't be added twice (e.g., "eth" and "ethereum" both map to #Ethereum)
- Order matters: rules are processed in array order, first 3 matches win
Current code (lines 80-81, 128-131):
if (embed.url.startsWith("https://imagedelivery.net")) {
New approach:
if (embed.metadata?.content_type?.startsWith("image/")) {
Changes required:
- Update the embed filtering condition (line 81)
- Update the image processing filter (line 131)
- No other changes needed - content_type parsing already exists
Test scenarios:
- imagedelivery.net image -> should upload (regression test)
- res.cloudinary.com image -> should upload (new)
- Unknown domain with image/* content-type -> should upload (new)
- URL without metadata -> should be appended to text (existing behavior)
New functions to add:
const MAX_VIDEO_SIZE = 50 * 1024 * 1024; // 50 MB
/**
* Check content length via HEAD request without downloading.
* Returns null if Content-Length header is missing.
*/
async function getContentLength(url: string): Promise<number | null> {
try {
const response = await fetch(url, { method: 'HEAD' });
const contentLength = response.headers.get('content-length');
return contentLength ? parseInt(contentLength, 10) : null;
} catch {
return null; // Assume we should try downloading
}
}
/**
* Download video and upload to Twitter using chunked upload.
* Returns mediaId on success, fallbackUrl on any failure.
*/
async function uploadVideo(
url: string,
twitterClient: TwitterApi
): Promise<VideoUploadResult> {
try {
// 1. Check size via HEAD request (fail fast)
const size = await getContentLength(url);
if (size !== null && size > MAX_VIDEO_SIZE) {
console.log(`Video too large: ${(size / 1024 / 1024).toFixed(1)}MB > 50MB limit`);
return { success: false, fallbackUrl: url };
}
// 2. Download video
console.log(`Downloading video from: ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Download failed: ${response.status}`);
}
const buffer = await response.arrayBuffer();
// 3. Double-check actual size (in case HEAD was wrong/missing)
if (buffer.byteLength > MAX_VIDEO_SIZE) {
console.log(`Video too large after download: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB`);
return { success: false, fallbackUrl: url };
}
// 4. Upload to Twitter (twitter-api-v2 handles chunking internally)
console.log(`Uploading video to Twitter: ${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB`);
const mediaId = await twitterClient.v1.uploadMedia(Buffer.from(buffer), {
mimeType: 'video/mp4', // Twitter requires explicit MIME type for video
target: 'tweet', // Required for video uploads
});
console.log(`Video uploaded successfully: ${mediaId}`);
return { success: true, mediaId };
} catch (error) {
console.error('Video upload failed:', error);
return { success: false, fallbackUrl: url };
}
}
Integration point (process video BEFORE images):
// Separate embeds by type
const videoEmbeds = castData.embeds?.filter(
embed => embed.metadata?.content_type?.startsWith("video/")
) ?? [];
const imageEmbeds = castData.embeds?.filter(
embed => embed.metadata?.content_type?.startsWith("image/")
) ?? [];
let videoUploaded = false;
let videoFallbackUrl: string | null = null;
// Try video first (max 1)
if (videoEmbeds.length > 0) {
const result = await uploadVideo(videoEmbeds[0].url, twitterClient);
if (result.success) {
mediaIds.push(result.mediaId);
videoUploaded = true;
} else {
videoFallbackUrl = result.fallbackUrl;
}
}
// If video failed or wasn't present, process images
if (!videoUploaded && imageEmbeds.length > 0) {
// ... existing image upload logic ...
}
// Only append video URL if video failed AND no images were uploaded
if (videoFallbackUrl && mediaIds.length === 0) {
textUrls.push(videoFallbackUrl);
}
Important Twitter constraint: A tweet can have EITHER images OR video, not both.
Mixed media strategy: Try video first → if upload fails, fallback to images (not URL).
Test scenarios:
- Video <50MB with valid content-type -> native upload
- Video >50MB (detected via HEAD) -> URL fallback, no download attempt
- Video >50MB (HEAD missing, detected after download) -> URL fallback
- Video download fails (network error) -> URL fallback
- Video upload fails (Twitter API error) -> URL fallback
- Multiple videos in cast -> only first one processed
New functions to add:
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function processHashtags(
text: string,
rules: HashtagRule[],
maxHashtags: number = 3,
maxLength: number = 280
): ProcessedText {
let result = text;
let hashtagsAdded = 0;
const appendQueue: string[] = [];
const usedHashtags = new Set<string>(); // Track by lowercase to avoid duplicates
for (const rule of rules) {
if (hashtagsAdded >= maxHashtags) break;
// Skip if hashtag already used (handles eth/ethereum both -> #Ethereum)
if (usedHashtags.has(rule.hashtag.toLowerCase())) continue;
// Case-insensitive word boundary match
const regex = new RegExp(`\\b${escapeRegex(rule.word)}\\b`, 'i');
if (!regex.test(result)) continue;
if (rule.mode === 'replace') {
const replacement = `#${rule.hashtag}`;
const newText = result.replace(regex, replacement);
// Only apply if within length limit
if (newText.length <= maxLength) {
result = newText;
hashtagsAdded++;
usedHashtags.add(rule.hashtag.toLowerCase());
}
} else {
// Append mode: queue for end, count now
appendQueue.push(rule.hashtag);
usedHashtags.add(rule.hashtag.toLowerCase());
hashtagsAdded++;
}
}
// Add queued hashtags at end (best effort)
for (const tag of appendQueue) {
const addition = ` #${tag}`;
if (result.length + addition.length <= maxLength) {
result += addition;
} else {
hashtagsAdded--; // Couldn't fit, don't count
}
}
return { text: result, hashtagsAdded };
}
Integration point (after building text, before length validation):
// Apply hashtag rules
const { text: processedText, hashtagsAdded } = processHashtags(castText, HASHTAG_RULES);
if (hashtagsAdded > 0) {
console.log(`Added ${hashtagsAdded} hashtag(s)`);
}
castText = processedText;
// Then do length check
if (castText.length > 280) {
console.error("Cast text too long for X:", castText.length, "characters");
return new Response("OK", { status: 200 });
}
Test scenarios:
- "Just posted on Farcaster" -> "Just posted on #Farcaster" (replace)
- "Building on Base" -> "Building on Base #Base" (append)
- "ETH and Ethereum" -> only one #Ethereum added (dedup)
- "Farcaster Base Ethereum Solana" -> only 3 hashtags (max limit)
- 270 char text with matches -> adds hashtags until 280 (best effort)
- Text already has #Farcaster -> word "Farcaster" no longer matches (word boundary)
Webhook POST received
|
v
Skip if not cast.created OR if reply (parent_hash !== null)
|
v
Classify embeds:
+-- Images (content_type starts with "image/")
+-- Videos (content_type starts with "video/") [NEW]
+-- Other URLs (everything else)
|
v
Process videos first (max 1): [NEW]
+-- Check size via HEAD
+-- If >50MB: skip video, try images instead
+-- If <=50MB: download + upload
+-- On upload failure: skip video, try images instead
|
v
If no video uploaded successfully, process images (max 2): [CHANGED]
+-- Download from any URL with image/* content-type
+-- Upload to Twitter
+-- On failure: skip entire tweet (existing behavior)
|
v
Build tweet text:
1. Cast text
2. + video URL (only if video failed AND no images uploaded) [NEW]
3. + other embed URLs
|
v
Apply hashtag rules: [NEW]
+-- Replace matches in-place
+-- Append remaining hashtags
+-- Respect 280 char limit
|
v
Final length check (>280 chars = skip)
|
v
Post tweet with media IDs
| Scenario | Expected Behavior |
|---|---|
| Cast with Cloudinary image | Image displays inline in tweet |
| Cast with small video (<50MB) | Video plays inline in tweet |
| Cast with large video (>50MB) | Tweet posted with video URL in text |
| Cast mentioning "Farcaster" | Tweet contains "#Farcaster" |
| Cast mentioning "eth" | Tweet ends with "#Ethereum" |
| Cast with 4+ matching words | Only 3 hashtags added |
| Long cast near 280 chars | Hashtags added until limit reached |
-
deno check index.tspasses with no errors - Image upload works for imagedelivery.net (regression)
- Image upload works for res.cloudinary.com
- Video size check via HEAD prevents large downloads
- Video upload produces playable video in tweet
- Video fallback appends URL when upload fails
- Hashtag replace mode modifies text correctly
- Hashtag append mode adds to end of text
- Hashtag deduplication works (eth + ethereum = 1 hashtag)
- Max 3 hashtags enforced
- 280 char limit respected after hashtag processing
| Edge Case | Expected Behavior |
|---|---|
| Video embed without content_type metadata | Treat as regular URL (append to text) |
| Image embed without content_type metadata | Treat as regular URL (append to text) |
| HEAD request returns no Content-Length | Attempt download anyway |
| Text already contains "#Farcaster" | Word "Farcaster" won't match (word boundary excludes #) |
| Empty cast text with only embeds | Process normally (text = "" + URLs) |
| Mixed video + images in cast | Try video first; if fails, upload images instead |
Minimal logging - only errors and key actions:
- Log when tweet is successfully posted
- Log errors that cause tweet to be skipped
- Do NOT log: size checks, hashtag matches, processing steps
| Scenario | Behavior | Log |
|---|---|---|
| Image download fails | Skip entire tweet | Yes (error) |
| Image upload fails | Skip entire tweet | Yes (error) |
| Video too large (HEAD check) | Try images instead | No |
| Video download fails | Try images instead | Yes (error) |
| Video upload fails | Try images instead | Yes (error) |
| Tweet >280 chars after processing | Skip tweet | Yes (error) |
| Missing Twitter credentials | Skip tweet | Yes (error) |
| Non-cast.created webhook | Return OK (no-op) | No |
| Tweet posted successfully | Continue | Yes (info) |
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Val Town timeout on large video | Medium | Tweet fails | 50MB limit + HEAD check fails fast |
| Twitter API rate limits | Low | Temporary failures | Existing behavior (fail and skip) |
| Memory exhaustion on video buffer | Low | Val crash | 50MB limit keeps buffer manageable |
| Chunked upload API changes | Low | Videos fail | twitter-api-v2 abstracts this |
| Hashtag rules too aggressive | Low | Awkward tweets | Conservative default rules, manual review |
All changes in /Users/hellno/dev/misc/farcaster-autoPostToX/index.ts:
| Location | Change Type | Description |
|---|---|---|
| Top of file | ADD | HashtagRule interface |
| Top of file | ADD | VideoUploadResult type |
| Top of file | ADD | ProcessedText interface |
| Top of file | ADD | HASHTAG_RULES constant array |
| Top of file | ADD | MAX_VIDEO_SIZE constant (50MB) |
| New function | ADD | escapeRegex() |
| New function | ADD | processHashtags() |
| New function | ADD | getContentLength() |
| New function | ADD | uploadVideo() |
| Line ~81 | MODIFY | Image detection: URL prefix -> content_type |
| Line ~86 | MODIFY | Video handling: URL append -> native upload |
| Line ~131 | MODIFY | Image filter: URL prefix -> content_type |
| Before tweet | ADD | Call processHashtags() |
| New section | ADD | Video upload logic with fallback |
Before: Only imagedelivery.net images uploaded
After: Any URL with image/* content-type uploaded
Cast embed: https://res.cloudinary.com/abc/image/upload/v123/photo.jpg
Metadata: { content_type: "image/jpeg" }
Result: Image downloaded and uploaded natively to Twitter
Scenario A: Small video (30MB), no images
Action: HEAD check passes, download, upload to Twitter
Result: Video plays inline in tweet
Scenario B: Large video (80MB), has images
Action: HEAD check fails (>50MB), skip video, process images instead
Result: Images display inline in tweet
Scenario C: Video upload fails, has images
Action: Download succeeds, Twitter API rejects, fallback to images
Result: Images display inline in tweet
Scenario D: Video upload fails, no images
Action: Download succeeds, Twitter API rejects, no image fallback
Result: Tweet posted with text only (video URL appended)
Input: "Just shipped a new feature on Farcaster for ETH payments"
Rules: farcaster->replace, eth->append(Ethereum)
Output: "Just shipped a new feature on #Farcaster for ETH payments #Ethereum"
(2 hashtags added)
Input: "Check out this Base project using Ethereum and ETH"
Rules: base->append, ethereum->append, eth->append(Ethereum)
Output: "Check out this Base project using Ethereum and ETH #Base #Ethereum"
(2 hashtags - eth/ethereum deduplicated to single #Ethereum)
Input: [270 char text mentioning Farcaster, Base, Ethereum, Solana]
Rules: All 4 match
Output: Adds only hashtags that fit within 280 chars (best effort)
| Phase | Effort | Description |
|---|---|---|
| Phase 1: Image refactor | 15 min | Change detection from URL prefix to content-type |
| Phase 2: Video upload | 45 min | Add upload function, size checking, fallback logic |
| Phase 3: Hashtag system | 30 min | Add processing function and integration |
| Testing | 30 min | Manual testing with real casts |
| Total | ~2 hours |
Recommended order: Phase 1 -> Phase 3 -> Phase 2 (lowest to highest risk)