Last active
June 5, 2026 06:27
-
-
Save 8ullyMaguire/8cd7938cf28ba0d91f88294266781a58 to your computer and use it in GitHub Desktop.
XtoB – Auto Crosspost Twitter/X → Bluesky
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name XtoB – Auto Crosspost Twitter/X → Bluesky | |
| // @version 1.6.0 | |
| // @description Auto-crossposts to Bluesky when you POST/REPOST on Twitter/X. Uses author handles (e.g., @user) in reposts. Button mode: butterfly for reposts only. | |
| // @author Mistral AI | |
| // @match https://x.com/* | |
| // @match https://twitter.com/* | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_notification | |
| // @connect bsky.social | |
| // @connect *.bsky.social | |
| // @connect video.bsky.app | |
| // ==/UserScript== | |
| (function() { | |
| 'use strict'; | |
| // ========== CONFIGURATION ========== | |
| const DEFAULT_CONFIG = { | |
| mode: 'auto', // 'auto' = post+repost, 'button' = butterfly for reposts only | |
| blueskyHandle: '', | |
| blueskyAppPassword: '', | |
| blueskyEndpoint: 'https://bsky.social/xrpc', | |
| maxPostLength: 300, | |
| maxImages: 4, | |
| includeAuthor: true, | |
| includeOriginalLink: true | |
| }; | |
| let config = { ...DEFAULT_CONFIG, ...GM_getValue('xToBConfig', {}) }; | |
| let repostedTweets = JSON.parse(GM_getValue('repostedTweets', '{}')); | |
| let postedNewTweets = JSON.parse(GM_getValue('postedNewTweets', '{}')); | |
| // ========== HELPERS ========== | |
| function hashCode(str) { | |
| let hash = 0; | |
| for (let i = 0; i < str.length; i++) { | |
| hash = ((hash << 5) - hash) + str.charCodeAt(i); | |
| hash = hash & hash; | |
| } | |
| return hash; | |
| } | |
| // ========== BLUESKY API ========== | |
| async function getSession() { | |
| let session = GM_getValue('blueskySession', null); | |
| if (session && session.timestamp && (Date.now() - session.timestamp) < 3600000) { | |
| return session; | |
| } | |
| const creds = getCredentials(); | |
| if (!creds) return null; | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: `${config.blueskyEndpoint}/com.atproto.server.createSession`, | |
| headers: { 'Content-Type': 'application/json' }, | |
| data: JSON.stringify({ | |
| identifier: creds.handle, | |
| password: creds.password | |
| }), | |
| onload: (resp) => { | |
| if (resp.status === 200) { | |
| const session = JSON.parse(resp.responseText); | |
| session.timestamp = Date.now(); | |
| GM_setValue('blueskySession', session); | |
| resolve(session); | |
| } else { | |
| reject(new Error(`Bluesky login failed: ${resp.statusText}`)); | |
| } | |
| }, | |
| onerror: reject | |
| }); | |
| }); | |
| } | |
| function getCredentials() { | |
| let handle = GM_getValue('blueskyHandle', config.blueskyHandle); | |
| let password = GM_getValue('blueskyPassword', config.blueskyAppPassword); | |
| if (!handle || !password) { | |
| handle = prompt('Bluesky handle (e.g., user.bsky.social):', handle) || ''; | |
| password = prompt('Bluesky app password:', password) || ''; | |
| if (handle && password) { | |
| GM_setValue('blueskyHandle', handle); | |
| GM_setValue('blueskyPassword', password); | |
| config.blueskyHandle = handle; | |
| config.blueskyAppPassword = password; | |
| GM_setValue('xToBConfig', config); | |
| } else { | |
| return null; | |
| } | |
| } | |
| return { handle, password }; | |
| } | |
| async function uploadBlob(session, blob, isVideo = false) { | |
| const url = isVideo | |
| ? 'https://video.bsky.app/xrpc/app.bsky.video.uploadVideo' | |
| : `${config.blueskyEndpoint}/com.atproto.repo.uploadBlob`; | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: url, | |
| headers: { | |
| 'Authorization': `Bearer ${session.accessJwt}`, | |
| 'Content-Type': blob.type | |
| }, | |
| data: blob, | |
| onload: (resp) => { | |
| if (resp.status === 200) { | |
| resolve(JSON.parse(resp.responseText).blob); | |
| } else { | |
| reject(new Error(`Upload failed: ${resp.statusText}`)); | |
| } | |
| }, | |
| onerror: reject | |
| }); | |
| }); | |
| } | |
| async function createBlueskyPost(session, text, images = [], videoBlob = null) { | |
| const record = { | |
| text, | |
| createdAt: new Date().toISOString(), | |
| $type: 'app.bsky.feed.post' | |
| }; | |
| const facets = []; | |
| const urlRegex = /(https?:\/\/[^\s]+)/g; | |
| let match; | |
| while ((match = urlRegex.exec(text)) !== null) { | |
| const byteStart = new TextEncoder().encode(text.substring(0, match.index)).length; | |
| const byteEnd = new TextEncoder().encode(text.substring(0, match.index + match[0].length)).length; | |
| facets.push({ | |
| index: { byteStart, byteEnd }, | |
| features: [{ $type: 'app.bsky.richtext.facet#link', uri: match[0] }] | |
| }); | |
| } | |
| if (facets.length) record.facets = facets; | |
| if (videoBlob) { | |
| record.embed = { $type: 'app.bsky.embed.video', video: videoBlob }; | |
| } else if (images.length) { | |
| record.embed = { | |
| $type: 'app.bsky.embed.images', | |
| images: images.map(img => ({ alt: '', image: img })) | |
| }; | |
| } | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: 'POST', | |
| url: `${config.blueskyEndpoint}/com.atproto.repo.createRecord`, | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${session.accessJwt}` | |
| }, | |
| data: JSON.stringify({ | |
| repo: session.did, | |
| collection: 'app.bsky.feed.post', | |
| record | |
| }), | |
| onload: (resp) => { | |
| if (resp.status === 200) { | |
| resolve(JSON.parse(resp.responseText)); | |
| } else { | |
| reject(new Error(`Post failed: ${resp.statusText}`)); | |
| } | |
| }, | |
| onerror: reject | |
| }); | |
| }); | |
| } | |
| // ========== TWEET PROCESSING ========== | |
| function formatPostText(tweetText, authorHandle = '', originalLink = '') { | |
| const authorPart = config.includeAuthor && authorHandle ? `${authorHandle}\n\n` : ''; | |
| const linkPart = config.includeOriginalLink && originalLink ? `\n\n${originalLink}` : ''; | |
| const reserved = authorPart.length + linkPart.length; | |
| const maxLen = config.maxPostLength; | |
| if (tweetText.length + authorPart.length <= maxLen) { | |
| let final = authorPart + tweetText; | |
| if (final.length + linkPart.length <= maxLen) return final + linkPart; | |
| const textLen = maxLen - reserved - linkPart.length; | |
| return authorPart + tweetText.slice(0, textLen - 3) + '...' + linkPart; | |
| } else { | |
| let trimmed = tweetText; | |
| if (trimmed.length > maxLen) { | |
| trimmed = trimmed.slice(0, maxLen - 3) + '...'; | |
| } | |
| if (trimmed.length + linkPart.length <= maxLen) { | |
| return trimmed + linkPart; | |
| } else { | |
| const textLen = maxLen - linkPart.length; | |
| return tweetText.slice(0, textLen - 3) + '...' + linkPart; | |
| } | |
| } | |
| } | |
| function extractTweetData(tweetElement) { | |
| try { | |
| const text = tweetElement.querySelector('[data-testid="tweetText"]')?.innerText || ''; | |
| // Get author handle (@username) instead of display name | |
| let authorHandle = ''; | |
| const userLink = tweetElement.querySelector('[data-testid="User-Name"] a[href^="/"]'); | |
| if (userLink) { | |
| authorHandle = '@' + userLink.href.split('/').pop(); | |
| } else { | |
| // Fallback: find handle in the sibling div | |
| const handleDiv = tweetElement.querySelector('div.r-18u37iz.r-1ez5h0i a[href^="/"] div[dir="ltr"]'); | |
| if (handleDiv) { | |
| authorHandle = handleDiv.textContent.trim(); | |
| } | |
| } | |
| const link = tweetElement.querySelector('time')?.parentElement?.href || ''; | |
| const images = []; | |
| tweetElement.querySelectorAll('[data-testid="tweetPhoto"] img').forEach(img => { | |
| if (img.src && !img.src.includes('profile_images') && !img.src.includes('emoji')) { | |
| images.push(getNativeImageUrl(img.src)); | |
| if (images.length >= config.maxImages) return; | |
| } | |
| }); | |
| let videoUrl = null; | |
| const video = tweetElement.querySelector('video'); | |
| if (video?.currentSrc) videoUrl = video.currentSrc; | |
| else { | |
| const source = tweetElement.querySelector('video source'); | |
| if (source?.src) videoUrl = source.src; | |
| } | |
| return { text, author: authorHandle, link, images, videoUrl }; | |
| } catch (e) { | |
| console.error('Extract error:', e); | |
| return null; | |
| } | |
| } | |
| function getNativeImageUrl(url) { | |
| if (!url) return url; | |
| try { | |
| const u = new URL(url); | |
| if (u.hostname.includes('twimg.com')) { | |
| u.searchParams.set('name', 'orig'); | |
| if (!u.searchParams.has('format')) u.searchParams.set('format', 'jpg'); | |
| return u.toString(); | |
| } | |
| } catch (e) {} | |
| return url.replace(/[?&]name=(small|medium|thumb|large)/gi, 'name=orig'); | |
| } | |
| async function crosspostToBluesky(content, isNewPost = false) { | |
| const data = isNewPost | |
| ? { text: content, author: '', link: '', images: [], videoUrl: null } | |
| : extractTweetData(content); | |
| if (!data) return false; | |
| const postId = isNewPost | |
| ? `new-${Math.abs(hashCode(data.text))}-${Date.now()}` | |
| : data.link; | |
| if (!isNewPost && repostedTweets[data.link]) return true; | |
| if (isNewPost && postedNewTweets[postId]) return true; | |
| const postText = formatPostText(data.text, data.author, data.link); | |
| try { | |
| const session = await getSession(); | |
| if (!session) throw new Error('No session'); | |
| let images = [], videoBlob = null; | |
| if (data.videoUrl) { | |
| try { | |
| const blob = await fetch(data.videoUrl).then(r => r.blob()); | |
| videoBlob = await uploadBlob(session, blob, true); | |
| } catch (e) { console.warn('Video upload failed:', e); } | |
| } else if (data.images.length) { | |
| for (const imgUrl of data.images.slice(0, config.maxImages)) { | |
| try { | |
| const blob = await fetch(imgUrl).then(r => r.blob()); | |
| images.push(await uploadBlob(session, blob)); | |
| } catch (e) { console.warn('Image upload failed:', imgUrl, e); } | |
| } | |
| } | |
| await createBlueskyPost(session, postText, images, videoBlob); | |
| if (isNewPost) { | |
| postedNewTweets[postId] = true; | |
| GM_setValue('postedNewTweets', JSON.stringify(postedNewTweets)); | |
| } else { | |
| repostedTweets[data.link] = true; | |
| GM_setValue('repostedTweets', JSON.stringify(repostedTweets)); | |
| } | |
| return true; | |
| } catch (e) { | |
| console.error('Crosspost failed:', e); | |
| GM_notification({ title: 'Bluesky Error', text: e.message, timeout: 5000 }); | |
| return false; | |
| } | |
| } | |
| // ========== MODES ========== | |
| function setupAutoMode() { | |
| const observer = new MutationObserver((mutations) => { | |
| mutations.forEach(({ addedNodes }) => { | |
| addedNodes.forEach(node => { | |
| if (node.nodeType !== Node.ELEMENT_NODE) return; | |
| // NEW POSTS: Compose Modal | |
| if (node.querySelector?.('[data-testid="tweetButton"]')) { | |
| const modal = node; | |
| const tweetButton = modal.querySelector('[data-testid="tweetButton"]'); | |
| if (tweetButton && !tweetButton.dataset.xToBHandled) { | |
| tweetButton.dataset.xToBHandled = 'true'; | |
| const originalClick = tweetButton.onclick; | |
| tweetButton.onclick = async (e) => { | |
| const textarea = modal.querySelector('[data-testid="tweetTextarea"]'); | |
| const text = textarea?.value?.trim() || ''; | |
| if (text) await crosspostToBluesky(text, true); | |
| if (originalClick) originalClick(e); | |
| }; | |
| } | |
| } | |
| // REPOSTS: Retweet Buttons | |
| node.querySelectorAll?.('[data-testid="retweet"], [data-testid="unretweet"]').forEach(btn => { | |
| if (!btn.dataset.xToBHandled) { | |
| btn.dataset.xToBHandled = 'true'; | |
| const originalClick = btn.onclick; | |
| btn.onclick = async (e) => { | |
| const tweet = btn.closest('[data-testid="tweet"]'); | |
| if (tweet) await crosspostToBluesky(tweet, false); | |
| if (originalClick) originalClick(e); | |
| }; | |
| } | |
| }); | |
| }); | |
| }); | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| // Process existing elements | |
| document.querySelectorAll('[data-testid="tweetButton"]').forEach(btn => { | |
| if (!btn.dataset.xToBHandled) { | |
| btn.dataset.xToBHandled = 'true'; | |
| const modal = btn.closest('[role="dialog"]') || btn.closest('[data-testid="tweet"]'); | |
| const originalClick = btn.onclick; | |
| btn.onclick = async (e) => { | |
| const textarea = modal?.querySelector('[data-testid="tweetTextarea"]'); | |
| const text = textarea?.value?.trim() || ''; | |
| if (text) await crosspostToBluesky(text, true); | |
| if (originalClick) originalClick(e); | |
| }; | |
| } | |
| }); | |
| document.querySelectorAll('[data-testid="retweet"], [data-testid="unretweet"]').forEach(btn => { | |
| if (!btn.dataset.xToBHandled) { | |
| btn.dataset.xToBHandled = 'true'; | |
| const originalClick = btn.onclick; | |
| btn.onclick = async (e) => { | |
| const tweet = btn.closest('[data-testid="tweet"]'); | |
| if (tweet) await crosspostToBluesky(tweet, false); | |
| if (originalClick) originalClick(e); | |
| }; | |
| } | |
| }); | |
| } | |
| function setupButtonMode() { | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| .xtob-btn { | |
| display: inline-flex; align-items: center; justify-content: center; | |
| cursor: pointer; background: transparent; border: none; | |
| border-radius: 9999px; padding: 4px; font-size: 18px; min-width: 32px; | |
| transition: all 0.2s ease; | |
| } | |
| .xtob-btn:hover { background: rgba(29,155,240,0.1); transform: scale(1.05); } | |
| .xtob-btn.posted { opacity: 0.7; cursor: default; } | |
| .xtob-btn.posted:hover { background: transparent; transform: none; } | |
| `; | |
| document.head.appendChild(style); | |
| function addButtons() { | |
| document.querySelectorAll('article[data-testid="tweet"]').forEach(tweet => { | |
| if (tweet.querySelector('.xtob-btn')) return; | |
| const link = tweet.querySelector('time')?.parentElement?.href; | |
| if (!link) return; | |
| const shareBtn = tweet.querySelector('[aria-label*="Share"]'); | |
| if (!shareBtn) return; | |
| const btn = document.createElement('div'); | |
| btn.className = 'xtob-btn'; | |
| btn.innerHTML = repostedTweets[link] ? '✓' : '🦋'; | |
| btn.title = repostedTweets[link] ? 'Already posted to Bluesky' : 'Post to Bluesky'; | |
| if (repostedTweets[link]) btn.classList.add('posted'); | |
| btn.onclick = async (e) => { | |
| e.stopPropagation(); | |
| if (repostedTweets[link]) return; | |
| await crosspostToBluesky(tweet, false); | |
| btn.innerHTML = '✓'; | |
| btn.classList.add('posted'); | |
| }; | |
| const wrapper = document.createElement('div'); | |
| wrapper.style.cssText = 'display: inline-flex; align-items: center; margin-left: 8px;'; | |
| wrapper.appendChild(btn); | |
| shareBtn.parentElement.insertAdjacentElement('afterend', wrapper); | |
| }); | |
| } | |
| setTimeout(addButtons, 1500); | |
| new MutationObserver(() => addButtons()).observe(document.body, { childList: true, subtree: true }); | |
| window.addEventListener('scroll', () => { | |
| clearTimeout(window._st); | |
| window._st = setTimeout(addButtons, 300); | |
| }); | |
| } | |
| // ========== CONFIG UI ========== | |
| function createConfigUI() { | |
| const btn = document.createElement('button'); | |
| btn.innerHTML = '⚙️ XtoB'; | |
| btn.style.cssText = ` | |
| position: fixed; bottom: 20px; right: 20px; z-index: 9999; | |
| padding: 8px 12px; background: #1da1f2; color: white; | |
| border: none; border-radius: 20px; cursor: pointer; | |
| font-size: 14px; box-shadow: 0 2px 5px rgba(0,0,0,0.2); | |
| `; | |
| btn.onclick = showConfigModal; | |
| document.body.appendChild(btn); | |
| } | |
| function showConfigModal() { | |
| const modal = document.createElement('div'); | |
| modal.style.cssText = ` | |
| position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
| background: rgba(0,0,0,0.7); z-index: 10000; | |
| display: flex; justify-content: center; align-items: center; | |
| `; | |
| modal.innerHTML = ` | |
| <div style="background: white; padding: 25px; border-radius: 12px; max-width: 500px; width: 90%;"> | |
| <h2 style="margin-top: 0;">XtoB Settings</h2> | |
| <h3>Mode</h3> | |
| <div style="margin-bottom: 15px;"> | |
| <label><input type="radio" name="mode" value="auto" ${config.mode === 'auto' ? 'checked' : ''}> <strong>Auto:</strong> Crosspost on POST + REPOST</label> | |
| </div> | |
| <div style="margin-bottom: 20px;"> | |
| <label><input type="radio" name="mode" value="button" ${config.mode === 'button' ? 'checked' : ''}> <strong>Button:</strong> Only repost via 🦋 button</label> | |
| </div> | |
| <h3>Bluesky</h3> | |
| <div style="margin-bottom: 10px;"> | |
| <label>Handle: <input type="text" value="${config.blueskyHandle}" id="handleInput" placeholder="user.bsky.social"></label> | |
| </div> | |
| <div style="margin-bottom: 10px;"> | |
| <label>App Password: <input type="password" value="${config.blueskyAppPassword}" id="passwordInput" placeholder="App password"></label> | |
| <div style="font-size: 12px; color: #666;">Get from https://bsky.app/settings/app-passwords</div> | |
| </div> | |
| <div style="margin-bottom: 10px;"> | |
| <label>Endpoint: <input type="text" value="${config.blueskyEndpoint}" id="endpointInput" placeholder="https://bsky.social/xrpc"></label> | |
| </div> | |
| <h3>Options</h3> | |
| <div style="margin-bottom: 10px;"> | |
| <label><input type="checkbox" id="includeAuthor" ${config.includeAuthor ? 'checked' : ''}> Include author <strong>handle</strong> (e.g., @user) in reposts</label> | |
| </div> | |
| <div style="margin-bottom: 10px;"> | |
| <label><input type="checkbox" id="includeLink" ${config.includeOriginalLink ? 'checked' : ''}> Include original tweet link</label> | |
| </div> | |
| <div style="margin-bottom: 10px;"> | |
| <label>Max images: <input type="number" value="${config.maxImages}" id="maxImages" min="1" max="4" style="width: 50px;"></label> | |
| </div> | |
| <div style="margin-top: 20px; text-align: right;"> | |
| <button id="saveBtn" style="padding: 8px 16px; background: #1da1f2; color: white; border: none; border-radius: 4px; cursor: pointer;">Save</button> | |
| <button id="cancelBtn" style="padding: 8px 16px; margin-left: 10px; background: #ccc; border: none; border-radius: 4px; cursor: pointer;">Cancel</button> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| modal.querySelector('#saveBtn').onclick = async () => { | |
| config.mode = modal.querySelector('input[name="mode"]:checked').value; | |
| config.blueskyHandle = modal.querySelector('#handleInput').value; | |
| config.blueskyAppPassword = modal.querySelector('#passwordInput').value; | |
| config.blueskyEndpoint = modal.querySelector('#endpointInput').value; | |
| config.includeAuthor = modal.querySelector('#includeAuthor').checked; | |
| config.includeOriginalLink = modal.querySelector('#includeLink').checked; | |
| config.maxImages = parseInt(modal.querySelector('#maxImages').value) || 4; | |
| GM_setValue('xToBConfig', config); | |
| GM_setValue('blueskyHandle', config.blueskyHandle); | |
| GM_setValue('blueskyPassword', config.blueskyAppPassword); | |
| modal.remove(); | |
| GM_notification('Settings saved! Reloading...'); | |
| setTimeout(() => location.reload(), 1500); | |
| }; | |
| modal.querySelector('#cancelBtn').onclick = () => modal.remove(); | |
| } | |
| // ========== INITIALIZE ========== | |
| createConfigUI(); | |
| if (config.mode === 'button') { | |
| setupButtonMode(); | |
| } else { | |
| setupAutoMode(); | |
| } | |
| console.log('XtoB Crossposter loaded. Mode:', config.mode); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment