• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
hno

hno

farcaster-autoPostToX

Remix of artivilla/farcaster-autoPostToX
Public
Like
farcaster-autoPostToX
Home
Code
7
.claude
1
.vtignore
AGENTS.md
IMPLEMENTATION_PLAN.md
README.md
deno.json
H
index.ts
Environment variables
4
Branches
1
Pull requests
Remixes
History
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Sign up now
Code
/
IMPLEMENTATION_PLAN.md
Code
/
IMPLEMENTATION_PLAN.md
Search
1/3/2026
Viewing readonly version of main branch: v7
View latest version
IMPLEMENTATION_PLAN.md

PRD: Video Upload & Hashtag System for Farcaster-to-X Cross-Poster

Overview

Problem Statement

The current cross-posting script has two significant limitations:

  1. 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.
  2. Discoverability gap: No hashtag support means cross-posted content has reduced reach on Twitter/X.

Objective

Enable native media uploads for all images and videos, and add an intelligent hashtag mapping system to improve content visibility on Twitter/X.

Success Metrics

  • 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

Scope

In Scope

FeatureDescription
Expanded image uploadUpload ANY image URL with image/* content-type (not just imagedelivery.net)
Native video uploadDownload and upload videos to Twitter with chunked upload API
Video size guardSkip upload for videos >50MB, use URL fallback
Hashtag mappingRule-based system to add/replace hashtags in tweet text

Out of Scope

  • 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

Dependencies

  • Twitter API v1.1 media upload endpoints (chunked upload for video)
  • twitter-api-v2 npm package (already in use)
  • Farcaster embed metadata (content_type field)

Technical Requirements

Platform Constraints (Val Town)

ConstraintLimitImpact
Wall clock timeout1 min (free) / 10 min (pro)Large video downloads may timeout
MemoryNot documented (estimate ~128-512MB)Buffer entire video in memory
Chunk sizeTwitter requires <=5MB chunksMust 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.

Twitter Media Upload Limits

Media TypeMax SizeUpload Method
Images (JPEG/PNG/WebP)5MBSimple upload
GIF15MBChunked upload
Video512MB (Twitter max)Chunked upload

Our limit: 50MB for videos (user requirement) to stay within Val Town timeout constraints.

Data Models

// 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; }

Hashtag Rules Configuration

// 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

Implementation Approach

Phase 1: Refactor Image Upload (Low Risk)

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:

  1. Update the embed filtering condition (line 81)
  2. Update the image processing filter (line 131)
  3. 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)

Phase 2: Add Video Upload (Medium Risk)

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

Phase 3: Hashtag Mapping System (Low Risk)

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)

Updated Processing Flow

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

Validation Criteria

Acceptance Criteria (User-Facing)

ScenarioExpected Behavior
Cast with Cloudinary imageImage 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 wordsOnly 3 hashtags added
Long cast near 280 charsHashtags added until limit reached

Technical Validation Checklist

  • deno check index.ts passes 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 Cases to Handle

Edge CaseExpected Behavior
Video embed without content_type metadataTreat as regular URL (append to text)
Image embed without content_type metadataTreat as regular URL (append to text)
HEAD request returns no Content-LengthAttempt download anyway
Text already contains "#Farcaster"Word "Farcaster" won't match (word boundary excludes #)
Empty cast text with only embedsProcess normally (text = "" + URLs)
Mixed video + images in castTry video first; if fails, upload images instead

Logging Policy

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

Error Handling Matrix

ScenarioBehaviorLog
Image download failsSkip entire tweetYes (error)
Image upload failsSkip entire tweetYes (error)
Video too large (HEAD check)Try images insteadNo
Video download failsTry images insteadYes (error)
Video upload failsTry images insteadYes (error)
Tweet >280 chars after processingSkip tweetYes (error)
Missing Twitter credentialsSkip tweetYes (error)
Non-cast.created webhookReturn OK (no-op)No
Tweet posted successfullyContinueYes (info)

Risks and Mitigations

RiskLikelihoodImpactMitigation
Val Town timeout on large videoMediumTweet fails50MB limit + HEAD check fails fast
Twitter API rate limitsLowTemporary failuresExisting behavior (fail and skip)
Memory exhaustion on video bufferLowVal crash50MB limit keeps buffer manageable
Chunked upload API changesLowVideos failtwitter-api-v2 abstracts this
Hashtag rules too aggressiveLowAwkward tweetsConservative default rules, manual review

File Changes Summary

All changes in /Users/hellno/dev/misc/farcaster-autoPostToX/index.ts:

LocationChange TypeDescription
Top of fileADDHashtagRule interface
Top of fileADDVideoUploadResult type
Top of fileADDProcessedText interface
Top of fileADDHASHTAG_RULES constant array
Top of fileADDMAX_VIDEO_SIZE constant (50MB)
New functionADDescapeRegex()
New functionADDprocessHashtags()
New functionADDgetContentLength()
New functionADDuploadVideo()
Line ~81MODIFYImage detection: URL prefix -> content_type
Line ~86MODIFYVideo handling: URL append -> native upload
Line ~131MODIFYImage filter: URL prefix -> content_type
Before tweetADDCall processHashtags()
New sectionADDVideo upload logic with fallback

Example Transformations

Image Source Expansion

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

Video Upload with Fallback

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)

Hashtag Processing

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)

Timeline Estimate

PhaseEffortDescription
Phase 1: Image refactor15 minChange detection from URL prefix to content-type
Phase 2: Video upload45 minAdd upload function, size checking, fallback logic
Phase 3: Hashtag system30 minAdd processing function and integration
Testing30 minManual testing with real casts
Total~2 hours

Recommended order: Phase 1 -> Phase 3 -> Phase 2 (lowest to highest risk)

FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
© 2026 Val Town, Inc.