MVP - image & text posts
This commit is contained in:
192
index.mjs
Normal file
192
index.mjs
Normal file
@@ -0,0 +1,192 @@
|
||||
// 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';
|
||||
|
||||
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));
|
||||
let uploadResult = await bsky.uploadBlob(fileBuffer, {
|
||||
encoding: `image/${mime}`
|
||||
});
|
||||
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){
|
||||
// TODO: Add aspect ratio to images
|
||||
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');
|
||||
// TODO: Strip hashtag urls #tag (http://url)
|
||||
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 (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}`);
|
||||
// TODO: Rempve temp files
|
||||
} else {
|
||||
console.log(`ERROR: ${status.url} is too long and unable to be reposted`);
|
||||
}
|
||||
}else{
|
||||
// is boosted post
|
||||
// TODO: Handle boosts
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user