Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save 8ullyMaguire/8cd7938cf28ba0d91f88294266781a58 to your computer and use it in GitHub Desktop.

Select an option

Save 8ullyMaguire/8cd7938cf28ba0d91f88294266781a58 to your computer and use it in GitHub Desktop.
XtoB – Auto Crosspost Twitter/X → Bluesky
// ==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