/** * @node bluesky * @name Bluesky * @category integration * @version 1.0.0 * @description Post to Bluesky over the AT Protocol, with links, tags and mentions made clickable * @icon at-sign */ const configSchema = { type: 'object', properties: { credentialId: { type: 'string', title: 'Account Credential', description: 'A basic credential whose username is the handle, such as name.bsky.social, and whose password is an app password created under Settings, App Passwords. Never your account password', dynamicOptions: { source: 'credentials', filter: { type: ['basic'] } } }, service: { type: 'string', title: 'Service', description: 'Address of the PDS holding the account. Only change this for a self-hosted server', default: 'https://bsky.social' }, operation: { type: 'string', title: 'Operation', enum: ['post', 'delete'], default: 'post', description: 'post publishes a new entry, delete removes one this account owns' }, text: { type: 'string', title: 'Text', description: 'The post body, up to 300 graphemes. Supports {{variable}} interpolation', format: 'textarea' }, detectFacets: { type: 'boolean', title: 'Detect Links, Tags and Mentions', description: 'Find URLs, #hashtags and @handles in the text and mark them up so Bluesky renders them as links. Each mention costs one extra request to resolve the handle', default: true }, languages: { type: 'string', title: 'Languages', description: 'Comma separated language codes for the post, such as en or en,hu. Leave empty to send none', default: 'en' }, replyTo: { type: 'string', title: 'Reply To', 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: { type: 'string', title: 'Post URI', description: 'The at:// URI of the post to remove, for the delete operation' }, timeout: { type: 'number', title: 'Timeout (ms)', default: 30000 } }, required: ['credentialId'] }; const inputSchema = { type: 'object', properties: { data: { type: 'any' } } }; const outputSchema = { type: 'object', properties: { uri: { type: 'string', description: 'at:// URI of the post that was created or removed' }, cid: { type: 'string', description: 'Content id 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' }, did: { type: 'string', description: 'DID of the posting account' }, imageCount: { type: 'number', description: 'How many images were attached' } } }; // Facet ranges are UTF-8 byte offsets, while JavaScript indexes UTF-16 code // units. Any emoji or accented character ahead of a link shifts the two apart, // and a facet built from string indexes then highlights the wrong span. function utf8Length(text) { let bytes = 0; for (let i = 0; i < text.length; i++) { const code = text.charCodeAt(i); if (code < 0x80) { bytes += 1; } else if (code < 0x800) { bytes += 2; } else if (code >= 0xd800 && code <= 0xdbff) { bytes += 4; i++; } else { bytes += 3; } } return bytes; } function byteRange(text, start, length) { const byteStart = utf8Length(text.substring(0, start)); return { byteStart: byteStart, byteEnd: byteStart + utf8Length(text.substring(start, start + length)) }; } function readCredential(credentialId) { const auth = smartbotic.credentials.get(credentialId); if (!auth || auth.success !== true) { throw new Error('Bluesky: could not read the account credential: ' + ((auth && auth.error) || 'unknown error')); } const value = auth.headerValue || ''; if (value.indexOf('Basic ') !== 0) { throw new Error('Bluesky: the credential must be a basic one, holding the handle as ' + 'its username and an app password as its password'); } const decoded = smartbotic.utils.base64Decode(value.substring(6)); const separator = decoded.indexOf(':'); if (separator < 1) { throw new Error('Bluesky: the credential is malformed, expected a username and a password'); } const identifier = decoded.substring(0, separator); const password = decoded.substring(separator + 1); if (!identifier || !password) { throw new Error('Bluesky: the credential needs both a handle and an app password'); } return { identifier: identifier, password: password }; } function call(options) { const response = smartbotic.http.request(options); let body = response.data; if (typeof body === 'string' && body.length > 0) { try { body = JSON.parse(body); } catch (e) { const snippet = body.substring(0, 200).replace(/\s+/g, ' '); throw new Error('Bluesky: ' + options.what + ' returned HTTP ' + response.status + ' with a body that is not JSON: ' + snippet); } } if (response.status < 200 || response.status >= 300) { const detail = (body && (body.message || body.error)) || ('HTTP ' + response.status); throw new Error('Bluesky: ' + options.what + ' failed: ' + detail); } return body || {}; } function createSession(service, credential, timeout) { return call({ method: 'POST', url: service + '/xrpc/com.atproto.server.createSession', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: credential.identifier, password: credential.password }), timeout: timeout, what: 'sign in' }); } // at://did:plc:xyz/app.bsky.feed.post/3k2a4b function parseAtUri(uri, what) { const match = String(uri || '').match(/^at:\/\/([^/]+)\/([^/]+)\/([^/]+)$/); if (!match) { throw new Error('Bluesky: ' + what + ' must be an at:// URI like ' + 'at://did:plc:example/app.bsky.feed.post/3k2a4b, got "' + uri + '"'); } return { repo: match[1], collection: match[2], rkey: match[3] }; } function findLinks(text) { const found = []; const pattern = /https?:\/\/[^\s]+/g; let match; while ((match = pattern.exec(text)) !== null) { // A URL that ends a sentence swallows the punctuation, and the shortened // link then 404s. Trailing characters that cannot end a real URL go back // to the sentence - but a closing bracket is only punctuation when it // has no opener inside the URL, or every Wikipedia link ending in // "_(disambiguation)" loses its last character. let value = match[0]; while (value.length > 0) { const last = value.charAt(value.length - 1); if ('.,;:!?\'"'.indexOf(last) !== -1) { value = value.substring(0, value.length - 1); continue; } const opener = last === ')' ? '(' : last === ']' ? '[' : last === '}' ? '{' : ''; if (opener !== '') { const opened = value.split(opener).length - 1; const closed = value.split(last).length - 1; if (closed > opened) { value = value.substring(0, value.length - 1); continue; } } break; } if (value.length === 0) { continue; } const range = byteRange(text, match.index, value.length); found.push({ index: range, features: [{ $type: 'app.bsky.richtext.facet#link', uri: value }] }); } return found; } function findTags(text) { const found = []; const pattern = /(^|\s)#([^\s#]+)/g; let match; while ((match = pattern.exec(text)) !== null) { const tag = match[2].replace(/[.,;:!?]+$/, ''); if (tag.length === 0) { continue; } const start = match.index + match[1].length; const range = byteRange(text, start, tag.length + 1); found.push({ index: range, features: [{ $type: 'app.bsky.richtext.facet#tag', tag: tag }] }); } return found; } function findMentions(text, service, timeout) { const found = []; const pattern = /(^|\s)@([a-zA-Z0-9][a-zA-Z0-9-]*(?:\.[a-zA-Z0-9-]+)+)/g; let match; while ((match = pattern.exec(text)) !== null) { const handle = match[2].replace(/[.,;:!?]+$/, ''); if (handle.indexOf('.') === -1) { continue; } let resolved; try { resolved = call({ method: 'GET', url: service + '/xrpc/com.atproto.identity.resolveHandle?handle=' + encodeURIComponent(handle), timeout: timeout, what: 'resolving @' + handle }); } catch (e) { // An unknown handle is ordinary text, not a failure. Posting it as // plain text is what the author sees in the composer anyway. smartbotic.log.info('Bluesky: @' + handle + ' did not resolve, leaving it as text'); continue; } if (!resolved || !resolved.did) { continue; } const start = match.index + match[1].length; const range = byteRange(text, start, handle.length + 1); found.push({ index: range, features: [{ $type: 'app.bsky.richtext.facet#mention', did: resolved.did }] }); } 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) { const parent = parseAtUri(replyTo, 'Reply To'); const record = call({ method: 'GET', url: service + '/xrpc/com.atproto.repo.getRecord?repo=' + encodeURIComponent(parent.repo) + '&collection=' + encodeURIComponent(parent.collection) + '&rkey=' + encodeURIComponent(parent.rkey), headers: { 'Authorization': 'Bearer ' + token }, timeout: timeout, what: 'reading the post being replied to' }); if (!record.uri || !record.cid) { throw new Error('Bluesky: the post at ' + replyTo + ' could not be read'); } const parentRef = { uri: record.uri, cid: record.cid }; // Replying to a reply must point at the thread root, not at the post being // answered, or the reply hangs outside the thread. const existingRoot = record.value && record.value.reply && record.value.reply.root; const root = existingRoot && existingRoot.uri && existingRoot.cid ? { uri: existingRoot.uri, cid: existingRoot.cid } : parentRef; return { root: root, parent: parentRef }; } async function execute(config, input, context) { const operation = config.operation || 'post'; const service = (config.service || 'https://bsky.social').replace(/\/+$/, ''); const timeout = Number(config.timeout) || 30000; if (!config.credentialId) { throw new Error('Bluesky: an account credential is required'); } const credential = readCredential(config.credentialId); const session = createSession(service, credential, timeout); if (!session.accessJwt || !session.did) { throw new Error('Bluesky: sign in returned no session for ' + credential.identifier); } const token = session.accessJwt; if (operation === 'delete') { const target = parseAtUri(config.uri, 'Post URI'); if (target.repo !== session.did && target.repo !== session.handle) { throw new Error('Bluesky: ' + config.uri + ' does not belong to ' + session.handle + ', and only your own posts can be deleted'); } call({ method: 'POST', url: service + '/xrpc/com.atproto.repo.deleteRecord', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: session.did, collection: target.collection, rkey: target.rkey }), timeout: timeout, what: 'deleting the post' }); smartbotic.log.info('Bluesky: deleted ' + config.uri); return { uri: config.uri, handle: session.handle, did: session.did }; } const text = config.text; if (typeof text !== 'string' || text.length === 0) { throw new Error('Bluesky: post text is required'); } const record = { $type: 'app.bsky.feed.post', text: text, createdAt: new Date().toISOString() }; const languages = String(config.languages || '') .split(',') .map(function (code) { return code.trim(); }) .filter(function (code) { return code.length > 0; }); if (languages.length > 0) { record.langs = languages; } if (config.detectFacets !== false) { const facets = findLinks(text) .concat(findTags(text)) .concat(findMentions(text, service, timeout)); if (facets.length > 0) { facets.sort(function (left, right) { return left.index.byteStart - right.index.byteStart; }); record.facets = facets; } } if (config.replyTo) { 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({ method: 'POST', url: service + '/xrpc/com.atproto.repo.createRecord', headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }, body: JSON.stringify({ repo: session.did, collection: 'app.bsky.feed.post', record: record }), timeout: timeout, what: 'creating the post' }); if (!created.uri) { throw new Error('Bluesky: the post was accepted but no URI came back'); } const rkey = created.uri.substring(created.uri.lastIndexOf('/') + 1); const facetCount = record.facets ? record.facets.length : 0; const imageCount = record.embed ? record.embed.images.length : 0; smartbotic.log.info('Bluesky: posted as ' + session.handle + ' with ' + facetCount + ' facets and ' + imageCount + ' images'); return { uri: created.uri, cid: created.cid || '', url: 'https://bsky.app/profile/' + session.handle + '/post/' + rkey, handle: session.handle, did: session.did, imageCount: imageCount }; } module.exports = { configSchema, inputSchema, outputSchema, execute };