@rmdes/indiekit-syndicator-bluesky 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,110 @@
1
+ import process from "node:process";
2
+ import { IndiekitError } from "@indiekit/error";
3
+ import { Bluesky } from "./lib/bluesky.js";
4
+
5
+ const defaults = {
6
+ handle: "",
7
+ password: process.env.BLUESKY_PASSWORD,
8
+ profileUrl: "https://bsky.app/profile",
9
+ serviceUrl: "https://bsky.social",
10
+ includePermalink: false,
11
+ syndicateExternalLikes: true, // NEW: Enable syndication of external likes
12
+ checked: false,
13
+ };
14
+
15
+ export default class BlueskySyndicator {
16
+ name = "Bluesky syndicator";
17
+
18
+ /**
19
+ * @param {object} [options] - Plug-in options
20
+ * @param {string} [options.profileUrl] - Profile URL
21
+ * @param {string} [options.serviceUrl] - Service URL
22
+ * @param {string} [options.handle] - Handle
23
+ * @param {string} [options.password] - Password
24
+ * @param {boolean} [options.includePermalink] - Include permalink in status
25
+ * @param {boolean} [options.syndicateExternalLikes] - Syndicate likes of external URLs as posts
26
+ * @param {boolean} [options.checked] - Check syndicator in UI
27
+ */
28
+ constructor(options = {}) {
29
+ this.options = { ...defaults, ...options };
30
+ }
31
+
32
+ get #profileUrl() {
33
+ return new URL(this.options.profileUrl).href;
34
+ }
35
+
36
+ get #serviceUrl() {
37
+ return new URL(this.options.serviceUrl).href;
38
+ }
39
+
40
+ get #user() {
41
+ return this.options?.handle
42
+ ? `@${this.options.handle.replace("@", "")}`
43
+ : false;
44
+ }
45
+
46
+ get environment() {
47
+ return ["BLUESKY_PASSWORD"];
48
+ }
49
+
50
+ get info() {
51
+ const userName = this.options?.handle?.replace("@", "");
52
+ const url = new URL(this.options.profileUrl).href + "/" + userName;
53
+
54
+ const info = {
55
+ checked: this.options.checked,
56
+ name: this.#user,
57
+ uid: url,
58
+ service: {
59
+ name: "Bluesky",
60
+ photo: "/assets/@indiekit-syndicator-bluesky/icon.svg",
61
+ url: this.#serviceUrl,
62
+ },
63
+ user: {
64
+ name: this.#user,
65
+ url,
66
+ },
67
+ };
68
+
69
+ if (!this.#user) {
70
+ info.error = "User identifier required";
71
+ }
72
+
73
+ return info;
74
+ }
75
+
76
+ get prompts() {
77
+ return [
78
+ {
79
+ type: "text",
80
+ name: "handle",
81
+ message: "What is your Bluesky handle (without the @)?",
82
+ },
83
+ ];
84
+ }
85
+
86
+ async syndicate(properties, publication) {
87
+ try {
88
+ const bluesky = new Bluesky({
89
+ identifier: this.options?.handle,
90
+ password: this.options?.password,
91
+ profileUrl: this.#profileUrl,
92
+ serviceUrl: this.#serviceUrl,
93
+ includePermalink: this.options.includePermalink,
94
+ syndicateExternalLikes: this.options.syndicateExternalLikes,
95
+ });
96
+
97
+ return await bluesky.post(properties, publication.me);
98
+ } catch (error) {
99
+ throw new IndiekitError(error.message, {
100
+ cause: error,
101
+ plugin: this.name,
102
+ status: error.statusCode,
103
+ });
104
+ }
105
+ }
106
+
107
+ init(Indiekit) {
108
+ Indiekit.addSyndicator(this);
109
+ }
110
+ }
package/lib/bluesky.js ADDED
@@ -0,0 +1,257 @@
1
+ import { AtpAgent } from "@atproto/api";
2
+ import { IndiekitError } from "@indiekit/error";
3
+ import { getCanonicalUrl, isSameOrigin } from "@indiekit/util";
4
+
5
+ import {
6
+ createRichText,
7
+ getPostImage,
8
+ getPostText,
9
+ getLikePostText,
10
+ getPostParts,
11
+ uriToPostUrl,
12
+ } from "./utils.js";
13
+
14
+ export class Bluesky {
15
+ /**
16
+ * @param {object} options - Syndicator options
17
+ * @param {string} options.identifier - User identifier
18
+ * @param {string} options.password - Password
19
+ * @param {string} options.profileUrl - Profile URL
20
+ * @param {string} options.serviceUrl - Service URL
21
+ * @param {boolean} [options.includePermalink] - Include permalink in status
22
+ * @param {boolean} [options.syndicateExternalLikes] - Syndicate likes of external URLs
23
+ */
24
+ constructor(options) {
25
+ this.identifier = options.identifier;
26
+ this.password = options.password;
27
+ this.profileUrl = options.profileUrl;
28
+ this.serviceUrl = options.serviceUrl;
29
+ this.includePermalink = options.includePermalink || false;
30
+ this.syndicateExternalLikes = options.syndicateExternalLikes !== false; // Default true
31
+ }
32
+
33
+ /**
34
+ * Initialise AT Protocol client
35
+ * @access private
36
+ * @returns {Promise<AtpAgent>} AT Protocol agent
37
+ */
38
+ async #client() {
39
+ const { identifier, password, serviceUrl } = this;
40
+ const agent = new AtpAgent({ service: serviceUrl });
41
+ await agent.login({ identifier, password });
42
+ return agent;
43
+ }
44
+
45
+ /**
46
+ * Get a post
47
+ * @param {string} postUrl - URL of post to like
48
+ * @returns {Promise<object>} Bluesky post record
49
+ */
50
+ async getPost(postUrl) {
51
+ const client = await this.#client();
52
+ const postParts = getPostParts(postUrl);
53
+ return await client.getPost({
54
+ repo: postParts.did,
55
+ rkey: postParts.rkey,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * Post a like
61
+ * @param {string} postUrl - URL of post to like
62
+ * @returns {Promise<string>} Bluesky post URL
63
+ */
64
+ async postLike(postUrl) {
65
+ const client = await this.#client();
66
+ const post = await this.getPost(postUrl);
67
+ const like = await client.like(post.uri, post.cid);
68
+ return uriToPostUrl(this.profileUrl, like.uri);
69
+ }
70
+
71
+ /**
72
+ * Post a repost
73
+ * @param {string} postUrl - URL of post to repost
74
+ * @returns {Promise<string>} Bluesky post URL
75
+ */
76
+ async postRepost(postUrl) {
77
+ const client = await this.#client();
78
+ const post = await this.getPost(postUrl);
79
+ const repost = await client.repost(post.uri, post.cid);
80
+ return uriToPostUrl(this.profileUrl, repost.uri);
81
+ }
82
+
83
+ /**
84
+ * Post a quote post
85
+ * @param {string} postUrl - URL of post to quote
86
+ * @param {object} richText - Rich text
87
+ * @param {Array} [images] - Images
88
+ * @returns {Promise<string>} Bluesky post URL
89
+ */
90
+ async postQuotePost(postUrl, richText, images) {
91
+ const client = await this.#client();
92
+ const post = await this.getPost(postUrl);
93
+
94
+ const record = {
95
+ $type: "app.bsky.embed.record",
96
+ record: { uri: post.uri, cid: post.cid },
97
+ };
98
+
99
+ const media = {
100
+ $type: "app.bsky.embed.images",
101
+ images,
102
+ };
103
+
104
+ const recordWithMedia = {
105
+ $type: "app.bsky.embed.recordWithMedia",
106
+ record,
107
+ media,
108
+ };
109
+
110
+ const embed = images?.length > 0 ? recordWithMedia : record;
111
+
112
+ const postData = {
113
+ $type: "app.bsky.feed.post",
114
+ text: richText.text,
115
+ facets: richText.facets,
116
+ createdAt: new Date().toISOString(),
117
+ embed,
118
+ };
119
+
120
+ const quotePost = await client.post(postData);
121
+ return uriToPostUrl(this.profileUrl, quotePost.uri);
122
+ }
123
+
124
+ /**
125
+ * Post a regular post
126
+ * @param {object} richText - Rich text
127
+ * @param {Array} [images] - Images
128
+ * @returns {Promise<string>} Bluesky post URL
129
+ */
130
+ async postPost(richText, images) {
131
+ const client = await this.#client();
132
+
133
+ const postData = {
134
+ $type: "app.bsky.feed.post",
135
+ text: richText.text,
136
+ facets: richText.facets,
137
+ createdAt: new Date().toISOString(),
138
+ ...(images?.length > 0 && {
139
+ embed: {
140
+ $type: "app.bsky.embed.images",
141
+ images,
142
+ },
143
+ }),
144
+ };
145
+
146
+ const post = await client.post(postData);
147
+ return uriToPostUrl(this.profileUrl, post.uri);
148
+ }
149
+
150
+ /**
151
+ * Upload media
152
+ * @param {object} media - JF2 media object
153
+ * @param {string} me - Publication URL
154
+ * @returns {Promise<object>} Blob reference for the uploaded media
155
+ */
156
+ async uploadMedia(media, me) {
157
+ const client = await this.#client();
158
+ const { url } = media;
159
+
160
+ if (typeof url !== "string") {
161
+ return;
162
+ }
163
+
164
+ try {
165
+ const mediaUrl = getCanonicalUrl(url, me);
166
+ const mediaResponse = await fetch(mediaUrl);
167
+
168
+ if (!mediaResponse.ok) {
169
+ throw await IndiekitError.fromFetch(mediaResponse);
170
+ }
171
+
172
+ let blob = await mediaResponse.blob();
173
+ let encoding = mediaResponse.headers.get("Content-Type");
174
+
175
+ if (encoding?.startsWith("image/")) {
176
+ const buffer = Buffer.from(await blob.arrayBuffer());
177
+ const image = await getPostImage(buffer, encoding);
178
+ blob = new Blob([new Uint8Array(image.buffer)], {
179
+ type: image.mimeType,
180
+ });
181
+ encoding = image.mimeType;
182
+ }
183
+
184
+ const response = await client.com.atproto.repo.uploadBlob(blob, {
185
+ encoding,
186
+ });
187
+
188
+ return response.data.blob;
189
+ } catch (error) {
190
+ throw new Error(error.message);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Post to Bluesky
196
+ * @param {object} properties - JF2 properties
197
+ * @param {string} me - Publication URL
198
+ * @returns {Promise<string|boolean>} URL of syndicated status
199
+ */
200
+ async post(properties, me) {
201
+ try {
202
+ const client = await this.#client();
203
+
204
+ // Upload photos
205
+ let images = [];
206
+ if (properties.photo) {
207
+ const photos = properties.photo.slice(0, 4);
208
+ const uploads = photos.map(async (photo) => ({
209
+ alt: photo.alt || "",
210
+ image: await this.uploadMedia(photo, me),
211
+ }));
212
+ images = await Promise.all(uploads);
213
+ }
214
+
215
+ // Handle reposts
216
+ const repostUrl = properties["repost-of"];
217
+ if (repostUrl) {
218
+ if (isSameOrigin(repostUrl, this.profileUrl) && properties.content) {
219
+ const text = getPostText(properties, this.includePermalink);
220
+ const richText = await createRichText(client, text);
221
+ return this.postQuotePost(repostUrl, richText, images);
222
+ }
223
+ if (isSameOrigin(repostUrl, this.profileUrl)) {
224
+ return this.postRepost(repostUrl);
225
+ }
226
+ // Do not syndicate reposts of other URLs
227
+ return;
228
+ }
229
+
230
+ // Handle likes
231
+ const likeOfUrl = properties["like-of"];
232
+ if (likeOfUrl) {
233
+ // Native Bluesky like for Bluesky URLs
234
+ if (isSameOrigin(likeOfUrl, this.profileUrl)) {
235
+ return this.postLike(likeOfUrl);
236
+ }
237
+
238
+ // NEW: Syndicate likes of external URLs as posts
239
+ if (this.syndicateExternalLikes) {
240
+ const text = getLikePostText(properties, likeOfUrl);
241
+ const richText = await createRichText(client, text);
242
+ return this.postPost(richText, images);
243
+ }
244
+
245
+ // Don't syndicate if option is disabled
246
+ return;
247
+ }
248
+
249
+ // Regular post
250
+ const text = getPostText(properties, this.includePermalink);
251
+ const richText = await createRichText(client, text);
252
+ return this.postPost(richText, images);
253
+ } catch (error) {
254
+ throw new Error(error.message);
255
+ }
256
+ }
257
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,162 @@
1
+ import { RichText } from "@atproto/api";
2
+ import { htmlToText } from "html-to-text";
3
+ import sharp from "sharp";
4
+
5
+ const AT_URI = /at:\/\/(?<did>did:[^/]+)\/(?<type>[^/]+)\/(?<rkey>[^/]+)/;
6
+
7
+ /**
8
+ * Convert plain text to rich text
9
+ * @param {import("@atproto/api").Agent} client - AT Protocol agent
10
+ * @param {string} text - Text to convert
11
+ * @returns {Promise<RichText>} Rich text
12
+ */
13
+ export const createRichText = async (client, text) => {
14
+ const rt = new RichText({ text });
15
+ await rt.detectFacets(client);
16
+ return rt;
17
+ };
18
+
19
+ /**
20
+ * Get post parts (UID and CID)
21
+ * @param {string} url - Post URL
22
+ * @returns {object} Parts
23
+ */
24
+ export const getPostParts = (url) => {
25
+ const pathParts = new URL(url).pathname.split("/");
26
+ const did = pathParts[2];
27
+ const rkey = pathParts[4];
28
+ return { did, rkey };
29
+ };
30
+
31
+ /**
32
+ * Convert Bluesky URI to post URL
33
+ * @param {string} profileUrl - Profile URL
34
+ * @param {string} uri - Bluesky URI
35
+ * @returns {string|undefined} Post URL
36
+ */
37
+ export const uriToPostUrl = (profileUrl, uri) => {
38
+ const match = uri.match(AT_URI);
39
+ if (match) {
40
+ let { did, rkey, type } = match.groups;
41
+ type = type.split(".").at(-1);
42
+ return `${profileUrl}/${did}/${type}/${rkey}`;
43
+ }
44
+ };
45
+
46
+ /**
47
+ * Get post text from given JF2 properties
48
+ * @param {object} properties - JF2 properties
49
+ * @param {boolean} [includePermalink] - Include permalink in post
50
+ * @returns {string} Post text
51
+ */
52
+ export const getPostText = (properties, includePermalink) => {
53
+ let text = "";
54
+
55
+ if (properties.name && properties.name !== "") {
56
+ text = `${properties.name} ${properties.url}`;
57
+ } else if (properties.content?.html) {
58
+ text = htmlToStatusText(properties.content.html);
59
+ } else if (properties.content?.text) {
60
+ text = properties.content.text;
61
+ }
62
+
63
+ // Truncate status if longer than 300 characters
64
+ if (text.length > 300) {
65
+ const suffix = includePermalink ? `\n\n${properties.url}` : "";
66
+ const maxLen = 300 - suffix.length - 3;
67
+ text = text.slice(0, maxLen).trim() + "..." + suffix;
68
+ } else if (includePermalink && !text.includes(properties.url)) {
69
+ text = `${text}\n\n${properties.url}`;
70
+ }
71
+
72
+ return text;
73
+ };
74
+
75
+ /**
76
+ * Get post text for a like of an external URL
77
+ * @param {object} properties - JF2 properties
78
+ * @param {string} likedUrl - The URL being liked
79
+ * @returns {string} Post text
80
+ */
81
+ export const getLikePostText = (properties, likedUrl) => {
82
+ let text = "";
83
+
84
+ // Get the content/comment
85
+ if (properties.content?.html) {
86
+ text = htmlToStatusText(properties.content.html);
87
+ } else if (properties.content?.text) {
88
+ text = properties.content.text;
89
+ }
90
+
91
+ // If there's content, append the liked URL
92
+ if (text) {
93
+ // Check if the URL is already in the text
94
+ if (!text.includes(likedUrl)) {
95
+ text = `${text}\n\n❤️ ${likedUrl}`;
96
+ }
97
+ } else {
98
+ // No content, just post the liked URL with a heart
99
+ text = `❤️ ${likedUrl}`;
100
+ }
101
+
102
+ // Truncate if needed (Bluesky limit is 300 chars)
103
+ if (text.length > 300) {
104
+ const suffix = `\n\n❤️ ${likedUrl}`;
105
+ const maxLen = 300 - suffix.length - 3;
106
+ const contentPart = text.replace(suffix, "").slice(0, maxLen).trim();
107
+ text = contentPart + "..." + suffix;
108
+ }
109
+
110
+ return text;
111
+ };
112
+
113
+ /**
114
+ * Constrain image buffer to be under 1MB
115
+ * @param {Buffer} buffer - Image buffer
116
+ * @param {number} maxBytes - Maximum byte length
117
+ * @param {number} [quality] - Image quality
118
+ * @returns {Promise<Buffer>} Compressed image
119
+ */
120
+ export async function constrainImage(buffer, maxBytes, quality = 90) {
121
+ const compressed = await sharp(buffer).jpeg({ quality }).toBuffer();
122
+ if (compressed.byteLength > maxBytes) {
123
+ return constrainImage(buffer, maxBytes, quality - 5);
124
+ }
125
+ return compressed;
126
+ }
127
+
128
+ /**
129
+ * Compress image buffer to be under 1MB for Bluesky
130
+ * @param {Buffer} buffer - Image buffer
131
+ * @param {string} mimeType - Original MIME type
132
+ * @returns {Promise<{buffer: Buffer, mimeType: string}>} Compressed image
133
+ */
134
+ export async function getPostImage(buffer, mimeType) {
135
+ const MAX_SIZE = 1024 * 1024; // 1MB
136
+ if (buffer.length < MAX_SIZE) {
137
+ return { buffer, mimeType };
138
+ }
139
+ const compressed = await constrainImage(buffer, MAX_SIZE);
140
+ return { buffer: compressed, mimeType: "image/jpeg" };
141
+ }
142
+
143
+ /**
144
+ * Convert HTML to plain text, appending last link href if present
145
+ * @param {string} html - HTML
146
+ * @returns {string} Text
147
+ */
148
+ export const htmlToStatusText = (html) => {
149
+ let hrefs = [...html.matchAll(/href="(https?:\/\/.+?)"/g)];
150
+ const lastHref = hrefs.length > 0 ? hrefs.at(-1)[1] : false;
151
+
152
+ const text = htmlToText(html, {
153
+ selectors: [
154
+ { selector: "a", options: { ignoreHref: true } },
155
+ { selector: "img", format: "skip" },
156
+ ],
157
+ wordwrap: false,
158
+ });
159
+
160
+ const statusText = lastHref ? `${text} ${lastHref}` : text;
161
+ return statusText;
162
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@rmdes/indiekit-syndicator-bluesky",
3
+ "version": "1.0.0",
4
+ "description": "Bluesky syndicator for Indiekit with external like support",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": "./index.js",
8
+ "keywords": [
9
+ "indiekit",
10
+ "indiekit-plugin",
11
+ "indieweb",
12
+ "bluesky",
13
+ "syndication"
14
+ ],
15
+ "author": "rmdes",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/rmdes/indiekit-syndicator-bluesky"
20
+ },
21
+ "dependencies": {
22
+ "@atproto/api": "^0.14.0",
23
+ "@indiekit/error": "^1.0.0-beta.25",
24
+ "@indiekit/util": "^1.0.0-beta.25",
25
+ "sharp": "^0.33.0"
26
+ },
27
+ "peerDependencies": {
28
+ "@indiekit/indiekit": "1.x"
29
+ }
30
+ }