m2b/index.mjs
2025-02-01 04:40:38 +00:00

204 lines
7.5 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
// TODO: This isn't working yet
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}`);
}
}
}
});