// ==UserScript==
// @name twitter-to-bsky
// @version 0.11
// @description Crosspost from Twitter/X to Bluesky and Mastodon
// @author 59de44955ebd
// @license MIT
// @namespace 59de44955ebd
// @match https://twitter.com/*
// @icon https://raw.githubusercontent.com/59de44955ebd/twitter-to-bsky/main/cross-64x64.png
// @resource cross_icon https://raw.githubusercontent.com/59de44955ebd/twitter-to-bsky/main/cross-64x64.png
// @grant GM_getResourceURL
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @grant GM_notification
// @homepageURL https://github.com/59de44955ebd/twitter-to-bsky
// @supportURL https://github.com/59de44955ebd/twitter-to-bsky/blob/main/README.md
// @run-at document-body
// @inject-into page
// ==/UserScript==
/*jshint esversion: 8 */
(function() {
'use strict';
// Config
const NAV_SELECTOR = 'header nav[role="navigation"]:not(.bsky-navbar)';
const POST_TOOLBAR_SELECTOR = 'div[data-testid="toolBar"] > nav:not(.bsky-toolbar)';
const POST_BUTTON_SELECTOR = 'div[data-testid="tweetButton"]:not(.bsky-button), div[data-testid="tweetButtonInline"]:not(.bsky-button)';
const POST_TEXT_AREA_SELECTOR = '[data-testid="tweetTextarea_0"]';
const POST_ATTACHMENTS_SELECTOR = '[data-testid="attachments"]';
const BSKY_PDS_URL = 'https://bsky.social';
const BSKY_IMAGE_MAX_BYTES = 1000000; // 10 MB
const MASTODON_IMAGE_MAX_BYTES = 8000000; // 8 MB
const MASTODON_VIDEO_MAX_BYTES = 40000000; // 40 MB
const icon_url = GM_getResourceURL('cross_icon', false);
const css = `
.bsky-nav {
padding: 12px;
cursor: pointer;
}
.bsky-nav a {
width: 1.75rem;
height: 1.75rem;
background-image: url(${icon_url});
background-size: cover;
display: block;
}
@media (min-width: 1265px) {
.bsky-nav a:after {
content: "Crosspost";
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 20px;
font-weight: 400;
margin-left: 46px;
color: rgb(15, 20, 25);
}
}
@media (prefers-color-scheme: dark) {
.bsky-nav a {
filter: invert(1);
}
.bsky-nav a:after {
font-weight: 500;
}
}
.cross-checkbox {
margin-left: 5px;
}
.cross-checkbox input {
cursor: pointer;
}
.cross-checkbox span {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-weight: bold;
font-size: 11px;
cursor: pointer;
}
.cross-checkbox input:disabled,
.cross-checkbox input:disabled + span {
color: #ccc;
cursor: default;
}
.bsky-settings {
position: fixed;
width: 280px;
background: inherit;
padding: 10px;
border: 2px solid #0085FF;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 13.3333px
}
.bsky-settings fieldset {
margin-bottom: 5px;
padding-bottom: 2px;
}
.bsky-settings legend {
font-size: 11px;
font-weight: bold;
margin-bottom: 5px;
}
.bsky-settings input[type="text"],
.bsky-settings input[type="url"],
.bsky-settings input[type="password"]
{
display: block;
box-sizing: border-box;
width: 100%;
margin-bottom: 10px
}
.bsky-settings label {
display: block;
cursor: pointer;
}
.bsky-settings button {
margin-top: 10px
}
`;
// Mastodon stuff
let mastodon_client = null;
let mastodon_instance_url = GM_getValue('mastodon_instance_url', 'https://mastodon.social');
let mastodon_api_key = GM_getValue('mastodon_api_key', '');
let mastodon_crosspost_enabled = mastodon_instance_url != '' && mastodon_api_key != '';
let mastodon_crosspost_checked = GM_getValue('mastodon_crosspost_checked', false);
// Bluesky stuff
let bsky_client = null;
let bsky_handle = GM_getValue('bsky_handle', '');
let bsky_app_password = GM_getValue('bsky_app_password', '');
let bsky_session = GM_getValue('bsky_session', null);
let bsky_crosspost_enabled = bsky_handle != '' && bsky_app_password != '';
let bsky_crosspost_checked = GM_getValue('bsky_crosspost_checked', false);
let crosspost_show_notifications = GM_getValue('crosspost_show_notifications', true);
let crosspost_open_tabs = GM_getValue('crosspost_open_tabs', false);
let settings_div = null;
let media_card = null;
let is_cross_posted = false;
const debug = function(...toLog)
{
console.debug('[BSKY]', ...toLog);
};
const notify = function(message)
{
if (crosspost_show_notifications)
{
GM_notification(message, 'twitter-to-bsky', icon_url);
}
};
class Mastodon
{
// Parameters are optional
constructor(mastodon_api_root_url, mastodon_api_key)
{
this._mastodon_api_root_url = mastodon_api_root_url;
this._mastodon_api_key = mastodon_api_key;
}
set_credentials(mastodon_api_root_url, mastodon_api_key)
{
this._mastodon_api_root_url = mastodon_api_root_url;
this._mastodon_api_key = mastodon_api_key;
}
async upload_image(image_url)
{
return fetch(image_url)
.then(res => res.blob())
.then(blob => {
if (blob.size > MASTODON_IMAGE_MAX_BYTES)
{
throw new Error(`Size of image ${blob.name} exceeds max. allowed size (${MASTODON_IMAGE_MAX_BYTES})`);
}
const formData = new FormData();
formData.append('file', blob);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: this._mastodon_api_root_url + '/api/v1/media',
headers: {
'Authorization': 'Bearer ' + this._mastodon_api_key,
},
fetch: true,
data: formData,
onload: (response) => {
const res = JSON.parse(response.responseText);
if (res.error)
{
reject(res.error);
}
resolve(res);
},
onerror: reject,
});
});
});
}
async upload_video(video_object)
{
return fetch(video_object.currentSrc)
.then(res => res.blob())
.then(blob => {
if (blob.size > MASTODON_VIDEO_MAX_BYTES)
{
throw new Error(`Size of video ${blob.name} exceeds max. allowed size (${MASTODON_VIDEO_MAX_BYTES})`);
}
const formData = new FormData();
formData.append('file', blob);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: this._mastodon_api_root_url + '/api/v1/media',
headers: {
'Authorization': 'Bearer ' + this._mastodon_api_key,
},
fetch: true,
data: formData,
onload: (response) => {
const res = JSON.parse(response.responseText);
if (res.error)
{
reject(res.error);
}
resolve(res);
},
onerror: reject,
});
});
});
}
async create_post(post_text, media_ids)
{
const post = {
status: post_text,
};
if (media_ids && media_ids.length)
{
post.media_ids = media_ids;
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: this._mastodon_api_root_url + '/api/v1/statuses',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this._mastodon_api_key,
},
fetch: true,
data: JSON.stringify(post),
onload: (response) => {
const res = JSON.parse(response.responseText);
if (res.error)
{
reject(res.error);
}
resolve(res);
},
onerror: reject,
});
});
}
}
class BSKY
{
// All parameters are optional
constructor(bsky_handle, bsky_app_password, bsky_session)
{
this._bsky_handle = bsky_handle;
this._bsky_app_password = bsky_app_password;
this._session = bsky_session;
}
set_credentials(bsky_handle, bsky_app_password)
{
this._bsky_handle = bsky_handle;
this._bsky_app_password = bsky_app_password;
this._session = null;
}
async login()
{
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: BSKY_PDS_URL + '/xrpc/com.atproto.server.createSession',
headers: {
'Content-Type': 'application/json',
},
data: JSON.stringify({
identifier: this._bsky_handle,
password: this._bsky_app_password,
}),
onload: (response) => {
const session = JSON.parse(response.responseText);
if (session.error)
{
reject(session.message);
}
this._session = session;
resolve(session);
},
onerror: reject,
});
});
}
async refresh_session()
{
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: BSKY_PDS_URL + '/xrpc/com.atproto.server.refreshSession',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this._session.refreshJwt,
},
onload: (response) => {
const session = JSON.parse(response.responseText);
if (session.error)
{
reject(session.message);
}
this._session = session;
resolve(session);
},
onerror: reject,
});
});
}
// Utility function
async verify_session()
{
if (this._session)
{
try
{
return await this.refresh_session();
} catch (err)
{
return await this.login();
}
}
else
{
return this.login();
}
}
async upload_image(image_url)
{
return fetch(image_url)
.then(res => res.blob())
.then(blob => {
if (blob.size > BSKY_IMAGE_MAX_BYTES)
{
throw new Error(`Size of image ${blob.name} exceeds max. allowed size (${BSKY_IMAGE_MAX_BYTES})`);
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: BSKY_PDS_URL + '/xrpc/com.atproto.repo.uploadBlob',
headers: {
'Content-Type': blob.type,
'Authorization': 'Bearer ' + this._session.accessJwt,
},
fetch: true,
data: blob,
onload: (response) => {
const res = JSON.parse(response.responseText);
if (res.error)
{
reject(res.message);
}
resolve(res);
},
onerror: reject,
});
});
});
}
async create_post(post_text, post_images, post_embed)
{
const now = (new Date()).toISOString();
// Required fields that each post must include
const post = {
'$type': 'app.bsky.feed.post',
'text': post_text,
'createdAt': now,
};
if (post_images && post_images.images.length)
{
post.embed = post_images;
}
else if (post_embed)
{
post.embed = post_embed;
}
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: BSKY_PDS_URL + '/xrpc/com.atproto.repo.createRecord',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + this._session.accessJwt,
},
fetch: true,
data: JSON.stringify({
repo: this._session.did,
collection: 'app.bsky.feed.post',
record: post,
}),
onload: (response) => {
const res = JSON.parse(response.responseText);
if (res.error)
{
reject(res.message);
}
resolve(res);
},
onerror: reject,
});
});
}
}
/*
* Adds new cross icon to navbar for changing crosspost settings.
*/
const extend_navbar = function(nav)
{
const a = document.createElement('a');
a.title = 'Crosspost Settings';
a.addEventListener('click', function()
{
if (settings_div)
{
document.body.removeChild(settings_div);
settings_div = null;
return;
}
const r = a.getBoundingClientRect();
settings_div = document.createElement('div');
settings_div.className = 'bsky-settings';
settings_div.style = `left:${r.right + 5}px;top:${r.top}px;`;
settings_div.innerHTML = `
<fieldset>
<legend>Mastodon</legend>
<input type="url" name="mastodon_instance_url" placeholder="Mastodon Instance URL" autocomplete="section-mastodon url" value="${mastodon_instance_url}">
<input type="password" name="mastodon_api_key" placeholder="Mastodon Access Token" autocomplete="section-mastodon current-password" value="${mastodon_api_key}">
</fieldset>
<fieldset>
<legend>Bluesky</legend>
<input type="text" name="bsky_handle" placeholder="Bluesky Handle" autocomplete="section-bsky username" value="${bsky_handle}">
<input type="password" name="bsky_app_password" placeholder="Bluesky App Password" autocomplete="section-bsky current-password" value="${bsky_app_password}">
</fieldset>
<label><input type="checkbox" name="crosspost_show_notifications"${crosspost_show_notifications ? ' checked' : ''}>Show crosspost notifications?</label>
<label><input type="checkbox" name="crosspost_open_tabs"${crosspost_open_tabs ? ' checked' : ''}>Open crossposts in new tab?</label>
`;
const btn = document.createElement('button');
btn.innerText = 'Save';
settings_div.appendChild(btn);
btn.addEventListener('click', function() {
mastodon_instance_url = settings_div.querySelector('[name="mastodon_instance_url"]').value;
mastodon_api_key = settings_div.querySelector('[name="mastodon_api_key"]').value;
bsky_handle = settings_div.querySelector('[name="bsky_handle"]').value;
bsky_app_password = settings_div.querySelector('[name="bsky_app_password"]').value;
crosspost_show_notifications = settings_div.querySelector('[name="crosspost_show_notifications"]').checked;
crosspost_open_tabs = settings_div.querySelector('[name="crosspost_open_tabs"]').checked;
document.body.removeChild(settings_div);
settings_div = null;
GM_setValue('mastodon_instance_url', mastodon_instance_url);
GM_setValue('mastodon_api_key', mastodon_api_key);
GM_setValue('bsky_handle', bsky_handle);
GM_setValue('bsky_app_password', bsky_app_password);
GM_setValue('crosspost_show_notifications', crosspost_show_notifications);
GM_setValue('crosspost_open_tabs', crosspost_open_tabs);
mastodon_client.set_credentials(mastodon_instance_url, mastodon_api_key);
mastodon_crosspost_enabled = mastodon_instance_url != '' && mastodon_api_key != '';
// Update disabled state of all checkboxes
for (let el of document.querySelectorAll('.mastodon-checkbox input'))
{
el.disabled = !mastodon_crosspost_enabled;
}
bsky_client.set_credentials(bsky_handle, bsky_app_password);
bsky_crosspost_enabled = bsky_handle != '' && bsky_app_password != '';
// Update disabled state of all checkboxes
for (let el of document.querySelectorAll('.bsky-checkbox input'))
{
el.disabled = !bsky_crosspost_enabled;
}
});
document.body.appendChild(settings_div);
return false;
});
const div = document.createElement('div');
div.className = 'bsky-nav';
div.appendChild(a);
nav.appendChild(div);
};
/*
* Adds new Bluesky and Mastodon checkboxes to post toolbars
*/
const create_crosspost_checkboxes = function(toolbar)
{
const label_m = document.createElement('label');
label_m.className = 'cross-checkbox mastodon-checkbox';
label_m.title = 'Crosspost to Mastodon?';
const checkbox_m = document.createElement('input');
checkbox_m.type = 'checkbox';
checkbox_m.checked = mastodon_crosspost_checked;
checkbox_m.disabled = !mastodon_crosspost_enabled;
checkbox_m.addEventListener('click', function()
{
mastodon_crosspost_checked = this.checked;
GM_setValue('mastodon_crosspost_checked', mastodon_crosspost_checked);
for (let el of document.querySelectorAll('.mastodon-checkbox input'))
{
el.checked = mastodon_crosspost_checked;
}
});
label_m.appendChild(checkbox_m);
const span_m = document.createElement('span');
span_m.innerText = 'Mastodon';
label_m.appendChild(span_m);
toolbar.appendChild(label_m);
const label_b = document.createElement('label');
label_b.className = 'cross-checkbox bsky-checkbox';
label_b.title = 'Crosspost to Bluesky?';
const checkbox_b = document.createElement('input');
checkbox_b.type = 'checkbox';
checkbox_b.checked = bsky_crosspost_checked;
checkbox_b.disabled = !bsky_crosspost_enabled;
checkbox_b.addEventListener('click', function()
{
bsky_crosspost_checked = this.checked;
GM_setValue('bsky_crosspost_checked', bsky_crosspost_checked);
for (let el of document.querySelectorAll('.bsky-checkbox input'))
{
el.checked = bsky_crosspost_checked;
}
});
label_b.appendChild(checkbox_b);
const span_b = document.createElement('span');
span_b.innerText = 'Bluesky';
label_b.appendChild(span_b);
toolbar.appendChild(label_b);
};
/*
* Intercepts post requests, possibly first posts to Mastodon and/or Bluesky, then to Twitter/X.
*/
const post_button_handler = async function(e)
{
debug('POST BUTTON clicked');
if (this.firstChild.getAttribute('aria-disabled'))
{
e.stopPropagation();
return;
}
if (!is_cross_posted && ((mastodon_crosspost_enabled && mastodon_crosspost_checked) || (bsky_crosspost_enabled && bsky_crosspost_checked)))
{
// First crosspost
e.stopPropagation();
let post_text = '';
const div_text = document.querySelector(POST_TEXT_AREA_SELECTOR);
if (div_text)
{
post_text = div_text.innerText;
}
// Mastodon
if (mastodon_crosspost_enabled && mastodon_crosspost_checked)
{
try
{
// Get media attachments
const media_ids = [];
const div_attachments = document.querySelector(POST_ATTACHMENTS_SELECTOR);
if (div_attachments)
{
const images = div_attachments.querySelectorAll('img');
if (images.length)
{
for (let img of images)
{
await mastodon_client.upload_image(img.src)
.then((res) => {
media_ids.push(res.id);
});
}
}
const videos = div_attachments.querySelectorAll('video');
if (videos.length)
{
for (let vid of videos)
{
await mastodon_client.upload_video(vid)
.then((res) => {
media_ids.push(res.id);
});
}
}
}
debug('Posting to Mastodon...');
await mastodon_client.create_post(post_text, media_ids)
.then((res) => {
notify('Post was successfully crossposted to Mastodon');
if (crosspost_open_tabs && res.uri)
{
GM_openInTab(res.url, {active: true});
}
});
}
catch (error)
{
debug(error);
notify(`Error: crossposting to Mastodon failed: \n${error}`);
}
}
// Bluesky
if (bsky_crosspost_enabled && bsky_crosspost_checked)
{
const post_images = {
'$type': 'app.bsky.embed.images',
'images': [],
};
let post_card = null;
try {
await bsky_client.verify_session()
.then((session) => {
if (session.error)
{
throw new Error(session.message);
}
GM_setValue('bsky_session', session);
});
// Get images
const div_attachments = document.querySelector(POST_ATTACHMENTS_SELECTOR);
if (div_attachments)
{
for (let img of div_attachments.querySelectorAll('img'))
{
await bsky_client.upload_image(img.src)
.then((res) => {
post_images.images.push({
alt: '',
image: res.blob
});
});
}
}
// Get card (Bluesky only allows either images or card)
if (!post_images.images.length && media_card && post_text.includes(media_card.url))
{
post_card = {
'$type': 'app.bsky.embed.external',
'external': {
uri: media_card.url,
title: media_card.title,
description: media_card.description,
},
};
if (media_card.image)
{
await bsky_client.upload_image(media_card.image)
.then((res) => {
post_card.external.thumb = res.blob;
// post_text = post_text.replace(media_card.url, '');
});
}
}
debug('Posting to Bluesky...');
await bsky_client.create_post(post_text, post_images, post_card)
.then((res) => {
notify('Post was successfully crossposted to Bluesky');
if (crosspost_open_tabs && res.uri)
{
GM_openInTab(`https://bsky.app/profile/${bsky_handle}/post/` + res.uri.split('/').pop(), {active: true});
}
});
}
catch (error)
{
debug(error);
notify(`Error: crossposting to Bluesky failed: \n${error.message}`);
}
}
is_cross_posted = true;
// Now forward click event to actually post on Twitter/X
this.click();
}
else
{
is_cross_posted = false;
}
};
GM_addStyle(css);
/*
* Observer that watches page for dynamic updates and injects elements and event handlers
*/
const pageObserver = new MutationObserver(() => {
const navbar = document.querySelector(NAV_SELECTOR);
if (navbar && !navbar.querySelector('.bsky-nav'))
{
debug('NAVBAR found');
navbar.classList.toggle('bsky-navbar', true);
extend_navbar(navbar);
}
const toolbar = document.querySelector(POST_TOOLBAR_SELECTOR);
if (toolbar)
{
debug('POST_TOOLBAR found');
toolbar.classList.toggle('bsky-toolbar', true);
create_crosspost_checkboxes(toolbar);
}
const button = document.querySelector(POST_BUTTON_SELECTOR);
if (button)
{
debug('POST_BUTTON found');
button.classList.toggle('bsky-button', true);
button.addEventListener('click', post_button_handler, true);
}
});
pageObserver.observe(document.body, { childList: true, subtree: true });
mastodon_client = new Mastodon(mastodon_instance_url, mastodon_api_key);
bsky_client = new BSKY(bsky_handle, bsky_app_password, bsky_session);
// Hook into native XMLHttpRequest to capture card data
unsafeWindow.XMLHttpRequest.prototype._open = unsafeWindow.XMLHttpRequest.prototype.open;
unsafeWindow.XMLHttpRequest.prototype.open = function(...args) {
if (args[1].includes('/cards/'))
{
this.addEventListener("readystatechange", function() {
if (this.readyState === 4)
{
const res = JSON.parse(this.response);
if (res.card)
{
media_card = {
url: res.card.url,
title: res.card.binding_values.title.string_value,
description: res.card.binding_values.description.string_value,
image: res.card.binding_values.thumbnail_image_original.image_value.url,
};
}
}
}, false);
}
this._open(...args);
};
})();