Last active
November 27, 2025 09:46
-
-
Save remy/a4552fca3ad638dffc103edfa28d50e6 to your computer and use it in GitHub Desktop.
Using node@>21 (for native WebSocket, fetch and FormData).
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
| // 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