diff --git a/_content/_data/_inbox/.gitkeep b/_content/_data/_inbox/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/_content/_data/_outbox/.gitkeep b/_content/_data/_outbox/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/_content/_data/liked.json b/_content/_data/liked.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/_content/_data/liked.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/package.json b/package.json index 7c0d339..32c717e 100644 --- a/package.json +++ b/package.json @@ -3,11 +3,11 @@ "module": "index.ts", "type": "module", "scripts": { - "start": "bun run --watch src/index.ts" + "start": "bun run --watch src/index.ts", + "ngrok": "ngrok tunnel --label edge=edghts_2VNJvaPttrFlAPWxrGyVKu0s3ad http://localhost:3000" }, "devDependencies": { - "@types/node-forge": "^1.3.5", - "@types/yaml": "^1.9.7" + "@types/node-forge": "^1.3.5" }, "peerDependencies": { "typescript": "^5.0.0" diff --git a/src/activitypub.ts b/src/activitypub.ts index 79af233..9ff2da3 100644 --- a/src/activitypub.ts +++ b/src/activitypub.ts @@ -1,6 +1,7 @@ import { ACCOUNT, ACTOR, HOSTNAME, PUBLIC_KEY } from "./env" import * as db from "./db" import { reqIsActivityPub, send, verify } from "./request" +import outbox from "./outbox" export default (req: Request): Response | Promise | undefined => { const url = new URL(req.url) @@ -8,6 +9,7 @@ export default (req: Request): Response | Promise | undefined => { if(req.method === "GET" && url.pathname === "/test") return new Response("", { status: 204 }) else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/inbox\/?$/i))) return postInbox(req, match[1]) + else if(req.method == "POST" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return postOutbox(req, match[1]) else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/outbox\/?$/i))) return getOutbox(req, match[1]) else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/followers\/?$/i))) return getFollowers(req, match[1]) else if(req.method == "GET" && (match = url.pathname.match(/^\/([^\/]+)\/following\/?$/i))) return getFollowing(req, match[1]) @@ -18,6 +20,32 @@ export default (req: Request): Response | Promise | undefined => { return undefined } + +export function idsFromValue(value:any):string[] { + if (!value) return [] + else if (typeof value === 'string') return [value] + else if (value.id) return [value.id] + else if (Array.isArray(value)) return value.map(v => idsFromValue(v)).flat(Infinity) as string[] + return [] +} + +const postOutbox = async (req:Request, account:string):Promise => { + console.log("PostOutbox", account) + if (ACCOUNT !== account) return new Response("", { status: 404 }) + + const bodyText = await req.text() + + // TODO: verify calls to the outbox, whether that be by basic authentication, bearer, or otherwise. + + const body = JSON.parse(bodyText) + // ensure that the verified actor matches the actor in the request body + if (ACTOR !== body.actor) return new Response("", { status: 401 }) + + // console.log(body) + + return await outbox(body) +} + const postInbox = async (req:Request, account:string):Promise => { console.log("PostInbox", account) if (ACCOUNT !== account) return new Response("", { status: 404 }) @@ -30,7 +58,6 @@ const postInbox = async (req:Request, account:string):Promise => { // verify the signed HTTP request from = await verify(req, bodyText); } catch (err) { - console.error(err); return new Response("", { status: 401 }) } @@ -39,13 +66,14 @@ const postInbox = async (req:Request, account:string):Promise => { // ensure that the verified actor matches the actor in the request body if (from !== body.actor) return new Response("", { status: 401 }) - console.log(body) + // console.log(body) // TODO: add support for more types! we want replies, likes, boosts, etc! switch (body.type) { case "Follow": await follow(body); case "Undo": await undo(body); case "Accept": await accept(body); + case "Reject": await reject(body); } return new Response("", { status: 204 }) @@ -74,11 +102,18 @@ const accept = async (body:any) => { } } +const reject = async (body:any) => { + switch (body.object.type) { + case "Follow": await db.deleteFollowing(body.actor); break + } +} + const getOutbox = async (req:Request, account:string):Promise => { console.log("GetOutbox", account) if (ACCOUNT !== account) return new Response("", { status: 404 }) - const posts = await db.listActivities() + // TODO: Paging? + const posts = await db.listOutboxActivities() return Response.json({ "@context": "https://www.w3.org/ns/activitystreams", diff --git a/src/admin.ts b/src/admin.ts index 10bd4dd..e79f195 100644 --- a/src/admin.ts +++ b/src/admin.ts @@ -1,6 +1,8 @@ -import { createFollowing, deleteFollowing, doActivity, getFollowing, listFollowers } from "./db" +import { idsFromValue } from "./activitypub" +import { getFollowing, getOutboxActivity, listLiked } from "./db" import { ACTOR, ADMIN_PASSWORD, ADMIN_USERNAME, BASE_URL } from "./env" -import { send } from "./request" +import outbox from "./outbox" +import { fetchObject } from "./request" export default (req: Request): Response | Promise | undefined => { const url = new URL(req.url) @@ -15,6 +17,10 @@ export default (req: Request): Response | Promise | undefined => { else if(req.method == "POST" && (match = url.pathname.match(/^\/create\/?$/i))) return create(req) else if(req.method == "POST" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return follow(req, match[1]) else if(req.method == "DELETE" && (match = url.pathname.match(/^\/follow\/([^\/]+)\/?$/i))) return unfollow(req, match[1]) + else if(req.method == "POST" && (match = url.pathname.match(/^\/like\/(.+)\/?$/i))) return like(req, match[1]) + else if(req.method == "DELETE" && (match = url.pathname.match(/^\/like\/(.+)\/?$/i))) return unlike(req, match[1]) + + console.log(`Couldn't match admin path ${req.method} "${url.pathname}"`) return undefined } @@ -35,20 +41,16 @@ const create = async (req:Request):Promise => { // create the object, merging in supplied data const date = new Date() - const id = date.getTime().toString(16) const object = { attributedTo: ACTOR, published: date.toISOString(), to: ["https://www.w3.org/ns/activitystreams#Public"], cc: [`${ACTOR}/followers`], - url: `${ACTOR}/post/${id}`, - id: `${ACTOR}/post/${id}`, ...body.object } const activity = { - "@context": "https://www.w3.org/ns/activitystreams", - id: `${ACTOR}/post/${id}/activity`, + "@context": "https://www.w3.org/ns/activitystreams", type: "Create", published: date.toISOString(), actor: ACTOR, @@ -58,58 +60,66 @@ const create = async (req:Request):Promise => { object: { ...object } } - // TODO: actually create the object (and the activity??) - await doActivity(activity, id) - - // loop through the list of followers - for (const follower of await listFollowers()) { - // send the activity to each follower - send(ACTOR, follower.actor, { - ...activity, - cc: [follower.actor], - }); - } - - // return HTTP 204: no content (success) - return new Response("", { status: 204 }) + return await outbox(activity) } const follow = async (req:Request, handle:string):Promise => { - const id = BASE_URL + '@' + handle // send the follow request to the supplied actor - await send(ACTOR, handle, { + return await outbox({ "@context": "https://www.w3.org/ns/activitystreams", - id, type: "Follow", actor: ACTOR, object: handle, - }); - await createFollowing(handle, id) - - return new Response("", { status: 204 }) + to: [handle, "https://www.w3.org/ns/activitystreams#Public"] + }) } const unfollow = async (req:Request, handle:string):Promise => { // check to see if we are already following. If not, just return success const existing = await getFollowing(handle) if (!existing) return new Response("", { status: 204 }) - - // TODO: send the unfollow request (technically an undo follow activity) - await send(ACTOR, handle, { + const activity = await getOutboxActivity(existing.id) + // outbox will also take care of the deletion + return await outbox({ "@context": "https://www.w3.org/ns/activitystreams", - id: existing.id + "/undo", type: "Undo", actor: ACTOR, - object: { - id: existing.id, - type: "Follow", - actor: ACTOR, - object: handle, - }, - }); + object: activity, + to: activity.to + }) +} - // delete the following reference from the database - deleteFollowing(handle); +const like = async (req:Request, object_url:string):Promise => { + const object = await (await fetchObject(ACTOR, object_url)).json() - return new Response("", { status: 204 }) + return await outbox({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Like", + actor: ACTOR, + object: object, + to: [...idsFromValue(object.attributedTo), "https://www.w3.org/ns/activitystreams#Public"] + }) +} + +const unlike = async (req:Request, object_id:string):Promise => { + // check to see if we are already following. If not, just return success + const liked = await listLiked() + let existing = liked.find(o => o.object_id === object_id) + if (!existing){ + const object = await (await fetchObject(ACTOR, object_id)).json() + idsFromValue(object).forEach(id => { + const e = liked.find(o => o.object_id === id) + if(e) existing = e + }) + } + if (!existing) return new Response("No like found to delete", { status: 204 }) + const activity = await getOutboxActivity(existing.id) + // outbox will also take care of the deletion + return await outbox({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Undo", + actor: ACTOR, + object: activity, + to: activity.to + }) } \ No newline at end of file diff --git a/src/db.ts b/src/db.ts index 936a92b..2537965 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,6 +1,7 @@ -import { ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env"; +import { ACTIVITY_INBOX_PATH, ACTIVITY_OUTBOX_PATH, ACTIVITY_PATH, ACTOR, BASE_URL, DATA_PATH, POSTS_PATH } from "./env"; import path from "path" import { readdir } from "fs/promises" +import { unlinkSync } from "node:fs" const matter = require('gray-matter') export async function doActivity(activity:any, object_id:string|null|undefined) { @@ -15,6 +16,58 @@ export async function doActivity(activity:any, object_id:string|null|undefined) } } +export async function createInboxActivity(activity:any, object_id:any) { + const activityFile = Bun.file(path.join(ACTIVITY_INBOX_PATH, `${object_id}.activity.json`)) + await Bun.write(activityFile, JSON.stringify(activity)) +} + +export async function createOutboxActivity(activity:any, object_id:any) { + const activityFile = Bun.file(path.join(ACTIVITY_OUTBOX_PATH, `${object_id}.activity.json`)) + await Bun.write(activityFile, JSON.stringify(activity)) +} + +export async function getInboxActivity(id:string) { + const file = Bun.file(path.join(ACTIVITY_INBOX_PATH, `${id}.activity.json`)) + return await file.json() +} + +export async function getOutboxActivity(id:string) { + const file = Bun.file(path.join(ACTIVITY_OUTBOX_PATH, `${id}.activity.json`)) + return await file.json() +} + +export async function listInboxActivities() { + return await Promise.all( + (await readdir(ACTIVITY_INBOX_PATH)).filter(v => v.endsWith('.activity.json')) + .map(async filename => await Bun.file(path.join(ACTIVITY_INBOX_PATH, filename)).json()) + ) +} + +export async function listOutboxActivities() { + return await Promise.all( + (await readdir(ACTIVITY_OUTBOX_PATH)).filter(v => v.endsWith('.activity.json')) + .map(async filename => await Bun.file(path.join(ACTIVITY_OUTBOX_PATH, filename)).json()) + ) +} + +export async function createPost(post_object:any, object_id:string) { + const file = Bun.file(path.join(POSTS_PATH, `${object_id}.md`)) + const {type, object} = post_object + if(object){ + let { content, published, id, attributedTo } = object + if(content as string) content = '> ' + content.replace('\n', '\n> ') + '\n' + else content = "" + + content += post_object.content || "" + //TODO: add appropriate content for different types (e.g. like, etc) + await Bun.write(file, matter.stringify(content, { id, published, attributedTo, type })) + } + else { + const { content, published, id, attributedTo } = post_object + await Bun.write(file, matter.stringify(content || "", { id, published, attributedTo, type })) + } +} + export async function getPost(id:string) { const file = Bun.file(path.join(POSTS_PATH, `${id}.md`)) const { data, content } = matter(await file.text()) @@ -24,6 +77,10 @@ export async function getPost(id:string) { } } +export async function deletePost(id:string) { + unlinkSync(path.join(POSTS_PATH, id + '.md')) +} + export async function listPosts() { return await Promise.all((await readdir(POSTS_PATH)).filter(v => v.endsWith('.md')).map(async filename => await getPost(filename.slice(0, -3)))) } @@ -33,10 +90,6 @@ export async function getActivity(id:string) { return await file.json() } -export async function listActivities() { - return await Promise.all((await readdir(ACTIVITY_PATH)).filter(v => v.endsWith('.activity.json')).map(async filename => await Bun.file(path.join(ACTIVITY_PATH, filename)).json())) -} - export async function createFollowing(handle:string, id:string) { const file = Bun.file(path.join(DATA_PATH, `following.json`)) const following_list = await file.json() as Array @@ -56,9 +109,9 @@ export async function getFollowing(handle:string) { return following_list.find(v => v.handle === handle) } -export async function listFollowing() { +export async function listFollowing(onlyAccepted = true) { const file = Bun.file(path.join(DATA_PATH, `following.json`)) - return await file.json() as Array + return ((await file.json()) as Array).filter(f => !onlyAccepted || f.accepted) } export async function acceptFollowing(handle:string) { @@ -91,4 +144,22 @@ export async function getFollower(actor:string) { export async function listFollowers() { const file = Bun.file(path.join(DATA_PATH, `followers.json`)) return await file.json() as Array +} + +export async function createLiked(object_id:string, id:string) { + const file = Bun.file(path.join(DATA_PATH, `liked.json`)) + const liked_list = await file.json() as Array + if(!liked_list.find(v => v.object_id === object_id)) liked_list.push({id, object_id, createdAt: new Date().toISOString()}) + await Bun.write(file, JSON.stringify(liked_list)) +} + +export async function deleteLiked(object_id:string) { + const file = Bun.file(path.join(DATA_PATH, `liked.json`)) + const liked_list = await file.json() as Array + await Bun.write(file, JSON.stringify(liked_list.filter(v => v.object_id !== object_id))) +} + +export async function listLiked() { + const file = Bun.file(path.join(DATA_PATH, `liked.json`)) + return await file.json() as Array } \ No newline at end of file diff --git a/src/env.ts b/src/env.ts index 625de89..89a83e0 100644 --- a/src/env.ts +++ b/src/env.ts @@ -35,4 +35,6 @@ export const PRIVATE_KEY = export const CONTENT_PATH = path.join('.', '_content') export const POSTS_PATH = path.join(CONTENT_PATH, "posts") export const ACTIVITY_PATH = path.join(CONTENT_PATH, "posts") -export const DATA_PATH = path.join(CONTENT_PATH, "_data") \ No newline at end of file +export const DATA_PATH = path.join(CONTENT_PATH, "_data") +export const ACTIVITY_INBOX_PATH = path.join(DATA_PATH, "_inbox") +export const ACTIVITY_OUTBOX_PATH = path.join(DATA_PATH, "_outbox") \ No newline at end of file diff --git a/src/inbox.ts b/src/inbox.ts new file mode 100644 index 0000000..50646be --- /dev/null +++ b/src/inbox.ts @@ -0,0 +1,63 @@ +import { idsFromValue } from "./activitypub"; +import * as db from "./db"; +import { ACTOR, BASE_URL } from "./env"; +import outbox from "./outbox"; +import { send } from "./request"; + +export default async function inbox(activity:any) { + const date = new Date() + // get the main recipients ([...new Set()] is to dedupe) + const recipientList = [...new Set(idsFromValue(activity.to).concat(idsFromValue(activity.cc)).concat(idsFromValue(activity.audience)))] + + // if my list of followers in the list of recipients, then forward to them as well + if(recipientList.includes(ACTOR + "/followers")) { + (await db.listFollowers()).forEach(f => send(activity.attributedTo, f, activity)) + } + + // save this activity to my inbox + const id = `${date.getTime().toString(16)}` + db.createInboxActivity(activity, id) + + // TODO: process the activity and update local data + switch(activity.type) { + case "Follow": follow(activity, id); break; + case "Accept": accept(activity); break; + case "Reject": reject(activity); break; + case "Undo": undo(activity); break; + } +} + +const follow = async (activity:any, id:string) => { + // someone is following me + // save this follower locally + db.createFollower(activity.actor, id) + // send an accept message to the outbox + await outbox({ + "@context": "https://www.w3.org/ns/activitystreams", + type: "Accept", + actor: ACTOR, + to: [activity.actor], + object: activity, + }); +} + +const undo = async (activity:any) => { + switch (activity.object.type) { + // someone is undoing their follow of me + case "Follow": await db.deleteFollower(activity.actor); break + } +} + +const accept = async (activity:any) => { + switch (activity.object.type) { + // someone accepted my follow of them + case "Follow": await db.acceptFollowing(activity.actor); break + } +} + +const reject = async (activity:any) => { + switch (activity.object.type) { + // someone rejected my follow of them + case "Follow": await db.deleteFollowing(activity.actor); break + } +} diff --git a/src/index.ts b/src/index.ts index 78b1799..64d3969 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { ACCOUNT, ACTOR, HOSTNAME, PORT } from "./env"; import admin from './admin' import activitypub from "./activitypub"; +import { fetchObject } from "./request"; const server = Bun.serve({ port: 3000, @@ -25,6 +26,12 @@ const server = Bun.serve({ ], }, { headers: { "content-type": "application/activity+json" }}) } + else if(req.method === "GET" && url.pathname === "/fetch") { + const object_url = url.searchParams.get('url') + if(!object_url) return new Response("No url supplied", { status: 400}) + + return fetchObject(ACTOR, object_url) + } return admin(req) || activitypub(req) || new Response("How did we get here?", { status: 404 }) }, diff --git a/src/outbox.ts b/src/outbox.ts new file mode 100644 index 0000000..95975c6 --- /dev/null +++ b/src/outbox.ts @@ -0,0 +1,127 @@ +import { idsFromValue } from "./activitypub" +import * as db from "./db"; +import { ACTOR } from "./env" +import { fetchObject, send } from "./request"; + +export default async function outbox(activity:any):Promise { + const date = new Date() + const id = `${date.getTime().toString(16)}` + console.log('outbox', id, activity) + + // https://www.w3.org/TR/activitypub/#object-without-create + if(!activity.actor && !(activity.object || activity.target || activity.result || activity.origin || activity.instrument)) { + const object = activity + activity = { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Create", + actor: ACTOR, + object + } + const { to, bto, cc, bcc, audience } = object + if(to) activity.to = to + if(bto) activity.bto = bto + if(cc) activity.cc = cc + if(bcc) activity.bcc = bcc + if(audience) activity.audience = audience + } + + activity.id = `${ACTOR}/outbox/${id}` + if(!activity.published) activity.published = date.toISOString() + + if(activity.type === 'Create' && activity.object && Object(activity.object) === activity.object) { + // When a Create activity is posted, the actor of the activity SHOULD be copied onto the object's attributedTo field. + activity.object.attributedTo = activity.actor + if(!activity.object.published) activity.object.published = activity.published + } + + // get the main recipients ([...new Set()] is to dedupe) + const recipientList = [...new Set(idsFromValue(activity.to).concat(idsFromValue(activity.cc)).concat(idsFromValue(activity.audience)))] + // add in the blind recipients + const finalRecipientList = [...new Set(recipientList.concat(idsFromValue(activity.bto)).concat(idsFromValue(activity.bcc)))] + // remove the blind recipients from the activity + delete activity.bto + delete activity.bcc + + // now that has been taken care of, it's time to update our local data, depending on the contents of the activity + switch(activity.type) { + case "Accept": await accept(activity, id); break; + case "Follow": await follow(activity, id); break; + case "Like": await like(activity, id); break; + case "Create": await create(activity, id); break; + case "Undo": await undo(activity); break; + // TODO: case "Anncounce": return await share(activity) + } + // save the activity data for the outbox + await db.createOutboxActivity(activity, id) + + // send to the appropriate recipients + finalRecipientList.forEach((to) => { + if (to.startsWith(ACTOR + "/followers")) db.listFollowers().then(followers => followers.forEach(f => send(ACTOR, f.actor, activity))) + else if (to === "https://www.w3.org/ns/activitystreams#Public") return // there's nothing to "send" to here + else if (to) send(ACTOR, to, activity) + }) + + return new Response("", { status: 201, headers: { location: activity.id } }) +} + +async function create(activity:any, id:string) { + activity.object.id = activity.object.url = `${ACTOR}/post/${id}` + await db.createPost(activity.object, id) + return true +} + +async function accept(activity:any, id:string) { + return true +} + +async function follow(activity:any, id:string) { + await db.createFollowing(activity.object , id) + return true +} + +async function like(activity:any, id:string) { + if(typeof activity.object === 'string'){ + await db.createLiked(activity.object, id) + activity.object = await fetchObject(ACTOR, activity.object) + } + else { + const liked = await idsFromValue(activity.object) + liked.forEach(l => db.createLiked(l, id)) + } + await db.createPost(activity, id) + return true +} + +// async function share(activity:any) { +// let object = activity.object +// if(typeof object === 'string') { +// try{ +// object = await fetchObject(object) +// } +// catch { } +// } +// db.createShared(object) +// return true +// } + +async function undo(activity:any) { + const id = await idsFromValue(activity.object).at(0) + if (!id) return true + const match = id.match(/\/([0-9a-f]+)\/?$/) + const local_id = match ? match[1] : id + console.log('undo', local_id) + try{ + const existing = await db.getOutboxActivity(local_id) + + switch(activity.object.type) { + case "Follow": await db.deleteFollowing(existing.object); break; + case "Like": idsFromValue(existing.object).forEach(async id => await db.deleteLiked(id)); await db.deletePost(local_id); break; + // case "Share": await db.deleteShared(existing.object) + } + } + catch { + return false + } + + return true +} \ No newline at end of file diff --git a/src/request.ts b/src/request.ts index 933ff1a..8096a40 100644 --- a/src/request.ts +++ b/src/request.ts @@ -1,5 +1,5 @@ import forge from "node-forge" // import crypto from "node:crypto" -import { PRIVATE_KEY } from "./env"; +import { ACTOR, PRIVATE_KEY } from "./env"; export function reqIsActivityPub(req:Request) { const contentType = req.headers.get("Accept") @@ -10,15 +10,57 @@ export function reqIsActivityPub(req:Request) { /** Fetches and returns an actor at a URL. */ async function fetchActor(url:string) { - const res = await fetch(url, { - headers: { accept: "application/activity+json" }, + return (await fetchObject(ACTOR, url)).json() +} + +/** Fetches and returns an object at a URL. */ +// export async function fetchObject(url:string) { +// const res = await fetch(url, { +// headers: { accept: "application/activity+json" }, +// }); + +// if (res.status < 200 || 299 < res.status) { +// throw new Error(`Received ${res.status} fetching object.`); +// } + +// return res.json(); +// } + +/** Fetches and returns an object at a URL. */ +export async function fetchObject(sender:string, object_url:string) { + console.log(`fetch ${object_url}`) + const url = new URL(object_url) + const path = url.pathname + + const d = new Date(); + + const key = forge.pki.privateKeyFromPem(PRIVATE_KEY) + + const data = [ + `(request-target): get ${path}`, + `host: ${url.hostname}`, + `date: ${d.toUTCString()}` + ].join("\n") + + + const signature = forge.util.encode64(key.sign(forge.md.sha256.create().update(data))) + + const res = await fetch(object_url, { + method: "GET", + headers: { + host: url.hostname, + date: d.toUTCString(), + "content-type": "application/json", + signature: `keyId="${sender}#main-key",headers="(request-target) host date",signature="${signature}"`, + accept: "application/json", + } }); if (res.status < 200 || 299 < res.status) { - throw new Error(`Received ${res.status} fetching actor.`); + throw new Error(res.statusText + ": " + (await res.text())); } - - return res.json(); + + return res; } /** Sends a signed message from the sender to the recipient. @@ -27,6 +69,7 @@ async function fetchActor(url:string) { * @param message the body of the request to send. */ export async function send(sender:string, recipient:string, message:any) { + console.log(`Sending to ${recipient}`, message) const url = new URL(recipient) const actor = await fetchActor(recipient) const path = actor.inbox.replace("https://" + url.hostname, "")