|
@@ -55,6 +55,26 @@ const configSchema = {
|
|
|
title: 'Reply To',
|
|
title: 'Reply To',
|
|
|
description: 'An at:// URI of a post to reply to. The node looks up its thread root itself'
|
|
description: 'An at:// URI of a post to reply to. The node looks up its thread root itself'
|
|
|
},
|
|
},
|
|
|
|
|
+ images: {
|
|
|
|
|
+ type: 'array',
|
|
|
|
|
+ title: 'Images',
|
|
|
|
|
+ description: 'Up to four images to attach. Each is uploaded from the file store before the post is created',
|
|
|
|
|
+ items: {
|
|
|
|
|
+ type: 'object',
|
|
|
|
|
+ properties: {
|
|
|
|
|
+ fileId: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'Stored File ID',
|
|
|
|
|
+ description: 'Id of the image in the file store'
|
|
|
|
|
+ },
|
|
|
|
|
+ alt: {
|
|
|
|
|
+ type: 'string',
|
|
|
|
|
+ title: 'Alt Text',
|
|
|
|
|
+ description: 'Description of the image for people using a screen reader. Bluesky shows an ALT badge when this is set'
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
uri: {
|
|
uri: {
|
|
|
type: 'string',
|
|
type: 'string',
|
|
|
title: 'Post URI',
|
|
title: 'Post URI',
|
|
@@ -83,7 +103,8 @@ const outputSchema = {
|
|
|
cid: { type: 'string', description: 'Content id of the created post' },
|
|
cid: { type: 'string', description: 'Content id of the created post' },
|
|
|
url: { type: 'string', description: 'Browsable https://bsky.app address of the created post' },
|
|
url: { type: 'string', description: 'Browsable https://bsky.app address of the created post' },
|
|
|
handle: { type: 'string', description: 'Handle the post was made as' },
|
|
handle: { type: 'string', description: 'Handle the post was made as' },
|
|
|
- did: { type: 'string', description: 'DID of the posting account' }
|
|
|
|
|
|
|
+ did: { type: 'string', description: 'DID of the posting account' },
|
|
|
|
|
+ imageCount: { type: 'number', description: 'How many images were attached' }
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
@@ -289,6 +310,72 @@ function findMentions(text, service, timeout) {
|
|
|
return found;
|
|
return found;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// Bluesky rejects anything over a megabyte on the blob upload, and the message
|
|
|
|
|
+// it returns names a byte count rather than the picture, so the check happens
|
|
|
|
|
+// here where the file is still identifiable.
|
|
|
|
|
+const MAX_IMAGE_BYTES = 1000000;
|
|
|
|
|
+const MAX_IMAGES = 4;
|
|
|
|
|
+
|
|
|
|
|
+function uploadImages(service, token, images, timeout) {
|
|
|
|
|
+ if (images.length > MAX_IMAGES) {
|
|
|
|
|
+ throw new Error('Bluesky: a post takes at most ' + MAX_IMAGES + ' images, got ' + images.length);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const uploaded = [];
|
|
|
|
|
+
|
|
|
|
|
+ for (const entry of images) {
|
|
|
|
|
+ const fileId = entry && entry.fileId;
|
|
|
|
|
+ if (!fileId) {
|
|
|
|
|
+ throw new Error('Bluesky: an image entry has no stored file id');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const info = smartbotic.storage.getFileInfo(fileId);
|
|
|
|
|
+ const file = smartbotic.storage.downloadFile(fileId);
|
|
|
|
|
+ if (!file || file.success !== true || !file.data) {
|
|
|
|
|
+ throw new Error('Bluesky: stored file ' + fileId + ' could not be read');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const mimeType = (info && info.mimeType) || 'image/jpeg';
|
|
|
|
|
+ if (mimeType.indexOf('image/') !== 0) {
|
|
|
|
|
+ throw new Error('Bluesky: stored file ' + fileId + ' is ' + mimeType +
|
|
|
|
|
+ ', which is not an image');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // file.data is base64, so its decoded length is what the server sees.
|
|
|
|
|
+ const padding = (file.data.match(/=+$/) || [''])[0].length;
|
|
|
|
|
+ const size = Math.floor(file.data.length / 4) * 3 - padding;
|
|
|
|
|
+ if (size > MAX_IMAGE_BYTES) {
|
|
|
|
|
+ throw new Error('Bluesky: image ' + ((info && info.name) || fileId) + ' is ' +
|
|
|
|
|
+ Math.round(size / 1024) + ' KB, over the ' +
|
|
|
|
|
+ Math.round(MAX_IMAGE_BYTES / 1024) + ' KB limit. Resize it before posting');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const blob = call({
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ url: service + '/xrpc/com.atproto.repo.uploadBlob',
|
|
|
|
|
+ headers: {
|
|
|
|
|
+ 'Authorization': 'Bearer ' + token,
|
|
|
|
|
+ 'Content-Type': mimeType
|
|
|
|
|
+ },
|
|
|
|
|
+ bodyBase64: file.data,
|
|
|
|
|
+ timeout: timeout,
|
|
|
|
|
+ what: 'uploading ' + ((info && info.name) || fileId)
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!blob || !blob.blob) {
|
|
|
|
|
+ throw new Error('Bluesky: the upload of ' + ((info && info.name) || fileId) +
|
|
|
|
|
+ ' returned no blob reference');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ uploaded.push({
|
|
|
|
|
+ alt: (entry && entry.alt) || '',
|
|
|
|
|
+ image: blob.blob
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return uploaded;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
function buildReply(service, token, replyTo, timeout) {
|
|
function buildReply(service, token, replyTo, timeout) {
|
|
|
const parent = parseAtUri(replyTo, 'Reply To');
|
|
const parent = parseAtUri(replyTo, 'Reply To');
|
|
|
const record = call({
|
|
const record = call({
|
|
@@ -402,6 +489,14 @@ async function execute(config, input, context) {
|
|
|
record.reply = buildReply(service, token, config.replyTo, timeout);
|
|
record.reply = buildReply(service, token, config.replyTo, timeout);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ const images = Array.isArray(config.images) ? config.images : [];
|
|
|
|
|
+ if (images.length > 0) {
|
|
|
|
|
+ record.embed = {
|
|
|
|
|
+ $type: 'app.bsky.embed.images',
|
|
|
|
|
+ images: uploadImages(service, token, images, timeout)
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const created = call({
|
|
const created = call({
|
|
|
method: 'POST',
|
|
method: 'POST',
|
|
|
url: service + '/xrpc/com.atproto.repo.createRecord',
|
|
url: service + '/xrpc/com.atproto.repo.createRecord',
|
|
@@ -424,14 +519,17 @@ async function execute(config, input, context) {
|
|
|
|
|
|
|
|
const rkey = created.uri.substring(created.uri.lastIndexOf('/') + 1);
|
|
const rkey = created.uri.substring(created.uri.lastIndexOf('/') + 1);
|
|
|
const facetCount = record.facets ? record.facets.length : 0;
|
|
const facetCount = record.facets ? record.facets.length : 0;
|
|
|
- smartbotic.log.info('Bluesky: posted as ' + session.handle + ' with ' + facetCount + ' facets');
|
|
|
|
|
|
|
+ const imageCount = record.embed ? record.embed.images.length : 0;
|
|
|
|
|
+ smartbotic.log.info('Bluesky: posted as ' + session.handle + ' with ' + facetCount +
|
|
|
|
|
+ ' facets and ' + imageCount + ' images');
|
|
|
|
|
|
|
|
return {
|
|
return {
|
|
|
uri: created.uri,
|
|
uri: created.uri,
|
|
|
cid: created.cid || '',
|
|
cid: created.cid || '',
|
|
|
url: 'https://bsky.app/profile/' + session.handle + '/post/' + rkey,
|
|
url: 'https://bsky.app/profile/' + session.handle + '/post/' + rkey,
|
|
|
handle: session.handle,
|
|
handle: session.handle,
|
|
|
- did: session.did
|
|
|
|
|
|
|
+ did: session.did,
|
|
|
|
|
+ imageCount: imageCount
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|