diff --git a/helpers/mock-agent/syndicator-mastodon.js b/helpers/mock-agent/syndicator-mastodon.js index 9e71ee2f7..0cb1d7f4d 100644 --- a/helpers/mock-agent/syndicator-mastodon.js +++ b/helpers/mock-agent/syndicator-mastodon.js @@ -46,6 +46,48 @@ export function mockClient() { .intercept({ path: `/api/v1/statuses/404/reblog`, method: "POST" }) .reply(404, { error: "Record not found" }, responseOptions); + // Search for a status on another server (found) + agent + .get(instanceOrigin) + .intercept({ + path: (path) => + path.startsWith("/api/v2/search?") && + path.includes(encodeURIComponent("https://deadbird.example/")), + }) + .reply( + 200, + { accounts: [], hashtags: [], statuses: [{ id: "111" }] }, + responseOptions, + ) + .persist(); + + // Search for a status on another server (not found) + agent + .get(instanceOrigin) + .intercept({ + path: (path) => + path.startsWith("/api/v2/search?") && + path.includes(encodeURIComponent("https://unknown.example/")), + }) + .reply(200, { accounts: [], hashtags: [], statuses: [] }, responseOptions) + .persist(); + + // Post status in reply to a status on another server + agent + .get(instanceOrigin) + .intercept({ + path: `/api/v1/statuses`, + headers: { authorization: "Bearer token" }, + method: "POST", + body: (body) => body.includes('"in_reply_to_id":"111"'), + }) + .reply( + 200, + { id: "222", url: `https://mastodon.example/@username/reply` }, + responseOptions, + ) + .persist(); + // Post status agent .get(instanceOrigin) diff --git a/packages/syndicator-mastodon/lib/mastodon.js b/packages/syndicator-mastodon/lib/mastodon.js index 3ba5460c4..82892596f 100644 --- a/packages/syndicator-mastodon/lib/mastodon.js +++ b/packages/syndicator-mastodon/lib/mastodon.js @@ -33,6 +33,27 @@ export class Mastodon { }); } + /** + * Resolve the URL of a status on another server to a status ID on this one + * + * Searching with `resolve` asks the server to fetch the status over + * ActivityPub if it hasn’t seen it before. + * @param {string} statusUrl - URL of status on another server + * @returns {Promise} Status ID, if resolved + * @see {@link https://docs.joinmastodon.org/methods/search/} + */ + async resolveRemoteStatus(statusUrl) { + const { v2 } = this.#client(); + const { statuses } = await v2.search.list({ + q: statusUrl, + type: "statuses", + resolve: true, + limit: 1, + }); + + return statuses[0]?.id; + } + /** * Post a favourite * @param {string} statusUrl - URL of status to favourite @@ -166,6 +187,15 @@ export class Mastodon { serverUrl: this.serverUrl, }); + // Thread reply to a status on another server, if it can be resolved + const inReplyTo = properties["in-reply-to"]; + if (status && inReplyTo && !isSameOrigin(inReplyTo, this.serverUrl)) { + const statusId = await this.resolveRemoteStatus(inReplyTo); + if (statusId) { + status.inReplyToId = statusId; + } + } + if (status) { return this.postStatus(status); } diff --git a/packages/syndicator-mastodon/lib/utils.js b/packages/syndicator-mastodon/lib/utils.js index c593b0f0e..1a7a4a8d1 100644 --- a/packages/syndicator-mastodon/lib/utils.js +++ b/packages/syndicator-mastodon/lib/utils.js @@ -1,6 +1,5 @@ import path from "node:path"; -import { IndiekitError } from "@indiekit/error"; import brevity from "brevity"; import { htmlToText } from "html-to-text"; @@ -79,18 +78,15 @@ export const createStatus = (properties, options = {}) => { parameters.mediaIds = mediaIds; } - // If post is in reply to a status, add respective parameter + // If post is in reply to a status on this server, add respective parameter. + // Replies to statuses on other servers are resolved before posting. if (properties["in-reply-to"]) { const inReplyTo = properties["in-reply-to"]; const inReplyToHostname = new URL(inReplyTo).hostname; const serverHostname = new URL(serverUrl).hostname; if (inReplyToHostname === serverHostname) { - // Reply to status - const statusId = getStatusIdFromUrl(inReplyTo); - parameters.inReplyToId = statusId; - } else { - throw IndiekitError.badRequest("Not a reply to a URL at this target"); + parameters.inReplyToId = getStatusIdFromUrl(inReplyTo); } } diff --git a/packages/syndicator-mastodon/test/unit/mastodon.js b/packages/syndicator-mastodon/test/unit/mastodon.js index 88018d9f4..3083bc042 100644 --- a/packages/syndicator-mastodon/test/unit/mastodon.js +++ b/packages/syndicator-mastodon/test/unit/mastodon.js @@ -145,6 +145,34 @@ describe("syndicator-mastodon/lib/mastodon", () => { ); }); + it("Posts a threaded reply to a status on another server", async () => { + const result = await mastodon.post( + { + content: { + html: "

I ate a cheese sandwich too!

", + }, + "in-reply-to": "https://deadbird.example/username/1234567890987654321", + }, + me, + ); + + assert.equal(result, "https://mastodon.example/@username/reply"); + }); + + it("Posts an unthreaded reply if status on another server can’t be resolved", async () => { + const result = await mastodon.post( + { + content: { + html: "

I ate a cheese sandwich too!

", + }, + "in-reply-to": "https://unknown.example/post", + }, + me, + ); + + assert.equal(result, statusUrl); + }); + it("Posts a status with photo to Mastodon", async () => { const result = await mastodon.post( { diff --git a/packages/syndicator-mastodon/test/unit/utils.js b/packages/syndicator-mastodon/test/unit/utils.js index 264db3a19..5d7c20e1f 100644 --- a/packages/syndicator-mastodon/test/unit/utils.js +++ b/packages/syndicator-mastodon/test/unit/utils.js @@ -101,18 +101,16 @@ describe("syndicator-mastodon/lib/utils", () => { assert.equal(result.inReplyToId, "1234567890987654321"); }); - it("Throws creating a status if post is off-service reply", () => { - assert.throws( - () => { - createStatus(JSON.parse(getFixture("jf2/reply-off-service.jf2")), { - serverUrl: "https://mastodon.example", - }); - }, + it("Creates a status for a reply to a URL on another server", () => { + const result = createStatus( + JSON.parse(getFixture("jf2/reply-off-service.jf2")), { - name: "BadRequestError", - message: "Not a reply to a URL at this target", + serverUrl: "https://mastodon.example", }, ); + + assert.equal(result.status, "I ate a cheese sandwich too!"); + assert.equal(result.inReplyToId, undefined); }); it("Creates a status with a photo", () => {