Compare commits
10 commits
Author | SHA1 | Date | |
---|---|---|---|
|
744767a9e2 | ||
|
ab01098653 | ||
|
94f3caf2fc | ||
|
598b60d98a | ||
|
9057cb4a71 | ||
|
b093c7d5fc | ||
|
598fb3a7a9 | ||
|
0b753d234c | ||
|
887ba8fcc7 | ||
|
032677e371 |
9 changed files with 9278 additions and 9261 deletions
|
@ -2,4 +2,5 @@ MASTODON_INSTANCE="https://mastodon.instance"
|
|||
MASTODON_USER="username"
|
||||
BLUESKY_ENDPOINT="https://bsky.social"
|
||||
BLUESKY_HANDLE="USERNAME.bsky.social"
|
||||
BLUESKY_PASSWORD="PASSWORD"
|
||||
BLUESKY_PASSWORD="PASSWORD"
|
||||
INTERVAL_MINUTES=5
|
||||
|
|
83
.github/workflows/main.yml
vendored
83
.github/workflows/main.yml
vendored
|
@ -1,83 +0,0 @@
|
|||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build docker image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Read .nvmrc
|
||||
run: echo "NODE_VERSION=$(awk -F. '{print $1}' .nvmrc)" >> $GITHUB_OUTPUT
|
||||
id: nvm
|
||||
|
||||
- name: Login to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=schedule
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=sha,prefix={{branch}}-,format=short
|
||||
type=sha,prefix=,format=short
|
||||
{{branch}}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
build-args: |
|
||||
NODE_VERSION=${{ steps.nvm.outputs.NODE_VERSION }}
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: build
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read .nvmrc
|
||||
run: echo "NODE_VERSION=$(cat .nvmrc)" >> $GITHUB_OUTPUT
|
||||
id: nvm
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ steps.nvm.outputs.NODE_VERSION }}
|
||||
|
||||
- name: Install
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_SEMANTIC_RELEASE }}
|
||||
run: npx semantic-release
|
44
.github/workflows/release.yml
vendored
44
.github/workflows/release.yml
vendored
|
@ -1,44 +0,0 @@
|
|||
name: Release
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- created
|
||||
env:
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
uses: battila7/get-version-action@v2
|
||||
|
||||
- name: Add Semantic Version tag to Docker Image
|
||||
uses: shrink/actions-docker-registry-tag@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
repository: ${{ env.IMAGE_NAME }}
|
||||
target: 'main'
|
||||
tags: |
|
||||
${{ steps.version.outputs.version-without-v }}
|
||||
${{ steps.version.outputs.major }}.${{ steps.version.outputs.minor }}
|
||||
latest
|
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -1,3 +1,24 @@
|
|||
# [1.2.0](https://github.com/mauricerenck/mastodon-to-bluesky/compare/v1.1.1...v1.2.0) (2024-05-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* split long posts into replies ([#10](https://github.com/mauricerenck/mastodon-to-bluesky/issues/10)) ([b093c7d](https://github.com/mauricerenck/mastodon-to-bluesky/commit/b093c7d5fc6383da19c6434f57037e014da822de))
|
||||
|
||||
## [1.1.1](https://github.com/mauricerenck/mastodon-to-bluesky/compare/v1.1.0...v1.1.1) (2024-04-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* updating newTimestampId correctly ([0b753d2](https://github.com/mauricerenck/mastodon-to-bluesky/commit/0b753d234caff870e7a5fcb5b4bc4fac1a2e1acf))
|
||||
|
||||
# [1.1.0](https://github.com/mauricerenck/mastodon-to-bluesky/compare/v1.0.0...v1.1.0) (2024-04-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add error-handling for Bluesky posting; truncate messages with more than 300 chars ([#7](https://github.com/mauricerenck/mastodon-to-bluesky/issues/7)) ([032677e](https://github.com/mauricerenck/mastodon-to-bluesky/commit/032677e3715566db9d26dd79a93d38776ddd4bc1))
|
||||
|
||||
# 1.0.0 (2024-03-30)
|
||||
|
||||
|
||||
|
|
59
README.md
59
README.md
|
@ -1,3 +1,56 @@
|
|||
# mastodon-to-bluesky
|
||||
A Node.js script for crossposting from mastodon to bluesky
|
||||
For more details see: https://maurice-renck.de/hub/tooling/crosspost-from-mastodon-to-bluesky
|
||||
# Mastodon to Bluesky
|
||||
This is forked from https://github.com/mauricerenck/mastodon-to-bluesky. So far I have only added the following:
|
||||
|
||||
- Upgraded packages due to issues with conversion to Bluesky.
|
||||
- Updated docker-compose to build the Docker image.
|
||||
|
||||
#### Crosspost from Mastodon to Bluesky
|
||||
|
||||
 
|
||||
|
||||
---
|
||||
|
||||
This scripts listens to your Mastodon account and crossposts your toots to your Bluesky account. It uses the Mastodon API and the Bluesky API to achieve this. The script is written in Node.js and can be run on your local machine or on a server.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
You can run the script directly using Node.js or you can use the Docker image.
|
||||
|
||||
### Node.js
|
||||
|
||||
Clone this repository and install the dependencies:
|
||||
|
||||
```bash
|
||||
git clone https://code.alexhyett.com/alexhyett/mastodon-to-bluesky.git
|
||||
cd mastodon-to-bluesky
|
||||
npm install
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a `.env` file in the root directory of the project and add the following variables:
|
||||
|
||||
```bash
|
||||
MASTODON_INSTANCE: 'https://mastodon.instance'
|
||||
MASTODON_USER: 'username'
|
||||
BLUESKY_ENDPOINT: 'https://bsky.social'
|
||||
BLUESKY_HANDLE: 'USERNAME.bsky.social'
|
||||
BLUESKY_PASSWORD: 'PASSWORD'
|
||||
INTERVAL_MINUTES: 5
|
||||
```
|
||||
|
||||
You can also set the same variables as environment variables in the `docker-compose.yml` file.
|
||||
|
||||
## Usage
|
||||
|
||||
To run the script, execute the following command:
|
||||
|
||||
```bash
|
||||
node main.js
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
For more details see: https://maurice-renck.de/hub/tooling/crosspost-from-mastodon-to-bluesky
|
||||
|
|
|
@ -1,19 +1,12 @@
|
|||
version: '3'
|
||||
services:
|
||||
app:
|
||||
image: host/mastodon-to-bluesky:latest
|
||||
build: .
|
||||
container_name: mastodon-to-bluesky
|
||||
environment:
|
||||
MASTODON_INSTANCE: 'https://mastodon.instance'
|
||||
MASTODON_USER: 'username'
|
||||
BLUESKY_ENDPOINT: 'https://bsky.social'
|
||||
BLUESKY_HANDLE: 'USERNAME.bsky.social'
|
||||
BLUESKY_PASSWORD: 'PASSWORD'
|
||||
INTERVAL_MINUTES: 5
|
||||
env_file: '.env'
|
||||
volumes:
|
||||
- mastodon-to-bluesky:/usr/src/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
mastodon-to-bluesky:
|
||||
external: true
|
||||
mastodon-to-bluesky:
|
||||
|
|
209
main.js
209
main.js
|
@ -1,95 +1,154 @@
|
|||
require("dotenv").config();
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { RichText, BskyAgent } = require("@atproto/api");
|
||||
const { RichText, AtpAgent } = require("@atproto/api");
|
||||
const axios = require("axios");
|
||||
const he = require('he');
|
||||
|
||||
// Mastodon credentials
|
||||
const mastodonInstance = process.env.MASTODON_INSTANCE;
|
||||
const mastodonUser = process.env.MASTODON_USER;
|
||||
|
||||
// Bluesky agent
|
||||
const agent = new BskyAgent({ service: process.env.BLUESKY_ENDPOINT });
|
||||
|
||||
// File to store the last processed Mastodon post ID
|
||||
const lastProcessedPostIdFile = path.join(
|
||||
__dirname,
|
||||
"data",
|
||||
"lastProcessedPostId.txt"
|
||||
);
|
||||
|
||||
// Variable to store the last processed Mastodon post ID
|
||||
let lastProcessedPostId = loadLastProcessedPostId();
|
||||
|
||||
// Function to load the last processed post ID from the file
|
||||
function loadLastProcessedPostId() {
|
||||
try {
|
||||
return fs.readFileSync(lastProcessedPostIdFile, "utf8").trim();
|
||||
} catch (error) {
|
||||
console.error("Error loading last processed post ID:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to save the last processed post ID to the file
|
||||
function saveLastProcessedPostId() {
|
||||
try {
|
||||
fs.writeFileSync(lastProcessedPostIdFile, `${lastProcessedPostId}`);
|
||||
} catch (error) {
|
||||
console.error("Error saving last processed post ID:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function postToBluesky(text) {
|
||||
await agent.login({
|
||||
async function main() {
|
||||
// Bluesky agent
|
||||
const agent = new AtpAgent({ service: process.env.BLUESKY_ENDPOINT });
|
||||
const loginResponse = await agent.login({
|
||||
identifier: process.env.BLUESKY_HANDLE,
|
||||
password: process.env.BLUESKY_PASSWORD,
|
||||
});
|
||||
if (!loginResponse.success) console.error("🔒 login failed");
|
||||
|
||||
const richText = new RichText({ text });
|
||||
await richText.detectFacets(agent);
|
||||
await agent.post({
|
||||
text: richText.text,
|
||||
facets: richText.facets,
|
||||
});
|
||||
}
|
||||
|
||||
function removeHtmlTags(input) {
|
||||
return input.replace(/<[^>]*>/g, "");
|
||||
}
|
||||
|
||||
// Function to periodically fetch new Mastodon posts
|
||||
async function fetchNewPosts() {
|
||||
const response = await axios.get(
|
||||
`${mastodonInstance}/users/${mastodonUser}/outbox?page=true`
|
||||
// File to store the last processed Mastodon post ID
|
||||
const lastProcessedPostIdFile = path.join(
|
||||
__dirname,
|
||||
"data",
|
||||
"lastProcessedPostId.txt"
|
||||
);
|
||||
|
||||
const reversed = response.data.orderedItems
|
||||
.filter((item) => item.object.type === "Note")
|
||||
.filter((item) => item.object.inReplyTo === null)
|
||||
.reverse();
|
||||
// Variable to store the last processed Mastodon post ID
|
||||
let lastProcessedPostId = loadLastProcessedPostId();
|
||||
|
||||
let newTimestampId = 0;
|
||||
|
||||
reversed.forEach((item) => {
|
||||
const currentTimestampId = Date.parse(item.published);
|
||||
|
||||
if (currentTimestampId > newTimestampId) {
|
||||
newTimestampId = currentTimestampId;
|
||||
// Function to load the last processed post ID from the file
|
||||
function loadLastProcessedPostId() {
|
||||
try {
|
||||
return fs.readFileSync(lastProcessedPostIdFile, "utf8").trim();
|
||||
} catch (error) {
|
||||
console.error("Error loading last processed post ID:", error);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (currentTimestampId > lastProcessedPostId && lastProcessedPostId != 0) {
|
||||
const text = removeHtmlTags(item.object.content);
|
||||
postToBluesky(text);
|
||||
}
|
||||
});
|
||||
|
||||
if (newTimestampId > 0) {
|
||||
lastProcessedPostId = newTimestampId;
|
||||
saveLastProcessedPostId();
|
||||
}
|
||||
|
||||
// Function to save the last processed post ID to the file
|
||||
function saveLastProcessedPostId() {
|
||||
try {
|
||||
fs.writeFileSync(lastProcessedPostIdFile, `${lastProcessedPostId}`);
|
||||
} catch (error) {
|
||||
console.error("Error saving last processed post ID:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function createBlueskyMessage(text) {
|
||||
const richText = new RichText({ text });
|
||||
await richText.detectFacets(agent);
|
||||
|
||||
return {
|
||||
text: richText.text,
|
||||
facets: richText.facets
|
||||
};
|
||||
}
|
||||
|
||||
async function postToBluesky(textParts) {
|
||||
const blueskyMessage = await createBlueskyMessage(textParts[0]);
|
||||
const rootMessageResponse = await agent.post(blueskyMessage);
|
||||
|
||||
if (textParts.length === 1) return;
|
||||
|
||||
let replyMessageResponse = null
|
||||
for (let index = 1; index < textParts.length; index++) {
|
||||
replyMessageResponse = await agent.post({
|
||||
...(await createBlueskyMessage(textParts[index])),
|
||||
reply: {
|
||||
root: rootMessageResponse,
|
||||
parent: replyMessageResponse ?? rootMessageResponse,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeHtml(input) {
|
||||
const withoutHtml = input.replace(/<[^>]*>/g, "");
|
||||
const decodeQuotes = he.decode(withoutHtml);
|
||||
const addSpace = decodeQuotes.replace(/(https?:\/\/)/g, ' $1');
|
||||
return addSpace;
|
||||
}
|
||||
|
||||
function splitText(text, maxLength) {
|
||||
// Split the text by spaces
|
||||
const words = text.split(" ");
|
||||
|
||||
let result = [];
|
||||
let currentChunk = "";
|
||||
|
||||
for (const word of words) {
|
||||
// Add the current word to the current chunk
|
||||
const potentialChunk = `${currentChunk} ${word}`.trim();
|
||||
|
||||
if (potentialChunk.length <= maxLength) {
|
||||
// If the current chunk is still under max length, add the word
|
||||
currentChunk = potentialChunk;
|
||||
} else {
|
||||
// Otherwise, add the current chunk to the result and start a new chunk
|
||||
result.push(currentChunk);
|
||||
currentChunk = word;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last chunk to the result
|
||||
result.push(currentChunk);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Function to periodically fetch new Mastodon posts
|
||||
async function fetchNewPosts() {
|
||||
const response = await axios.get(
|
||||
`${mastodonInstance}/users/${mastodonUser}/outbox?page=true`
|
||||
);
|
||||
|
||||
const reversed = response.data.orderedItems
|
||||
.filter((item) => item.object.type === "Note")
|
||||
.filter((item) => item.object.inReplyTo === null)
|
||||
.reverse();
|
||||
|
||||
let newTimestampId = 0;
|
||||
|
||||
reversed.forEach((item) => {
|
||||
const currentTimestampId = Date.parse(item.published);
|
||||
|
||||
if (currentTimestampId > newTimestampId) {
|
||||
newTimestampId = currentTimestampId;
|
||||
}
|
||||
|
||||
if (currentTimestampId > lastProcessedPostId && lastProcessedPostId != 0) {
|
||||
try {
|
||||
console.log('📧 posting to BlueSky', currentTimestampId)
|
||||
const textParts = splitText(sanitizeHtml(item.object.content), 300);
|
||||
postToBluesky(textParts);
|
||||
} catch (error) {
|
||||
console.error('🔥 can\'t post to Bluesky', currentTimestampId, error)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (newTimestampId > 0) {
|
||||
lastProcessedPostId = newTimestampId;
|
||||
saveLastProcessedPostId();
|
||||
}
|
||||
}
|
||||
|
||||
fetchNewPosts();
|
||||
// Fetch new posts every 5 minutes (adjust as needed)
|
||||
setInterval(fetchNewPosts, (process.env.INTERVAL_MINUTES ?? 5) * 60 * 1000);
|
||||
}
|
||||
|
||||
fetchNewPosts();
|
||||
// Fetch new posts every 5 minutes (adjust as needed)
|
||||
setInterval(fetchNewPosts, (process.env.INTERVAL_MINUTES ?? 5) * 60 * 1000);
|
||||
main()
|
18072
package-lock.json
generated
18072
package-lock.json
generated
File diff suppressed because it is too large
Load diff
35
package.json
35
package.json
|
@ -1,19 +1,20 @@
|
|||
{
|
||||
"name": "mastodon-to-bluesky",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "Maurice Renck <hello@maurice-renck.de>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.12.2",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"axios": "^1.6.8",
|
||||
"dotenv": "^16.4.5",
|
||||
"mastodon-api": "^1.3.0"
|
||||
}
|
||||
"name": "mastodon-to-bluesky",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"author": "Maurice Renck <hello@maurice-renck.de>",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.13.15",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/git": "^10.0.1",
|
||||
"axios": "^1.7.7",
|
||||
"dotenv": "^16.4.5",
|
||||
"he": "^1.2.0",
|
||||
"mastodon-api": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue