ソースを参照

feat: a Bluesky node, posting over the AT Protocol

Signs in with an app password, then creates or deletes a post. Built on
http and credentials like the other integration nodes, with no new engine
support needed.

The part that is not just an HTTP call is rich text. Bluesky does not parse
a post body - a URL is inert text unless the record carries a facet marking
its span, and those spans are UTF-8 byte offsets while JavaScript indexes
UTF-16. One emoji ahead of a link shifts the two apart and the link
highlights the wrong characters, so offsets are computed over UTF-8 lengths
and were checked against TextEncoder.

Links, #tags and @mentions are detected. Trailing sentence punctuation is
trimmed off a URL, but a closing bracket is only trimmed when it has no
opener inside the URL, so a Wikipedia link ending in _(disambiguation)
survives while (see https://example.com) does not eat the paren. A mention
that does not resolve stays plain text rather than failing the post, which
is what the composer does too.

Replies look up their thread root rather than pointing at the post being
answered, or the reply hangs outside the thread.
fszontagh 1 ヶ月 前
親
コミット
d4743dbc77
2 ファイル変更、456 行追加、0 行削除
  1. 438 0
      nodes/integration/bluesky.js
  2. 18 0
      tests/nodes/bluesky-errors.json

+ 438 - 0
nodes/integration/bluesky.js

@@ -0,0 +1,438 @@
+/**
+ * @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'
+        },
+        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' }
+    }
+};
+
+// 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;
+}
+
+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 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;
+    smartbotic.log.info('Bluesky: posted as ' + session.handle + ' with ' + facetCount + ' facets');
+
+    return {
+        uri: created.uri,
+        cid: created.cid || '',
+        url: 'https://bsky.app/profile/' + session.handle + '/post/' + rkey,
+        handle: session.handle,
+        did: session.did
+    };
+}
+
+module.exports = { configSchema, inputSchema, outputSchema, execute };

+ 18 - 0
tests/nodes/bluesky-errors.json

@@ -0,0 +1,18 @@
+{
+  "name": "verify-bluesky-no-credential",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Post", "type": "bluesky", "position": {"x": 0, "y": 100},
+     "config": {"operation": "post", "text": "hello"}},
+    {"id": "n3", "name": "After", "type": "code", "position": {"x": 0, "y": 200},
+     "config": {"code": "return { marker: 'should not run' };"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n2": {"status": "failed", "errorContains": "an account credential is required"}
+  },
+  "expectMissing": ["n3"]
+}