Skip to content

Instantly share code, notes, and snippets.

@remy
Last active November 27, 2025 09:46
Show Gist options
  • Select an option

  • Save remy/a4552fca3ad638dffc103edfa28d50e6 to your computer and use it in GitHub Desktop.

Select an option

Save remy/a4552fca3ad638dffc103edfa28d50e6 to your computer and use it in GitHub Desktop.
Using node@>21 (for native WebSocket, fetch and FormData).
// Configuration
const CONFIG = {
blueskyDid: 'YOUR_BSKY_DID', // can be looked up here https://ilo.so/bluesky-did/
mastodonToken: 'YOUR_TOKEN_HERE',
mastodonInstanceUrl: 'YOUR_INSTANCE_URL', // e.g., 'https://front-end.social' - defaults to mastodon.social if empty - no trailing slash
production: true // Set to false for just logging instead of posting
};
// WebSocket connection to Bluesky Jetstream
const ws = new WebSocket(
`wss://jetstream2.us-east.bsky.network/subscribe?wantedCollections=app.bsky.feed.post&wantedDids=${CONFIG.blueskyDid}`
);
ws.on('open', () => {
console.log('Connected to Bluesky Jetstream');
});
ws.on('message', async (data) => {
try {
const payload = JSON.parse(data);
console.log('Received:', payload);
// Filter: only process "create" operations
if (payload.commit?.operation !== 'create') {
return;
}
// Filter: ignore replies
if (payload.commit.record?.reply) {
console.log('Ignoring reply');
return;
}
// Process based on content type
const hasImages =
payload.commit?.embed?.images ||
payload.commit?.record?.embed?.images;
let mastodonPayload;
if (hasImages) {
mastodonPayload = await processWithImages(payload);
} else {
mastodonPayload = processTextOnly(payload);
}
console.log('Mastodon payload:', mastodonPayload);
// Post to Mastodon
if (CONFIG.production) {
await postToMastodon(mastodonPayload);
} else {
console.log('Test mode - would post:', mastodonPayload);
}
} catch (error) {
console.error('Error processing message:', error);
}
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
ws.on('close', () => {
console.log('WebSocket closed');
});
// Process posts with images
async function processWithImages(payload) {
const images =
payload.commit?.embed?.images ||
payload.commit.record.embed.images;
const imagePromises = images.map(async (img) => {
let imageUrl = img.fullsize;
if (!imageUrl) {
const did = payload.did;
const cid = img.image.ref.$link;
imageUrl = `https://bsky.social/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`;
}
const imageRes = await fetch(imageUrl);
const image = Buffer.from(await imageRes.arrayBuffer());
return { image, description: img.alt };
});
const imageArray = await Promise.all(imagePromises);
const text = injectFacetLinks(payload.commit.record);
return { image: imageArray, text };
}
// Process text-only posts
function processTextOnly(payload) {
const text = injectFacetLinks(payload.commit.record);
return { text };
}
// Replace link facets with actual URLs
function injectFacetLinks(record) {
let { text } = record;
const facets = record.facets || [];
if (!facets.length) return text;
const linkFacets = facets
.map((f) => ({
...f,
linkFeature: (f.features || []).find(
(ft) => ft.$type === 'app.bsky.richtext.facet#link'
),
}))
.filter((f) => f.linkFeature);
// Sort by byteStart DESC to replace from the end
linkFacets.sort((a, b) => b.index.byteStart - a.index.byteStart);
for (const facet of linkFacets) {
const { byteStart, byteEnd } = facet.index;
const url = facet.linkFeature.uri;
const start = getStringIndex(text, byteStart);
const end = getStringIndex(text, byteEnd);
text = text.slice(0, start) + url + text.slice(end);
}
return text;
}
// Convert byte offset to string index (handles Unicode properly)
function getStringIndex(text, byteOffset) {
let byteCount = 0;
let i = 0;
while (i < text.length) {
if (byteCount === byteOffset) {
return i;
}
const codePoint = text.codePointAt(i);
let bytes;
if (codePoint < 0x80) bytes = 1;
else if (codePoint < 0x800) bytes = 2;
else if (codePoint < 0x10000) bytes = 3;
else bytes = 4;
byteCount += bytes;
i += codePoint > 0xffff ? 2 : 1;
}
return text.length;
}
// Post to Mastodon
async function postToMastodon(payload) {
const apiUrl = CONFIG.mastodonInstanceUrl
? `${CONFIG.mastodonInstanceUrl}/api/v1/`
: 'https://mastodon.social/api/v1/';
let mediaIds = [];
// Upload images if present
if (payload.image) {
for (const img of payload.image) {
const formData = new FormData();
formData.append('file', new Blob([img.image]), 'image.jpg');
if (img.description) {
formData.append('description', img.description);
}
const mediaRes = await fetch(`${apiUrl}media`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CONFIG.mastodonToken}`
},
body: formData
});
const media = await mediaRes.json();
mediaIds.push(media.id);
}
}
// Post status
const statusData = {
status: payload.text,
visibility: 'public'
};
if (mediaIds.length > 0) {
statusData.media_ids = mediaIds;
}
const response = await fetch(`${apiUrl}statuses`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${CONFIG.mastodonToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(statusData)
});
const result = await response.json();
console.log('Posted to Mastodon:', result);
return result;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment