A simple tool which lets you scrape twitter accounts and crosspost them to bluesky accounts! Comes with a CLI and a webapp for managing profiles! Works with images/videos/link embeds/threads.

feat: implement tweet threading and revert video to links

jack 9d177ab2 336c1506

+95 -53
+95 -53
src/index.ts
··· 265 265 return Math.floor(Math.random() * (max - min + 1) + min); 266 266 } 267 267 268 + function splitText(text: string, limit = 300): string[] { 269 + if (text.length <= limit) return [text]; 270 + 271 + const chunks: string[] = []; 272 + let remaining = text; 273 + 274 + while (remaining.length > 0) { 275 + if (remaining.length <= limit) { 276 + chunks.push(remaining); 277 + break; 278 + } 279 + 280 + // Try to split by paragraph 281 + let splitIndex = remaining.lastIndexOf('\n\n', limit); 282 + if (splitIndex === -1) { 283 + // Try to split by sentence 284 + splitIndex = remaining.lastIndexOf('. ', limit); 285 + if (splitIndex === -1) { 286 + // Try to split by space 287 + splitIndex = remaining.lastIndexOf(' ', limit); 288 + if (splitIndex === -1) { 289 + // Force split 290 + splitIndex = limit; 291 + } 292 + } else { 293 + splitIndex += 1; // Include the period 294 + } 295 + } 296 + 297 + chunks.push(remaining.substring(0, splitIndex).trim()); 298 + remaining = remaining.substring(splitIndex).trim(); 299 + } 300 + 301 + return chunks; 302 + } 303 + 268 304 function refreshQueryIds(): Promise<void> { 269 305 return new Promise((resolve) => { 270 306 console.log("⚠️ Attempting to refresh Twitter Query IDs via 'bird' CLI..."); ··· 403 439 .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0)); 404 440 405 441 if (mp4s.length > 0 && mp4s[0]) { 406 - const videoUrl = mp4s[0].url; 407 - try { 408 - const { buffer, mimeType } = await downloadMedia(videoUrl); 409 - if (buffer.length > 95 * 1024 * 1024) { 410 - text += `\n[Video: ${media.media_url_https}]`; 411 - continue; 412 - } 413 - const blob = await uploadToBluesky(agent, buffer, mimeType); 414 - videoBlob = blob; 415 - videoAspectRatio = aspectRatio; 416 - break; 417 - } catch (err) { 418 - console.error(`Failed to upload video ${videoUrl}:`, (err as Error).message); 419 - text += `\n${media.media_url_https}`; 420 - } 442 + // Reverting to links for video for now as native upload is failing 443 + text += `\n${media.media_url_https}`; 421 444 } 422 445 } 423 446 } ··· 434 457 } 435 458 } 436 459 437 - const rt = new RichText({ text }); 438 - await rt.detectFacets(agent); 439 - const detectedLangs = detectLanguage(text); 460 + const chunks = splitText(text); 461 + let lastPostInfo: ProcessedTweetEntry | null = replyParentInfo; 440 462 441 - // biome-ignore lint/suspicious/noExplicitAny: dynamic record construction 442 - const postRecord: Record<string, any> = { 443 - text: rt.text, 444 - facets: rt.facets, 445 - langs: detectedLangs, 446 - createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(), 447 - }; 463 + for (let i = 0; i < chunks.length; i++) { 464 + const chunk = chunks[i] as string; 465 + const rt = new RichText({ text: chunk }); 466 + await rt.detectFacets(agent); 467 + const detectedLangs = detectLanguage(chunk); 448 468 449 - if (videoBlob) { 450 - postRecord.embed = { $type: 'app.bsky.embed.video', video: videoBlob, aspectRatio: videoAspectRatio }; 451 - } else if (images.length > 0) { 452 - const imagesEmbed = { $type: 'app.bsky.embed.images', images }; 453 - if (quoteEmbed) { 454 - postRecord.embed = { $type: 'app.bsky.embed.recordWithMedia', media: imagesEmbed, record: quoteEmbed }; 455 - } else { 456 - postRecord.embed = imagesEmbed; 469 + // biome-ignore lint/suspicious/noExplicitAny: dynamic record construction 470 + const postRecord: Record<string, any> = { 471 + text: rt.text, 472 + facets: rt.facets, 473 + langs: detectedLangs, 474 + createdAt: tweet.created_at ? new Date(tweet.created_at).toISOString() : new Date().toISOString(), 475 + }; 476 + 477 + // Only attach media/quotes to the first chunk 478 + if (i === 0) { 479 + if (images.length > 0) { 480 + const imagesEmbed = { $type: 'app.bsky.embed.images', images }; 481 + if (quoteEmbed) { 482 + postRecord.embed = { $type: 'app.bsky.embed.recordWithMedia', media: imagesEmbed, record: quoteEmbed }; 483 + } else { 484 + postRecord.embed = imagesEmbed; 485 + } 486 + } else if (quoteEmbed) { 487 + postRecord.embed = quoteEmbed; 488 + } 457 489 } 458 - } else if (quoteEmbed) { 459 - postRecord.embed = quoteEmbed; 460 - } 461 490 462 - if (replyParentInfo?.uri && replyParentInfo?.cid) { 463 - postRecord.reply = { 464 - root: replyParentInfo.root || { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 465 - parent: { uri: replyParentInfo.uri, cid: replyParentInfo.cid }, 466 - }; 467 - } 491 + if (lastPostInfo?.uri && lastPostInfo?.cid) { 492 + postRecord.reply = { 493 + root: lastPostInfo.root || { uri: lastPostInfo.uri, cid: lastPostInfo.cid }, 494 + parent: { uri: lastPostInfo.uri, cid: lastPostInfo.cid }, 495 + }; 496 + } 468 497 469 - try { 470 - const response = await agent.post(postRecord); 471 - processedTweets[tweetId] = { 472 - uri: response.uri, 473 - cid: response.cid, 474 - root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, 475 - }; 476 - saveProcessedTweets(twitterUsername, processedTweets); 477 - await new Promise((r) => setTimeout(r, getRandomDelay(1000, 4000))); 478 - } catch (err) { 479 - console.error(`Failed to post ${tweetId}:`, err); 498 + try { 499 + const response = await agent.post(postRecord); 500 + const currentPostInfo = { 501 + uri: response.uri, 502 + cid: response.cid, 503 + root: postRecord.reply ? postRecord.reply.root : { uri: response.uri, cid: response.cid }, 504 + }; 505 + 506 + if (i === 0) { 507 + processedTweets[tweetId] = currentPostInfo; 508 + saveProcessedTweets(twitterUsername, processedTweets); 509 + } 510 + 511 + lastPostInfo = currentPostInfo; 512 + 513 + if (chunks.length > 1) { 514 + await new Promise((r) => setTimeout(r, 1000)); // Short delay between thread parts 515 + } 516 + } catch (err) { 517 + console.error(`Failed to post ${tweetId} (chunk ${i + 1}):`, err); 518 + break; // Stop threading if a chunk fails 519 + } 480 520 } 521 + 522 + await new Promise((r) => setTimeout(r, getRandomDelay(1000, 4000))); 481 523 } 482 524 } 483 525