Skip to content

Commit 6b00a2d

Browse files
dahliacodex
andcommitted
Add FEP-5711 collection inverse properties
Add FEP-5711 inverse properties to the bot actor's outbox and followers collections by patching ActivityPub collection responses in BotImpl.fetch(). Add fetch-level regression coverage for actor, collection root, and paged collection responses, and document the change in the bot concept docs and changelog. Co-Authored-By: OpenAI Codex <codex@openai.com>
1 parent 4517af3 commit 6b00a2d

4 files changed

Lines changed: 197 additions & 1 deletion

File tree

CHANGES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ To be released.
88

99
### @fedify/botkit
1010

11+
- Added FEP-5711 inverse properties to the bot actor's `outbox` and
12+
`followers` collections.
13+
1114
- Added a remote follow button to the web interface.
1215
[[#10], [#14] by Hyeonseo Kim]
1316

docs/concepts/bot.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ The `Bot` object is the main component of the library. It is used to register
1313
event handlers, and provides the entry point of the bot—the `~Bot.fetch()`
1414
method to be connected to the HTTP server.
1515

16+
BotKit also exposes the bot actor's standard ActivityPub collections. The
17+
JSON-LD responses of the `outbox` and `followers` collections include the
18+
[FEP-5711] inverse properties `outboxOf` and `followersOf`.
19+
20+
[FEP-5711]: https://w3id.org/fep/5711
21+
1622

1723
Instantiation
1824
-------------

packages/botkit/src/bot-impl.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2309,6 +2309,137 @@ test("BotImpl.fetch()", async () => {
23092309
assert.deepStrictEqual(response2.status, 200);
23102310
});
23112311

2312+
test("BotImpl.fetch() includes FEP-5711 inverse properties", async () => {
2313+
const repository = new MemoryRepository();
2314+
const bot = new BotImpl<void>({
2315+
kv: new MemoryKvStore(),
2316+
repository,
2317+
username: "bot",
2318+
collectionWindow: 1,
2319+
});
2320+
const actorId = new URL("https://example.com/ap/actor/bot");
2321+
2322+
await repository.addFollower(
2323+
new URL("https://example.com/actor/1#follow"),
2324+
new Person({
2325+
id: new URL("https://example.com/actor/1"),
2326+
preferredUsername: "john",
2327+
inbox: new URL("https://example.com/actor/1/inbox"),
2328+
}),
2329+
);
2330+
await repository.addMessage(
2331+
"78acb1ea-4ac6-46b7-bcd4-3a8965d8126e",
2332+
new Create({
2333+
id: new URL(
2334+
"https://example.com/ap/actor/bot/create/78acb1ea-4ac6-46b7-bcd4-3a8965d8126e",
2335+
),
2336+
actor: actorId,
2337+
to: PUBLIC_COLLECTION,
2338+
cc: new URL("https://example.com/ap/actor/bot/followers"),
2339+
object: new Note({
2340+
id: new URL("https://example.com/ap/actor/bot/note/1"),
2341+
attribution: actorId,
2342+
to: PUBLIC_COLLECTION,
2343+
cc: new URL("https://example.com/ap/actor/bot/followers"),
2344+
content: "Hello, world!",
2345+
published: Temporal.Instant.from("2025-01-01T00:00:00Z"),
2346+
}),
2347+
published: Temporal.Instant.from("2025-01-01T00:00:00Z"),
2348+
}),
2349+
);
2350+
2351+
const actorResponse = await bot.fetch(
2352+
new Request("https://example.com/ap/actor/bot", {
2353+
headers: { accept: "application/activity+json" },
2354+
}),
2355+
);
2356+
assert.deepStrictEqual(actorResponse.status, 200);
2357+
const actorJson = await actorResponse.json();
2358+
assert.deepStrictEqual(actorJson.id, actorId.href);
2359+
assert.deepStrictEqual(
2360+
actorJson.followers,
2361+
"https://example.com/ap/actor/bot/followers",
2362+
);
2363+
assert.deepStrictEqual(
2364+
actorJson.outbox,
2365+
"https://example.com/ap/actor/bot/outbox",
2366+
);
2367+
2368+
const outboxResponse = await bot.fetch(
2369+
new Request("https://example.com/ap/actor/bot/outbox", {
2370+
headers: { accept: "application/activity+json" },
2371+
}),
2372+
);
2373+
assert.deepStrictEqual(outboxResponse.status, 200);
2374+
const outboxJson = await outboxResponse.json();
2375+
assert.deepStrictEqual(outboxJson.type, "OrderedCollection");
2376+
assert.deepStrictEqual(
2377+
outboxJson.id,
2378+
"https://example.com/ap/actor/bot/outbox",
2379+
);
2380+
assert.deepStrictEqual(outboxJson.totalItems, 1);
2381+
assert.deepStrictEqual(
2382+
outboxJson.first,
2383+
"https://example.com/ap/actor/bot/outbox?cursor=",
2384+
);
2385+
assert.deepStrictEqual(outboxJson.outboxOf, actorId.href);
2386+
2387+
const outboxPageResponse = await bot.fetch(
2388+
new Request("https://example.com/ap/actor/bot/outbox?cursor=", {
2389+
headers: { accept: "application/activity+json" },
2390+
}),
2391+
);
2392+
assert.deepStrictEqual(outboxPageResponse.status, 200);
2393+
const outboxPageJson = await outboxPageResponse.json();
2394+
assert.deepStrictEqual(outboxPageJson.type, "OrderedCollectionPage");
2395+
assert.deepStrictEqual(
2396+
outboxPageJson.id,
2397+
"https://example.com/ap/actor/bot/outbox?cursor=",
2398+
);
2399+
assert.deepStrictEqual(
2400+
outboxPageJson.partOf,
2401+
"https://example.com/ap/actor/bot/outbox",
2402+
);
2403+
assert.deepStrictEqual(outboxPageJson.outboxOf, actorId.href);
2404+
2405+
const followersResponse = await bot.fetch(
2406+
new Request("https://example.com/ap/actor/bot/followers", {
2407+
headers: { accept: "application/activity+json" },
2408+
}),
2409+
);
2410+
assert.deepStrictEqual(followersResponse.status, 200);
2411+
const followersJson = await followersResponse.json();
2412+
assert.deepStrictEqual(followersJson.type, "OrderedCollection");
2413+
assert.deepStrictEqual(
2414+
followersJson.id,
2415+
"https://example.com/ap/actor/bot/followers",
2416+
);
2417+
assert.deepStrictEqual(followersJson.totalItems, 1);
2418+
assert.deepStrictEqual(
2419+
followersJson.first,
2420+
"https://example.com/ap/actor/bot/followers?cursor=0",
2421+
);
2422+
assert.deepStrictEqual(followersJson.followersOf, actorId.href);
2423+
2424+
const followersPageResponse = await bot.fetch(
2425+
new Request("https://example.com/ap/actor/bot/followers?cursor=0", {
2426+
headers: { accept: "application/activity+json" },
2427+
}),
2428+
);
2429+
assert.deepStrictEqual(followersPageResponse.status, 200);
2430+
const followersPageJson = await followersPageResponse.json();
2431+
assert.deepStrictEqual(followersPageJson.type, "OrderedCollectionPage");
2432+
assert.deepStrictEqual(
2433+
followersPageJson.id,
2434+
"https://example.com/ap/actor/bot/followers?cursor=0",
2435+
);
2436+
assert.deepStrictEqual(
2437+
followersPageJson.partOf,
2438+
"https://example.com/ap/actor/bot/followers",
2439+
);
2440+
assert.deepStrictEqual(followersPageJson.followersOf, actorId.href);
2441+
});
2442+
23122443
describe("BotImpl.addCustomEmoji(), BotImpl.addCustomEmojis()", () => {
23132444
const bot = new BotImpl<void>({ kv: new MemoryKvStore(), username: "bot" });
23142445

packages/botkit/src/bot-impl.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,57 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
10641064
return new SessionImpl(this, ctx);
10651065
}
10661066

1067+
async addCollectionInverseProperty(
1068+
request: Request,
1069+
contextData: TContextData,
1070+
response: Response,
1071+
): Promise<Response> {
1072+
if (!response.ok) return response;
1073+
const ctx = this.federation.createContext(request, contextData);
1074+
const parsed = ctx.parseUri(new URL(request.url));
1075+
if (
1076+
parsed == null ||
1077+
(parsed.type !== "outbox" && parsed.type !== "followers") ||
1078+
parsed.identifier == null
1079+
) {
1080+
return response;
1081+
}
1082+
const contentType = response.headers.get("Content-Type");
1083+
if (
1084+
contentType == null ||
1085+
(
1086+
!contentType.startsWith("application/activity+json") &&
1087+
!contentType.startsWith("application/ld+json")
1088+
)
1089+
) {
1090+
return response;
1091+
}
1092+
const body = await response.json();
1093+
if (typeof body !== "object" || body == null || Array.isArray(body)) {
1094+
return new Response(JSON.stringify(body), {
1095+
headers: response.headers,
1096+
status: response.status,
1097+
statusText: response.statusText,
1098+
});
1099+
}
1100+
const property = parsed.type === "outbox" ? "outboxOf" : "followersOf";
1101+
const actorUri = ctx.getActorUri(parsed.identifier).href;
1102+
if (body[property] === actorUri) {
1103+
return new Response(JSON.stringify(body), {
1104+
headers: response.headers,
1105+
status: response.status,
1106+
statusText: response.statusText,
1107+
});
1108+
}
1109+
const headers = new Headers(response.headers);
1110+
headers.delete("Content-Length");
1111+
return new Response(JSON.stringify({ ...body, [property]: actorUri }), {
1112+
headers,
1113+
status: response.status,
1114+
statusText: response.statusText,
1115+
});
1116+
}
1117+
10671118
async fetch(request: Request, contextData: TContextData): Promise<Response> {
10681119
if (this.behindProxy) {
10691120
request = await getXForwardedRequest(request);
@@ -1074,7 +1125,12 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
10741125
url.pathname.startsWith("/ap/") ||
10751126
url.pathname.startsWith("/nodeinfo/")
10761127
) {
1077-
return await this.federation.fetch(request, { contextData });
1128+
const response = await this.federation.fetch(request, { contextData });
1129+
return await this.addCollectionInverseProperty(
1130+
request,
1131+
contextData,
1132+
response,
1133+
);
10781134
}
10791135
const match = /^\/emojis\/([a-z0-9-_]+)(?:$|\.)/.exec(url.pathname);
10801136
if (match != null) {

0 commit comments

Comments
 (0)