Compare commits

..

No commits in common. "main" and "v1.1.1" have entirely different histories.
main ... v1.1.1

9 changed files with 9284 additions and 9273 deletions

View file

@ -3,4 +3,3 @@ MASTODON_USER="username"
BLUESKY_ENDPOINT="https://bsky.social" BLUESKY_ENDPOINT="https://bsky.social"
BLUESKY_HANDLE="USERNAME.bsky.social" BLUESKY_HANDLE="USERNAME.bsky.social"
BLUESKY_PASSWORD="PASSWORD" BLUESKY_PASSWORD="PASSWORD"
INTERVAL_MINUTES=5

83
.github/workflows/main.yml vendored Normal file
View file

@ -0,0 +1,83 @@
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 Normal file
View file

@ -0,0 +1,44 @@
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

View file

@ -1,10 +1,3 @@
# [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) ## [1.1.1](https://github.com/mauricerenck/mastodon-to-bluesky/compare/v1.1.0...v1.1.1) (2024-04-24)

View file

@ -1,56 +1,3 @@
# 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: A Node.js script for crossposting from mastodon to bluesky
- Upgraded packages due to issues with conversion to Bluesky.
- Updated docker-compose to build the Docker image.
#### Crosspost from Mastodon to Bluesky
![GitHub release](https://img.shields.io/github/release/mauricerenck/mastodon-to-bluesky.svg?maxAge=1800) ![License](https://img.shields.io/github/license/mashape/apistatus.svg)
---
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 For more details see: https://maurice-renck.de/hub/tooling/crosspost-from-mastodon-to-bluesky

View file

@ -1,12 +1,19 @@
version: '3' version: '3'
services: services:
app: app:
build: . image: host/mastodon-to-bluesky:latest
container_name: mastodon-to-bluesky container_name: mastodon-to-bluesky
env_file: '.env' 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
volumes: volumes:
- mastodon-to-bluesky:/usr/src/app/data - mastodon-to-bluesky:/usr/src/app/data
restart: unless-stopped restart: unless-stopped
volumes: volumes:
mastodon-to-bluesky: mastodon-to-bluesky:
external: true

85
main.js
View file

@ -1,22 +1,15 @@
require("dotenv").config(); require("dotenv").config();
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { RichText, AtpAgent } = require("@atproto/api"); const { RichText, BskyAgent } = require("@atproto/api");
const axios = require("axios"); const axios = require("axios");
const he = require('he');
// Mastodon credentials // Mastodon credentials
const mastodonInstance = process.env.MASTODON_INSTANCE; const mastodonInstance = process.env.MASTODON_INSTANCE;
const mastodonUser = process.env.MASTODON_USER; const mastodonUser = process.env.MASTODON_USER;
async function main() {
// Bluesky agent // Bluesky agent
const agent = new AtpAgent({ service: process.env.BLUESKY_ENDPOINT }); const agent = new BskyAgent({ 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");
// File to store the last processed Mastodon post ID // File to store the last processed Mastodon post ID
const lastProcessedPostIdFile = path.join( const lastProcessedPostIdFile = path.join(
@ -47,66 +40,31 @@ async function main() {
} }
} }
async function createBlueskyMessage(text) { async function postToBluesky(text) {
await agent.login({
identifier: process.env.BLUESKY_HANDLE,
password: process.env.BLUESKY_PASSWORD,
});
const richText = new RichText({ text }); const richText = new RichText({ text });
await richText.detectFacets(agent); await richText.detectFacets(agent);
await agent.post({
return {
text: richText.text, text: richText.text,
facets: richText.facets 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 removeHtmlTags(input) {
return input.replace(/<[^>]*>/g, "");
} }
function sanitizeHtml(input) { function truncate(text, timestampId) {
const withoutHtml = input.replace(/<[^>]*>/g, ""); if (text.length > 300) {
const decodeQuotes = he.decode(withoutHtml); console.warn(`✂ post '${timestampId}' was truncated`)
const addSpace = decodeQuotes.replace(/(https?:\/\/)/g, ' $1'); return text.substring(0, 299) + '…'
return addSpace;
} }
function splitText(text, maxLength) { return text
// 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 // Function to periodically fetch new Mastodon posts
@ -132,8 +90,8 @@ async function main() {
if (currentTimestampId > lastProcessedPostId && lastProcessedPostId != 0) { if (currentTimestampId > lastProcessedPostId && lastProcessedPostId != 0) {
try { try {
console.log('📧 posting to BlueSky', currentTimestampId) console.log('📧 posting to BlueSky', currentTimestampId)
const textParts = splitText(sanitizeHtml(item.object.content), 300); const text = truncate(removeHtmlTags(item.object.content), currentTimestampId);
postToBluesky(textParts); postToBluesky(text);
} catch (error) { } catch (error) {
console.error('🔥 can\'t post to Bluesky', currentTimestampId, error) console.error('🔥 can\'t post to Bluesky', currentTimestampId, error)
} }
@ -149,6 +107,3 @@ async function main() {
fetchNewPosts(); fetchNewPosts();
// Fetch new posts every 5 minutes (adjust as needed) // Fetch new posts every 5 minutes (adjust as needed)
setInterval(fetchNewPosts, (process.env.INTERVAL_MINUTES ?? 5) * 60 * 1000); setInterval(fetchNewPosts, (process.env.INTERVAL_MINUTES ?? 5) * 60 * 1000);
}
main()

74
package-lock.json generated
View file

@ -9,51 +9,48 @@
"version": "1.0.0", "version": "1.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@atproto/api": "^0.13.15", "@atproto/api": "^0.12.2",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"axios": "^1.7.7", "axios": "^1.6.8",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"he": "^1.2.0",
"mastodon-api": "^1.3.0" "mastodon-api": "^1.3.0"
} }
}, },
"node_modules/@atproto/api": { "node_modules/@atproto/api": {
"version": "0.13.15", "version": "0.12.2",
"resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.13.15.tgz", "resolved": "https://registry.npmjs.org/@atproto/api/-/api-0.12.2.tgz",
"integrity": "sha512-zC8KH+Spcr2HE6vD4hddP5rZpWrGUTWvL8hQmUxa/sAnlsjoFyv/Oja8ZHGXoDsAl6ie5Gd77cPNxaxWH/yIBQ==", "integrity": "sha512-UVzCiDZH2j0wrr/O8nb1edD5cYLVqB5iujueXUCbHS3rAwIxgmyLtA3Hzm2QYsGPo/+xsIg1fNvpq9rNT6KWUA==",
"dependencies": { "dependencies": {
"@atproto/common-web": "^0.3.1", "@atproto/common-web": "^0.3.0",
"@atproto/lexicon": "^0.4.2", "@atproto/lexicon": "^0.4.0",
"@atproto/syntax": "^0.3.0", "@atproto/syntax": "^0.3.0",
"@atproto/xrpc": "^0.6.3", "@atproto/xrpc": "^0.5.0",
"await-lock": "^2.2.2",
"multiformats": "^9.9.0", "multiformats": "^9.9.0",
"tlds": "^1.234.0", "tlds": "^1.234.0"
"zod": "^3.23.8"
} }
}, },
"node_modules/@atproto/common-web": { "node_modules/@atproto/common-web": {
"version": "0.3.1", "version": "0.3.0",
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.3.0.tgz",
"integrity": "sha512-N7wiTnus5vAr+lT//0y8m/FaHHLJ9LpGuEwkwDAeV3LCiPif4m/FS8x/QOYrx1PdZQwKso95RAPzCGWQBH5j6Q==", "integrity": "sha512-67VnV6JJyX+ZWyjV7xFQMypAgDmjVaR9ZCuU/QW+mqlqI7fex2uL4Fv+7/jHadgzhuJHVd6OHOvNn0wR5WZYtA==",
"dependencies": { "dependencies": {
"graphemer": "^1.4.0", "graphemer": "^1.4.0",
"multiformats": "^9.9.0", "multiformats": "^9.9.0",
"uint8arrays": "3.0.0", "uint8arrays": "3.0.0",
"zod": "^3.23.8" "zod": "^3.21.4"
} }
}, },
"node_modules/@atproto/lexicon": { "node_modules/@atproto/lexicon": {
"version": "0.4.2", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.2.tgz", "resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.4.0.tgz",
"integrity": "sha512-CXoOkhcdF3XVUnR2oNgCs2ljWfo/8zUjxL5RIhJW/UNLp/FSl+KpF8Jm5fbk8Y/XXVPGRAsv9OYfxyU/14N/pw==", "integrity": "sha512-RvCBKdSI4M8qWm5uTNz1z3R2yIvIhmOsMuleOj8YR6BwRD+QbtUBy3l+xQ7iXf4M5fdfJFxaUNa6Ty0iRwdKqQ==",
"dependencies": { "dependencies": {
"@atproto/common-web": "^0.3.1", "@atproto/common-web": "^0.3.0",
"@atproto/syntax": "^0.3.0", "@atproto/syntax": "^0.3.0",
"iso-datestring-validator": "^2.2.2", "iso-datestring-validator": "^2.2.2",
"multiformats": "^9.9.0", "multiformats": "^9.9.0",
"zod": "^3.23.8" "zod": "^3.21.4"
} }
}, },
"node_modules/@atproto/syntax": { "node_modules/@atproto/syntax": {
@ -62,12 +59,12 @@
"integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA==" "integrity": "sha512-Weq0ZBxffGHDXHl9U7BQc2BFJi/e23AL+k+i5+D9hUq/bzT4yjGsrCejkjq0xt82xXDjmhhvQSZ0LqxyZ5woxA=="
}, },
"node_modules/@atproto/xrpc": { "node_modules/@atproto/xrpc": {
"version": "0.6.3", "version": "0.5.0",
"resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.6.3.tgz", "resolved": "https://registry.npmjs.org/@atproto/xrpc/-/xrpc-0.5.0.tgz",
"integrity": "sha512-S3tRvOdA9amPkKLll3rc4vphlDitLrkN5TwWh5Tu/jzk7mnobVVE3akYgICV9XCNHKjWM+IAPxFFI2qi+VW6nQ==", "integrity": "sha512-swu+wyOLvYW4l3n+VAuJbHcPcES+tin2Lsrp8Bw5aIXIICiuFn1YMFlwK9JwVUzTH21Py1s1nHEjr4CJeElJog==",
"dependencies": { "dependencies": {
"@atproto/lexicon": "^0.4.2", "@atproto/lexicon": "^0.4.0",
"zod": "^3.23.8" "zod": "^3.21.4"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -1141,11 +1138,6 @@
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
}, },
"node_modules/await-lock": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
},
"node_modules/aws-sign2": { "node_modules/aws-sign2": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
@ -1160,9 +1152,9 @@
"integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg=="
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.7.7", "version": "1.6.8",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.6", "follow-redirects": "^1.15.6",
"form-data": "^4.0.0", "form-data": "^4.0.0",
@ -3098,14 +3090,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"bin": {
"he": "bin/he"
}
},
"node_modules/highlight.js": { "node_modules/highlight.js": {
"version": "10.7.3", "version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
@ -9041,9 +9025,9 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.23.8", "version": "3.22.4",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View file

@ -9,12 +9,11 @@
"author": "Maurice Renck <hello@maurice-renck.de>", "author": "Maurice Renck <hello@maurice-renck.de>",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@atproto/api": "^0.13.15", "@atproto/api": "^0.12.2",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"axios": "^1.7.7", "axios": "^1.6.8",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"he": "^1.2.0",
"mastodon-api": "^1.3.0" "mastodon-api": "^1.3.0"
} }
} }