Skip to content

Commit

Permalink
Added initial reply action
Browse files Browse the repository at this point in the history
ref https://linear.app/tryghost/issue/AP-306

We force a mention for the author of the object being replied to in
order to make sure they get a notification in mastodon, and we also
make sure that the activity is sent directly to the author, in case
they are not one of our followers.

The endpoint responds with the created activity so that the client can
update its local cache with the new object.
  • Loading branch information
allouis committed Sep 19, 2024
1 parent a515df1 commit 1d9234a
Show file tree
Hide file tree
Showing 2 changed files with 108 additions and 1 deletion.
4 changes: 3 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ import {
inboxHandler,
postPublishedWebhook,
siteChangedWebhook,
getActivities
getActivities,
replyAction,
} from './handlers';

if (process.env.SENTRY_DSN) {
Expand Down Expand Up @@ -291,6 +292,7 @@ app.post('/.ghost/activitypub/webhooks/site/changed', siteChangedWebhook);
app.post('/.ghost/activitypub/actions/follow/:handle', followAction);
app.post('/.ghost/activitypub/actions/like/:id', likeAction);
app.post('/.ghost/activitypub/actions/unlike/:id', unlikeAction);
app.post('/.ghost/activitypub/actions/reply/:id', replyAction);

/** Federation wire up */

Expand Down
105 changes: 105 additions & 0 deletions src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Update,
Actor,
PUBLIC_COLLECTION,
Mention,
} from '@fedify/fedify';
import { Buffer } from 'node:buffer';
import { Context, Next } from 'hono';
Expand Down Expand Up @@ -208,6 +209,110 @@ export async function likeAction(
});
}


const ReplyActionSchema = z.object({
content: z.string()
});

export async function replyAction(
ctx: Context<{ Variables: HonoContextVariables }>,
) {
const id = ctx.req.param('id');

const data = ReplyActionSchema.parse(
await ctx.req.json() as unknown
);

const apCtx = fedify.createContext(ctx.req.raw as Request, {
db: ctx.get('db'),
globaldb: ctx.get('globaldb'),
});

const objectToReplyTo = await apCtx.lookupObject(id);
if (!objectToReplyTo) {
return new Response(null, {
status: 404,
});
}

const actor = await apCtx.getActor(ACTOR_DEFAULT_HANDLE);

let attributionActor: Actor | null = null;
if (objectToReplyTo.attributionId) {
attributionActor = await lookupActor(apCtx, objectToReplyTo.attributionId.href);
}

if (!attributionActor) {
return new Response(null, {
status: 400,
});
}

const to = PUBLIC_COLLECTION;
const cc = [attributionActor, apCtx.getFollowersUri(ACTOR_DEFAULT_HANDLE)];

const conversation = objectToReplyTo.replyTargetId || objectToReplyTo.id!;
const mentions = [new Mention({
href: attributionActor.id,
name: attributionActor.name,
})];

const replyId = apCtx.getObjectUri(Note, {
id: uuidv4(),
});

const reply = new Note({
id: replyId,
attribution: actor,
replyTarget: objectToReplyTo,
content: data.content,
summary: null,
published: Temporal.Now.instant(),
contexts: [conversation],
tags: mentions,
to: to,
ccs: cc,
});

const createId = apCtx.getObjectUri(Create, {
id: uuidv4(),
});

const create = new Create({
id: createId,
actor: actor,
object: reply,
to: to,
ccs: cc,
});

const activityJson = await create.toJsonLd();

await ctx
.get('globaldb')
.set([create.id!.href], activityJson);
await ctx
.get('globaldb')
.set([reply.id!.href], await reply.toJsonLd());

await addToList(ctx.get('db'), ['outbox'], create.id!.href);

apCtx.sendActivity({ handle: ACTOR_DEFAULT_HANDLE }, attributionActor, create, {
preferSharedInbox: true,
});

await apCtx.sendActivity({ handle: ACTOR_DEFAULT_HANDLE }, 'followers', create, {
preferSharedInbox: true,
});

return new Response(JSON.stringify(activityJson), {
headers: {
'Content-Type': 'application/activity+json',
},
status: 200,
});
}

export async function followAction(
ctx: Context<{ Variables: HonoContextVariables }>,
) {
Expand Down

0 comments on commit 1d9234a

Please sign in to comment.