Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions helpers/mock-agent/syndicator-mastodon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions packages/syndicator-mastodon/lib/mastodon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string|undefined>} 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
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 3 additions & 7 deletions packages/syndicator-mastodon/lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import path from "node:path";

import { IndiekitError } from "@indiekit/error";
import brevity from "brevity";
import { htmlToText } from "html-to-text";

Expand Down Expand Up @@ -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);
}
}

Expand Down
28 changes: 28 additions & 0 deletions packages/syndicator-mastodon/test/unit/mastodon.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>I ate a cheese sandwich too!</p>",
},
"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: "<p>I ate a cheese sandwich too!</p>",
},
"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(
{
Expand Down
16 changes: 7 additions & 9 deletions packages/syndicator-mastodon/test/unit/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading