202 lines
7.4 KiB
JavaScript
202 lines
7.4 KiB
JavaScript
// 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}`);
|
|
}
|
|
}
|
|
}
|
|
}); |