// Copyright 2025 Elizabeth Cray // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import "dotenv/config"; import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync, createWriteStream } from 'fs'; import { finished } from 'stream/promises'; import { Readable } from 'stream'; import Mastodon from "mastodon-api"; import replaceAsync from "string-replace-async"; import { htmlToText } from "html-to-text"; import axios from "axios"; import { AtpAgent, RichText } from '@atproto/api' import { v4 as uuid } from 'uuid'; import { fileTypeFromBuffer } from 'file-type'; const historyFile = process.env.HISTORY_FILE || '.history'; let history = []; let sessionFile = process.env.SESSION_FILE || '.bsky_session'; const client = new Mastodon({ access_token: process.env.MASTODON_TOKEN, api_url: `${process.env.MASTODON_INSTANCE}/api/v1`, }); const bsky = new AtpAgent({ service: process.env.BSKY_INSTANCE || 'https://bsky.social', persistSession: (evt, sess = null) => { if (evt === "create" || evt === "update"){ // safe writeFileSync(sessionFile, JSON.stringify(sess)); }else { // delete if (existsSync(sessionFile)){ unlinkSync(sessionFile); } } } }); const downloadFile = (async (url, fileName) => { const res = await fetch(url); const fileStream = createWriteStream(fileName, { flags: 'wx' }); await finished(Readable.fromWeb(res.body).pipe(fileStream)); }); let freshLogin = false; console.log("Logging in to Bluesky"); try { if (existsSync(sessionFile)){ await bsky.resumeSession(JSON.parse(readFileSync(sessionFile, 'UTF-8'))); }else{ freshLogin = true; } } catch (error) { console.log(error); freshLogin = true; } if (freshLogin){ console.log('Creating new session'); await bsky.login({ identifier: process.env.BSKY_USER, password: process.env.BSKY_APP_PASS }); } if (!process.env.MASTODON_ID){ client.get("/accounts/lookup", { acct: process.env.MASTODON_USER }, (error, data) => { if (error || !data.id) { console.error(`User ${process.env.MASTODON_USER} not found`); process.exit(1); } else { process.env.MASTODON_ID = data.id; appendFileSync('.env', `\nMASTODON_ID: "${data.id}"`); } }); } if (existsSync(historyFile)){ history += readFileSync(historyFile, 'UTF-8').split('\n'); } const bskyPost = async (text, media = []) => { let uploadedMedia = []; for (let m of media){ // let mime = `${m.url}`.split('.').pop(); // mime = mime.toLowerCase(); // mime = mime === 'jpg'?'jpeg':mime; const fileBuffer = Buffer.from(readFileSync(m.data)); const { ext, mimeT } = await fileTypeFromBuffer(fileBuffer); let uploadResult = await bsky.uploadBlob(fileBuffer, { // encoding: `image/${mime}` encoding: mimeT }); if (uploadResult.success){ uploadedMedia.push({ alt: m.alt, image: JSON.parse(JSON.stringify(uploadResult.data.blob)) }); }else { console.log(`Error uploading media: ${JSON.stringify(uploadResult)}`); } } const postBody = new RichText({ text: text }); await postBody.detectFacets(bsky); let post = { $type: 'app.bsky.feed.post', text: postBody.text, facets: postBody.facets, createdAt: new Date().toISOString() }; if (uploadedMedia.length > 0){ post.embed = { "$type": "app.bsky.embed.images", images: uploadedMedia } } console.log(JSON.stringify(post)); let bskyPostData = await bsky.post(post); console.log(`Posted to Bluesky: ${bskyPostData.uri} - ${bskyPostData.cid}`); } client.get(`/accounts/${process.env.MASTODON_ID}/statuses`, { limit: process.env.MASTODON_API_LIMIT || 5, exclude_replies: false, exclude_reblogs: false }, async (error, data) => { if (error) { console.error(error); process.exit(1); } for (let status of data) { if (!history.includes(status.id) && status.visibility === 'public' && !status.local_only){ // Post has not been handled, and is public if (!status.reblog){ // Is normal post or reply let text = htmlToText(status.content, { preserveNewlines: true, selectors: [ { selector: 'a', options: { hideLinkHrefIfSameAsText: true, linkBrackets: ['(',')'], }} ], }); text = text.replace(/@([^ ]+) \(http[s]?:\/\/([^\/]+)[^\)]+\)/g, '@$1@$2'); text = text.replace(/(#[^ #]+) \(http[s]?:\/\/[^\)]+\)/g, '$1'); text = await replaceAsync(text, /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g, async (url) => { if (url.length < 40){ return url; } let r = await axios.get(`https://ulvis.net/api.php?url=${url}`); return r.data?r.data:url; }); if (status.mentions > 0 && status.in_reply_to_id !== null){ // Post is a reply const replyTo = [...status.mentions].filter((mention) => mention.id === status.in_reply_to_account_id); if (replyTo.length > 0){ text = `Reply to: ${replyTo[0].url}/@${replyTo[0].acct}/${status.in_reply_to_id}\n${text}`; } } if (text.length > 300 && status.url.length < 300){ text = `${text.slice(0, -1 * (status.url.length + 4))}...\n${status.url}`; } if (text.length <= 300){ let medias = []; for (let media of status.media_attachments){ if (media.type == 'image'){ const tFile = `.temp_${uuid()}`; await downloadFile(media.preview_url, tFile); medias.push({ data: tFile, url: media.preview_url, alt: media.description }); } } bskyPost(text, medias); appendFileSync(historyFile, `\n${status.id}`); } else { console.log(`ERROR: ${status.url} is too long and unable to be reposted`); } }else{ // is boosted post let text = status.reblog.url; bskyPost(text, []); appendFileSync(historyFile, `\n${status.id}`); } } } });