From 6a966f91b343a59a279f381f3ee2a5de294b4d8a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 18:47:54 +0900 Subject: [PATCH 01/36] Make Repository interface multi-bot aware To support hosting multiple bot actors on a single instance, a single repository now stores data for multiple bots: every Repository method takes the identifier of the owning bot actor as its first parameter, and data belonging to different identifiers are completely isolated. This commit includes the following changes: - Added identifier parameter to all Repository methods - Added ActorScopedRepository, a view of a Repository bound to a single bot actor identifier, created via Repository.forIdentifier(); BotImpl now scopes its repository through it, so its internals are unchanged - Added Repository.findFollowedBots(), a reverse lookup answering which bots follow a given actor, needed later for routing shared-inbox activities from followed actors without enumerating dynamic bots - Reserved an optional Repository.migrate() hook for adopting data stored by BotKit 0.4 or earlier (implementations follow separately) - Re-keyed KvRepository under ["_botkit", "bots", identifier, ...] with a reverse index under ["_botkit", "index", "followees", ...]; the per-category KvStoreRepositoryPrefixes option was replaced with a single prefix option (KvRepositoryOptions) - Re-keyed MemoryRepository and MemoryCachedRepository per identifier - Added a bot_id column and composite primary keys to all tables in @fedify/botkit-sqlite and @fedify/botkit-postgres; migration of existing databases follows in a separate commit - Scoped PostgreSQL advisory lock keys by bot identifier - Fixed KvRepository.vote() not awaiting writes on KV stores without CAS support, and MemoryRepository.getMessages() ignoring the limit option - Made KvRepository.removeFollowee() and MemoryCachedRepository repair the reverse index and cache on retried removals - Declared @fedify/botkit as a workspace peer dependency of @fedify/botkit-postgres, and made the test:node task build all packages before running tests, since the Node.js tests of dependent packages now import runtime code from @fedify/botkit https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- deno.json | 2 +- packages/botkit-postgres/package.json | 2 +- packages/botkit-postgres/src/mod.test.ts | 232 +++-- packages/botkit-postgres/src/mod.ts | 341 ++++-- packages/botkit-sqlite/src/mod.test.ts | 91 +- packages/botkit-sqlite/src/mod.ts | 349 +++++-- packages/botkit/src/bot-impl.test.ts | 71 +- packages/botkit/src/bot-impl.ts | 11 +- packages/botkit/src/follow-impl.test.ts | 14 +- packages/botkit/src/message-impl.test.ts | 17 +- packages/botkit/src/mod.ts | 2 + packages/botkit/src/repository.test.ts | 683 ++++++++++--- packages/botkit/src/repository.ts | 1194 ++++++++++++++++------ packages/botkit/src/session-impl.test.ts | 33 +- pnpm-lock.yaml | 2 +- 15 files changed, 2214 insertions(+), 830 deletions(-) diff --git a/deno.json b/deno.json index bf78cec..54711ca 100644 --- a/deno.json +++ b/deno.json @@ -36,7 +36,7 @@ "fmt": "deno fmt && deno task hongdown --write", "install": "deno cache packages/*/src/*.ts && pnpm install", "test": "deno test --allow-read --allow-write --allow-env --allow-net=hollo.social,localhost,127.0.0.1 --parallel", - "test:node": "pnpm install && pnpm run -r test", + "test:node": "pnpm install && pnpm run -r build && pnpm run -r test", "test-all": { "dependencies": ["check", "test", "test:node"] }, diff --git a/packages/botkit-postgres/package.json b/packages/botkit-postgres/package.json index a0f6cfe..b55bf8e 100644 --- a/packages/botkit-postgres/package.json +++ b/packages/botkit-postgres/package.json @@ -43,7 +43,7 @@ "README.md" ], "peerDependencies": { - "@fedify/botkit": "^0.4.0" + "@fedify/botkit": "workspace:" }, "dependencies": { "@fedify/fedify": "^2.1.2", diff --git a/packages/botkit-postgres/src/mod.test.ts b/packages/botkit-postgres/src/mod.test.ts index 3922974..132d3af 100644 --- a/packages/botkit-postgres/src/mod.test.ts +++ b/packages/botkit-postgres/src/mod.test.ts @@ -169,7 +169,7 @@ if (postgresUrl == null) { schema: repositorySchema, prepare: false, }); - await repo.countMessages(); + await repo.countMessages("bot"); assert.ok(prepares.length > 0); assert.ok(prepares.every((prepare) => !prepare)); @@ -189,7 +189,7 @@ if (postgresUrl == null) { sql, url: undefined, maxConnections: undefined, - }]).countMessages(), + }]).countMessages("bot"), ); await assert.rejects( async () => @@ -197,7 +197,7 @@ if (postgresUrl == null) { sql, url: postgresUrl, maxConnections: 1, - }]).countMessages(), + }]).countMessages("bot"), new TypeError( "PostgresRepositoryOptions.sql cannot be combined with PostgresRepositoryOptions.url or PostgresRepositoryOptions.maxConnections.", ), @@ -242,7 +242,7 @@ if (postgresUrl == null) { }]) as PostgresRepository; await waitForMacrotask(); await assert.rejects( - () => repo.countMessages(), + () => repo.countMessages("bot"), error, ); await waitForMacrotask(); @@ -300,7 +300,7 @@ if (postgresUrl == null) { arrivals < 2 && query.includes("SELECT follower_id") && query.includes("FOR UPDATE") && - parameters?.[0] === followId.href + parameters?.[1] === followId.href ) { arrivals++; if (arrivals === 2) releaseBarrier(); @@ -329,12 +329,12 @@ if (postgresUrl == null) { const repoB = new PostgresRepository({ sql: wrapSql(sqlB), schema }); await Promise.all([ - repoA.addFollower(followId, followerA), - repoB.addFollower(followId, followerB), + repoA.addFollower("bot", followId, followerA), + repoB.addFollower("bot", followId, followerB), ]); - const followers = await Array.fromAsync(repoA.getFollowers()); - assert.deepStrictEqual(await repoA.countFollowers(), 1); + const followers = await Array.fromAsync(repoA.getFollowers("bot")); + assert.deepStrictEqual(await repoA.countFollowers("bot"), 1); assert.deepStrictEqual(followers.length, 1); const remainingFollowerId = followers[0]?.id?.href; assert.ok( @@ -342,11 +342,11 @@ if (postgresUrl == null) { remainingFollowerId === followerB.id!.href, ); assert.deepStrictEqual( - await repoA.hasFollower(followerA.id!), + await repoA.hasFollower("bot", followerA.id!), remainingFollowerId === followerA.id!.href, ); assert.deepStrictEqual( - await repoA.hasFollower(followerB.id!), + await repoA.hasFollower("bot", followerB.id!), remainingFollowerId === followerB.id!.href, ); } finally { @@ -398,7 +398,7 @@ if (postgresUrl == null) { ); if ( query.includes("pg_advisory_xact_lock") && - parameters?.[1] === `${schema}:${followId.href}` + parameters?.[1] === `${schema}:bot:${followId.href}` ) { resolveLockAcquired(); await barrier; @@ -420,9 +420,13 @@ if (postgresUrl == null) { const repoA = new PostgresRepository({ sql: wrappedSql, schema }); const repoB = new PostgresRepository({ sql: sqlB, schema }); - const addPromise = repoA.addFollower(followId, follower); + const addPromise = repoA.addFollower("bot", followId, follower); await lockAcquired; - const removePromise = repoB.removeFollower(followId, follower.id!); + const removePromise = repoB.removeFollower( + "bot", + followId, + follower.id!, + ); await waitForMacrotask(); releaseBarrier(); @@ -434,8 +438,11 @@ if (postgresUrl == null) { await removedFollower?.toJsonLd(), await follower.toJsonLd(), ); - assert.deepStrictEqual(await repoA.countFollowers(), 0); - assert.deepStrictEqual(await repoA.hasFollower(follower.id!), false); + assert.deepStrictEqual(await repoA.countFollowers("bot"), 0); + assert.deepStrictEqual( + await repoA.hasFollower("bot", follower.id!), + false, + ); } finally { await adminSql.unsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await Promise.all([sqlA.end(), sqlB.end(), adminSql.end()]); @@ -493,7 +500,7 @@ if (postgresUrl == null) { query.includes( `DELETE FROM "${schema}"."followers"`, ) && - parameters?.[0] === oldFollower.id!.href + parameters?.[1] === oldFollower.id!.href ) { arrivals++; if (arrivals === 2) releaseBarrier(); @@ -523,23 +530,26 @@ if (postgresUrl == null) { try { await initializePostgresRepositorySchema(adminSql, schema); const setupRepo = new PostgresRepository({ sql: adminSql, schema }); - await setupRepo.addFollower(followA, oldFollower); - await setupRepo.addFollower(followB, oldFollower); + await setupRepo.addFollower("bot", followA, oldFollower); + await setupRepo.addFollower("bot", followB, oldFollower); const repoA = new PostgresRepository({ sql: wrapSql(sqlA), schema }); const repoB = new PostgresRepository({ sql: wrapSql(sqlB), schema }); await Promise.all([ - repoA.addFollower(followA, newFollowerA), - repoB.addFollower(followB, newFollowerB), + repoA.addFollower("bot", followA, newFollowerA), + repoB.addFollower("bot", followB, newFollowerB), ]); const followers = await Promise.all( - (await Array.fromAsync(repoA.getFollowers())).map((follower) => + (await Array.fromAsync(repoA.getFollowers("bot"))).map((follower) => follower.toJsonLd() ), ); - assert.deepStrictEqual(await repoA.countFollowers(), 2); - assert.deepStrictEqual(await repoA.hasFollower(oldFollower.id!), false); + assert.deepStrictEqual(await repoA.countFollowers("bot"), 2); + assert.deepStrictEqual( + await repoA.hasFollower("bot", oldFollower.id!), + false, + ); assert.deepStrictEqual(followers, [ await newFollowerA.toJsonLd(), await newFollowerB.toJsonLd(), @@ -599,7 +609,7 @@ if (postgresUrl == null) { query.includes( `DELETE FROM "${schema}"."followers"`, ) && - parameters?.[0] === oldFollower.id!.href + parameters?.[1] === oldFollower.id!.href ) { resolveCleanupReady(); await Promise.race([ @@ -651,8 +661,8 @@ if (postgresUrl == null) { query.includes( `INSERT INTO "${schema}"."follow_requests"`, ) && - parameters?.[0] === followB.href && - parameters?.[1] === oldFollower.id!.href + parameters?.[1] === followB.href && + parameters?.[2] === oldFollower.id!.href ) { resolveInsertedNewRequest(); await new Promise((resolve) => @@ -674,7 +684,7 @@ if (postgresUrl == null) { try { await initializePostgresRepositorySchema(adminSql, schema); const setupRepo = new PostgresRepository({ sql: adminSql, schema }); - await setupRepo.addFollower(followA, oldFollower); + await setupRepo.addFollower("bot", followA, oldFollower); const repoA = new PostgresRepository({ sql: wrapCleanupSql(sqlA), @@ -682,30 +692,37 @@ if (postgresUrl == null) { }); const repoB = new PostgresRepository({ sql: wrapAddSql(sqlB), schema }); - const reassignPromise = repoA.addFollower(followA, reassignedFollower); + const reassignPromise = repoA.addFollower( + "bot", + followA, + reassignedFollower, + ); await cleanupReady; - const addPromise = repoB.addFollower(followB, oldFollower); + const addPromise = repoB.addFollower("bot", followB, oldFollower); await Promise.all([reassignPromise, addPromise]); const followers = await Promise.all( - (await Array.fromAsync(repoA.getFollowers())).map((follower) => + (await Array.fromAsync(repoA.getFollowers("bot"))).map((follower) => follower.toJsonLd() ), ); - assert.deepStrictEqual(await repoA.countFollowers(), 2); - assert.ok(await repoA.hasFollower(oldFollower.id!)); - assert.ok(await repoA.hasFollower(reassignedFollower.id!)); + assert.deepStrictEqual(await repoA.countFollowers("bot"), 2); + assert.ok(await repoA.hasFollower("bot", oldFollower.id!)); + assert.ok(await repoA.hasFollower("bot", reassignedFollower.id!)); assert.deepStrictEqual(followers, [ await oldFollower.toJsonLd(), await reassignedFollower.toJsonLd(), ]); assert.deepStrictEqual( - await (await repoA.removeFollower(followB, oldFollower.id!)) + await (await repoA.removeFollower("bot", followB, oldFollower.id!)) ?.toJsonLd(), await oldFollower.toJsonLd(), ); - assert.deepStrictEqual(await repoA.countFollowers(), 1); - assert.deepStrictEqual(await repoA.hasFollower(oldFollower.id!), false); + assert.deepStrictEqual(await repoA.countFollowers("bot"), 1); + assert.deepStrictEqual( + await repoA.hasFollower("bot", oldFollower.id!), + false, + ); } finally { await adminSql.unsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); await Promise.all([sqlA.end(), sqlB.end(), adminSql.end()]); @@ -717,9 +734,9 @@ if (postgresUrl == null) { try { const repo = harness.repository; - assert.deepStrictEqual(await repo.getKeyPairs(), undefined); - await repo.setKeyPairs(keyPairs); - assert.deepStrictEqual(await repo.getKeyPairs(), keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), undefined); + await repo.setKeyPairs("bot", keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), keyPairs); const messageA = new Create({ id: new URL( @@ -781,13 +798,23 @@ if (postgresUrl == null) { updated: Temporal.Instant.from("2025-01-02T12:00:00Z"), }); - assert.deepStrictEqual(await repo.countMessages(), 0); - await repo.addMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0", messageA); - await repo.addMessage("0194244f-d800-7873-8993-ef71ccd47306", messageB); - assert.deepStrictEqual(await repo.countMessages(), 2); + assert.deepStrictEqual(await repo.countMessages("bot"), 0); + await repo.addMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + messageA, + ); + await repo.addMessage( + "bot", + "0194244f-d800-7873-8993-ef71ccd47306", + messageB, + ); + assert.deepStrictEqual(await repo.countMessages("bot"), 2); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages({ order: "oldest" }))).map(( + (await Array.fromAsync( + repo.getMessages("bot", { order: "oldest" }), + )).map(( message, ) => message.toJsonLd()), ), @@ -796,7 +823,7 @@ if (postgresUrl == null) { assert.deepStrictEqual( await Promise.all( (await Array.fromAsync( - repo.getMessages({ + repo.getMessages("bot", { since: Temporal.Instant.from("2025-01-02T00:00:00Z"), }), )).map((message) => message.toJsonLd()), @@ -805,6 +832,7 @@ if (postgresUrl == null) { ); assert.ok( await repo.updateMessage( + "bot", "0194244f-d800-7873-8993-ef71ccd47306", async (message) => message.clone({ @@ -814,18 +842,22 @@ if (postgresUrl == null) { ), ); assert.deepStrictEqual( - await (await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306")) + await (await repo.getMessage( + "bot", + "0194244f-d800-7873-8993-ef71ccd47306", + )) ?.toJsonLd(), await messageB2.toJsonLd(), ); assert.deepStrictEqual( await (await repo.removeMessage( + "bot", "01941f29-7c00-7fe8-ab0a-7b593990a3c0", )) ?.toJsonLd(), await messageA.toJsonLd(), ); - assert.deepStrictEqual(await repo.countMessages(), 1); + assert.deepStrictEqual(await repo.countMessages("bot"), 1); const followerA = new Person({ id: new URL("https://example.com/ap/actor/alice"), @@ -842,58 +874,67 @@ if (postgresUrl == null) { "https://example.com/ap/follow/a3d4cc4f-af93-4a9f-a7b3-0b7c0fe4901d", ); - await repo.addFollower(followA, followerA); - await repo.addFollower(followB, followerB); - assert.ok(await repo.hasFollower(followerA.id!)); - assert.deepStrictEqual(await repo.countFollowers(), 2); + await repo.addFollower("bot", followA, followerA); + await repo.addFollower("bot", followB, followerB); + assert.ok(await repo.hasFollower("bot", followerA.id!)); + assert.deepStrictEqual(await repo.countFollowers("bot"), 2); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getFollowers({ offset: 1 }))).map(( - follower, - ) => follower.toJsonLd()), + (await Array.fromAsync(repo.getFollowers("bot", { offset: 1 }))) + .map(( + follower, + ) => follower.toJsonLd()), ), [await followerB.toJsonLd()], ); assert.deepStrictEqual( - await repo.removeFollower(followA, followerB.id!), + await repo.removeFollower("bot", followA, followerB.id!), undefined, ); assert.deepStrictEqual( - await (await repo.removeFollower(followA, followerA.id!))?.toJsonLd(), + await (await repo.removeFollower("bot", followA, followerA.id!)) + ?.toJsonLd(), await followerA.toJsonLd(), ); - assert.deepStrictEqual(await repo.countFollowers(), 1); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); const followA2 = new URL( "https://example.com/ap/follow/6eedf12f-32aa-4f1d-b6ca-d5bf34c4d149", ); - await repo.addFollower(followA, followerA); - await repo.addFollower(followA2, followerA); - assert.deepStrictEqual(await repo.countFollowers(), 2); - assert.ok(await repo.hasFollower(followerA.id!)); + await repo.addFollower("bot", followA, followerA); + await repo.addFollower("bot", followA2, followerA); + assert.deepStrictEqual(await repo.countFollowers("bot"), 2); + assert.ok(await repo.hasFollower("bot", followerA.id!)); assert.deepStrictEqual( - await (await repo.removeFollower(followA, followerA.id!))?.toJsonLd(), + await (await repo.removeFollower("bot", followA, followerA.id!)) + ?.toJsonLd(), await followerA.toJsonLd(), ); - assert.ok(await repo.hasFollower(followerA.id!)); - assert.deepStrictEqual(await repo.countFollowers(), 2); + assert.ok(await repo.hasFollower("bot", followerA.id!)); + assert.deepStrictEqual(await repo.countFollowers("bot"), 2); assert.deepStrictEqual( - await (await repo.removeFollower(followA2, followerA.id!)) + await (await repo.removeFollower("bot", followA2, followerA.id!)) ?.toJsonLd(), await followerA.toJsonLd(), ); - assert.deepStrictEqual(await repo.countFollowers(), 1); - assert.deepStrictEqual(await repo.hasFollower(followerA.id!), false); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerA.id!), + false, + ); - await repo.addFollower(followA, followerA); - assert.deepStrictEqual(await repo.countFollowers(), 2); - await repo.addFollower(followA, followerB); - assert.deepStrictEqual(await repo.countFollowers(), 1); - assert.deepStrictEqual(await repo.hasFollower(followerA.id!), false); - assert.ok(await repo.hasFollower(followerB.id!)); + await repo.addFollower("bot", followA, followerA); + assert.deepStrictEqual(await repo.countFollowers("bot"), 2); + await repo.addFollower("bot", followA, followerB); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerA.id!), + false, + ); + assert.ok(await repo.hasFollower("bot", followerB.id!)); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getFollowers())).map((follower) => + (await Array.fromAsync(repo.getFollowers("bot"))).map((follower) => follower.toJsonLd() ), ), @@ -908,40 +949,51 @@ if (postgresUrl == null) { object: new URL("https://example.com/ap/actor/john"), }); await repo.addSentFollow( + "bot", "03a395a2-353a-4894-afdb-2cab31a7b004", sentFollow, ); assert.deepStrictEqual( await (await repo.getSentFollow( + "bot", "03a395a2-353a-4894-afdb-2cab31a7b004", )) ?.toJsonLd(), await sentFollow.toJsonLd(), ); - await repo.removeSentFollow("03a395a2-353a-4894-afdb-2cab31a7b004"); + await repo.removeSentFollow( + "bot", + "03a395a2-353a-4894-afdb-2cab31a7b004", + ); assert.deepStrictEqual( - await repo.getSentFollow("03a395a2-353a-4894-afdb-2cab31a7b004"), + await repo.getSentFollow( + "bot", + "03a395a2-353a-4894-afdb-2cab31a7b004", + ), undefined, ); const followeeId = new URL("https://example.com/ap/actor/john"); - await repo.addFollowee(followeeId, sentFollow); + await repo.addFollowee("bot", followeeId, sentFollow); assert.deepStrictEqual( - await (await repo.getFollowee(followeeId))?.toJsonLd(), + await (await repo.getFollowee("bot", followeeId))?.toJsonLd(), await sentFollow.toJsonLd(), ); - await repo.removeFollowee(followeeId); - assert.deepStrictEqual(await repo.getFollowee(followeeId), undefined); + await repo.removeFollowee("bot", followeeId); + assert.deepStrictEqual( + await repo.getFollowee("bot", followeeId), + undefined, + ); const messageId = "01945678-1234-7890-abcd-ef0123456789"; const voter1 = new URL("https://example.com/ap/actor/alice"); const voter2 = new URL("https://example.com/ap/actor/bob"); - await repo.vote(messageId, voter1, "option1"); - await repo.vote(messageId, voter1, "option1"); - await repo.vote(messageId, voter1, "option2"); - await repo.vote(messageId, voter2, "option1"); - assert.deepStrictEqual(await repo.countVoters(messageId), 2); - assert.deepStrictEqual(await repo.countVotes(messageId), { + await repo.vote("bot", messageId, voter1, "option1"); + await repo.vote("bot", messageId, voter1, "option1"); + await repo.vote("bot", messageId, voter1, "option2"); + await repo.vote("bot", messageId, voter2, "option1"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId), 2); + assert.deepStrictEqual(await repo.countVotes("bot", messageId), { "option1": 2, "option2": 1, }); @@ -952,8 +1004,8 @@ if (postgresUrl == null) { schema: harness.schema, maxConnections: 1, }); - assert.deepStrictEqual(await repo2.getKeyPairs(), keyPairs); - assert.deepStrictEqual(await repo2.countMessages(), 1); + assert.deepStrictEqual(await repo2.getKeyPairs("bot"), keyPairs); + assert.deepStrictEqual(await repo2.countMessages("bot"), 1); await repo2.close(); } finally { await harness.cleanup(); @@ -965,7 +1017,7 @@ if (postgresUrl == null) { const sql = createSql(postgresUrl); try { const repo = new PostgresRepository({ sql, schema }); - await repo.countMessages(); + await repo.countMessages("bot"); await repo.close(); const result = await sql`SELECT 1 AS value`; diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index 7006ecc..5e87382 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -13,11 +13,12 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { - Repository, - RepositoryGetFollowersOptions, - RepositoryGetMessagesOptions, - Uuid, +import { + ActorScopedRepository, + type Repository, + type RepositoryGetFollowersOptions, + type RepositoryGetMessagesOptions, + type Uuid, } from "@fedify/botkit/repository"; import { exportJwk, importJwk } from "@fedify/fedify/sig"; import { Temporal, toTemporalInstant } from "@js-temporal/polyfill"; @@ -140,9 +141,11 @@ export async function initializePostgresRepositorySchema( await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."key_pairs" ( - position INTEGER PRIMARY KEY, + bot_id TEXT NOT NULL, + position INTEGER NOT NULL, private_key_jwk JSONB NOT NULL, - public_key_jwk JSONB NOT NULL + public_key_jwk JSONB NOT NULL, + PRIMARY KEY (bot_id, position) )`, [], prepare, @@ -150,9 +153,11 @@ export async function initializePostgresRepositorySchema( await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."messages" ( - id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + id TEXT NOT NULL, activity_json JSONB NOT NULL, - published BIGINT + published BIGINT, + PRIMARY KEY (bot_id, id) )`, [], prepare, @@ -160,15 +165,17 @@ export async function initializePostgresRepositorySchema( await execute( sql, `CREATE INDEX IF NOT EXISTS "idx_messages_published" - ON "${validatedSchema}"."messages" (published, id)`, + ON "${validatedSchema}"."messages" (bot_id, published, id)`, [], prepare, ); await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."followers" ( - follower_id TEXT PRIMARY KEY, - actor_json JSONB NOT NULL + bot_id TEXT NOT NULL, + follower_id TEXT NOT NULL, + actor_json JSONB NOT NULL, + PRIMARY KEY (bot_id, follower_id) )`, [], prepare, @@ -176,9 +183,12 @@ export async function initializePostgresRepositorySchema( await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."follow_requests" ( - follow_request_id TEXT PRIMARY KEY, - follower_id TEXT NOT NULL - REFERENCES "${validatedSchema}"."followers" (follower_id) + bot_id TEXT NOT NULL, + follow_request_id TEXT NOT NULL, + follower_id TEXT NOT NULL, + PRIMARY KEY (bot_id, follow_request_id), + FOREIGN KEY (bot_id, follower_id) + REFERENCES "${validatedSchema}"."followers" (bot_id, follower_id) ON DELETE CASCADE )`, [], @@ -187,15 +197,17 @@ export async function initializePostgresRepositorySchema( await execute( sql, `CREATE INDEX IF NOT EXISTS "idx_follow_requests_follower" - ON "${validatedSchema}"."follow_requests" (follower_id)`, + ON "${validatedSchema}"."follow_requests" (bot_id, follower_id)`, [], prepare, ); await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."sent_follows" ( - id TEXT PRIMARY KEY, - follow_json JSONB NOT NULL + bot_id TEXT NOT NULL, + id TEXT NOT NULL, + follow_json JSONB NOT NULL, + PRIMARY KEY (bot_id, id) )`, [], prepare, @@ -203,19 +215,29 @@ export async function initializePostgresRepositorySchema( await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."followees" ( - followee_id TEXT PRIMARY KEY, - follow_json JSONB NOT NULL + bot_id TEXT NOT NULL, + followee_id TEXT NOT NULL, + follow_json JSONB NOT NULL, + PRIMARY KEY (bot_id, followee_id) )`, [], prepare, ); + await execute( + sql, + `CREATE INDEX IF NOT EXISTS "idx_followees_followee_id" + ON "${validatedSchema}"."followees" (followee_id)`, + [], + prepare, + ); await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."poll_votes" ( + bot_id TEXT NOT NULL, message_id TEXT NOT NULL, voter_id TEXT NOT NULL, option TEXT NOT NULL, - PRIMARY KEY (message_id, voter_id, option) + PRIMARY KEY (bot_id, message_id, voter_id, option) )`, [], prepare, @@ -223,7 +245,7 @@ export async function initializePostgresRepositorySchema( await execute( sql, `CREATE INDEX IF NOT EXISTS "idx_poll_votes_message_option" - ON "${validatedSchema}"."poll_votes" (message_id, option)`, + ON "${validatedSchema}"."poll_votes" (bot_id, message_id, option)`, [], prepare, ); @@ -295,19 +317,27 @@ export class PostgresRepository implements Repository, AsyncDisposable { } } - async setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { + async setKeyPairs( + identifier: string, + keyPairs: CryptoKeyPair[], + ): Promise { await this.ensureReady(); await this.sql.begin(async (sql) => { - await this.query(sql, `DELETE FROM ${this.table("key_pairs")}`); + await this.query( + sql, + `DELETE FROM ${this.table("key_pairs")} WHERE bot_id = $1`, + [identifier], + ); for (const [position, keyPair] of keyPairs.entries()) { const privateJwk = await exportJwk(keyPair.privateKey); const publicJwk = await exportJwk(keyPair.publicKey); await this.query( sql, `INSERT INTO ${this.table("key_pairs")} - (position, private_key_jwk, public_key_jwk) - VALUES ($1, $2::jsonb, $3::jsonb)`, + (bot_id, position, private_key_jwk, public_key_jwk) + VALUES ($1, $2, $3::jsonb, $4::jsonb)`, [ + identifier, position, serializeJson(privateJwk), serializeJson(publicJwk), @@ -317,7 +347,7 @@ export class PostgresRepository implements Repository, AsyncDisposable { }); } - async getKeyPairs(): Promise { + async getKeyPairs(identifier: string): Promise { await this.ensureReady(); const rows = await this.query<{ readonly private_key_jwk: unknown; @@ -326,7 +356,9 @@ export class PostgresRepository implements Repository, AsyncDisposable { this.sql, `SELECT private_key_jwk, public_key_jwk FROM ${this.table("key_pairs")} + WHERE bot_id = $1 ORDER BY position ASC`, + [identifier], ); if (rows.length < 1) return undefined; const keyPairs: CryptoKeyPair[] = []; @@ -344,13 +376,19 @@ export class PostgresRepository implements Repository, AsyncDisposable { return keyPairs; } - async addMessage(id: Uuid, activity: Create | Announce): Promise { + async addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise { await this.ensureReady(); await this.query( this.sql, - `INSERT INTO ${this.table("messages")} (id, activity_json, published) - VALUES ($1, $2::jsonb, $3)`, + `INSERT INTO ${this.table("messages")} + (bot_id, id, activity_json, published) + VALUES ($1, $2, $3::jsonb, $4)`, [ + identifier, id, serializeJson(await activity.toJsonLd({ format: "compact" })), activity.published?.epochMilliseconds ?? null, @@ -359,6 +397,7 @@ export class PostgresRepository implements Repository, AsyncDisposable { } async updateMessage( + identifier: string, id: Uuid, updater: ( existing: Create | Announce, @@ -370,9 +409,9 @@ export class PostgresRepository implements Repository, AsyncDisposable { sql, `SELECT activity_json FROM ${this.table("messages")} - WHERE id = $1 + WHERE bot_id = $1 AND id = $2 FOR UPDATE`, - [id], + [identifier, id], ); const row = rows[0]; if (row == null) return false; @@ -385,10 +424,11 @@ export class PostgresRepository implements Repository, AsyncDisposable { `UPDATE ${this.table("messages")} SET activity_json = $1::jsonb, published = $2 - WHERE id = $3`, + WHERE bot_id = $3 AND id = $4`, [ serializeJson(await updated.toJsonLd({ format: "compact" })), updated.published?.epochMilliseconds ?? null, + identifier, id, ], ); @@ -396,27 +436,31 @@ export class PostgresRepository implements Repository, AsyncDisposable { }); } - async removeMessage(id: Uuid): Promise { + async removeMessage( + identifier: string, + id: Uuid, + ): Promise { await this.ensureReady(); const rows = await this.query<{ readonly activity_json: unknown }>( this.sql, `DELETE FROM ${this.table("messages")} - WHERE id = $1 + WHERE bot_id = $1 AND id = $2 RETURNING activity_json`, - [id], + [identifier, id], ); return await parseActivity(rows[0]?.activity_json); } async *getMessages( + identifier: string, options: RepositoryGetMessagesOptions = {}, ): AsyncIterable { await this.ensureReady(); const { order = "newest", since, until, limit } = options; - const parameters: QueryParameter[] = []; + const parameters: QueryParameter[] = [identifier]; let query = `SELECT activity_json FROM ${this.table("messages")} - WHERE TRUE`; + WHERE bot_id = $1`; if (since != null) { parameters.push(since.epochMilliseconds); query += ` AND published >= $${parameters.length}`; @@ -443,29 +487,38 @@ export class PostgresRepository implements Repository, AsyncDisposable { } } - async getMessage(id: Uuid): Promise { + async getMessage( + identifier: string, + id: Uuid, + ): Promise { await this.ensureReady(); const rows = await this.query<{ readonly activity_json: unknown }>( this.sql, `SELECT activity_json FROM ${this.table("messages")} - WHERE id = $1`, - [id], + WHERE bot_id = $1 AND id = $2`, + [identifier, id], ); return await parseActivity(rows[0]?.activity_json); } - async countMessages(): Promise { + async countMessages(identifier: string): Promise { await this.ensureReady(); const rows = await this.query<{ readonly count: number }>( this.sql, `SELECT COUNT(*)::integer AS count - FROM ${this.table("messages")}`, + FROM ${this.table("messages")} + WHERE bot_id = $1`, + [identifier], ); return rows[0]?.count ?? 0; } - async addFollower(followId: URL, follower: Actor): Promise { + async addFollower( + identifier: string, + followId: URL, + follower: Actor, + ): Promise { await this.ensureReady(); if (follower.id == null) { throw new TypeError("The follower ID is missing."); @@ -473,97 +526,101 @@ export class PostgresRepository implements Repository, AsyncDisposable { const followerId = follower.id; const followerJson = await follower.toJsonLd({ format: "compact" }); await this.sql.begin(async (sql) => { - await this.lockFollowRequest(sql, followId); + await this.lockFollowRequest(sql, identifier, followId); const rows = await this.query<{ readonly follower_id: string }>( sql, `SELECT follower_id FROM ${this.table("follow_requests")} - WHERE follow_request_id = $1 + WHERE bot_id = $1 AND follow_request_id = $2 FOR UPDATE`, - [followId.href], + [identifier, followId.href], ); const previousFollowerId = rows[0]?.follower_id; - await this.lockFollowers(sql, [ + await this.lockFollowers(sql, identifier, [ followerId.href, ...(previousFollowerId == null ? [] : [previousFollowerId]), ]); await this.query( sql, - `INSERT INTO ${this.table("followers")} (follower_id, actor_json) - VALUES ($1, $2::jsonb) - ON CONFLICT (follower_id) + `INSERT INTO ${this.table("followers")} + (bot_id, follower_id, actor_json) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (bot_id, follower_id) DO UPDATE SET actor_json = EXCLUDED.actor_json`, - [followerId.href, serializeJson(followerJson)], + [identifier, followerId.href, serializeJson(followerJson)], ); await this.query( sql, - `INSERT INTO ${ - this.table("follow_requests") - } (follow_request_id, follower_id) - VALUES ($1, $2) - ON CONFLICT (follow_request_id) + `INSERT INTO ${this.table("follow_requests")} + (bot_id, follow_request_id, follower_id) + VALUES ($1, $2, $3) + ON CONFLICT (bot_id, follow_request_id) DO UPDATE SET follower_id = EXCLUDED.follower_id`, - [followId.href, followerId.href], + [identifier, followId.href, followerId.href], ); if ( previousFollowerId != null && previousFollowerId !== followerId.href ) { - await this.cleanupFollower(sql, previousFollowerId); + await this.cleanupFollower(sql, identifier, previousFollowerId); } }); } async removeFollower( + identifier: string, followId: URL, followerId: URL, ): Promise { await this.ensureReady(); return await this.sql.begin(async (sql) => { - await this.lockFollowRequest(sql, followId); + await this.lockFollowRequest(sql, identifier, followId); const rows = await this.query<{ readonly actor_json: unknown }>( sql, `SELECT f.actor_json FROM ${this.table("follow_requests")} AS fr JOIN ${this.table("followers")} AS f - ON f.follower_id = fr.follower_id - WHERE fr.follow_request_id = $1 - AND fr.follower_id = $2 + ON f.bot_id = fr.bot_id AND f.follower_id = fr.follower_id + WHERE fr.bot_id = $1 + AND fr.follow_request_id = $2 + AND fr.follower_id = $3 FOR UPDATE`, - [followId.href, followerId.href], + [identifier, followId.href, followerId.href], ); const row = rows[0]; if (row == null) return undefined; await this.query( sql, `DELETE FROM ${this.table("follow_requests")} - WHERE follow_request_id = $1`, - [followId.href], + WHERE bot_id = $1 AND follow_request_id = $2`, + [identifier, followId.href], ); - await this.cleanupFollower(sql, followerId.href); + await this.cleanupFollower(sql, identifier, followerId.href); return await parseActor(row.actor_json); }); } - async hasFollower(followerId: URL): Promise { + async hasFollower(identifier: string, followerId: URL): Promise { await this.ensureReady(); const rows = await this.query<{ readonly exists: number }>( this.sql, `SELECT 1 AS exists FROM ${this.table("followers")} - WHERE follower_id = $1`, - [followerId.href], + WHERE bot_id = $1 AND follower_id = $2`, + [identifier, followerId.href], ); return rows.length > 0; } async *getFollowers( + identifier: string, options: RepositoryGetFollowersOptions = {}, ): AsyncIterable { await this.ensureReady(); const { offset = 0, limit } = options; - const parameters: QueryParameter[] = []; + const parameters: QueryParameter[] = [identifier]; let query = `SELECT actor_json FROM ${this.table("followers")} + WHERE bot_id = $1 ORDER BY follower_id ASC`; if (limit != null) { parameters.push(limit, offset); @@ -583,116 +640,166 @@ export class PostgresRepository implements Repository, AsyncDisposable { } } - async countFollowers(): Promise { + async countFollowers(identifier: string): Promise { await this.ensureReady(); const rows = await this.query<{ readonly count: number }>( this.sql, `SELECT COUNT(*)::integer AS count - FROM ${this.table("followers")}`, + FROM ${this.table("followers")} + WHERE bot_id = $1`, + [identifier], ); return rows[0]?.count ?? 0; } - async addSentFollow(id: Uuid, follow: Follow): Promise { + async addSentFollow( + identifier: string, + id: Uuid, + follow: Follow, + ): Promise { await this.ensureReady(); await this.query( this.sql, - `INSERT INTO ${this.table("sent_follows")} (id, follow_json) - VALUES ($1, $2::jsonb) - ON CONFLICT (id) + `INSERT INTO ${this.table("sent_follows")} (bot_id, id, follow_json) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (bot_id, id) DO UPDATE SET follow_json = EXCLUDED.follow_json`, - [id, serializeJson(await follow.toJsonLd({ format: "compact" }))], + [ + identifier, + id, + serializeJson(await follow.toJsonLd({ format: "compact" })), + ], ); } - async removeSentFollow(id: Uuid): Promise { + async removeSentFollow( + identifier: string, + id: Uuid, + ): Promise { await this.ensureReady(); const rows = await this.query<{ readonly follow_json: unknown }>( this.sql, `DELETE FROM ${this.table("sent_follows")} - WHERE id = $1 + WHERE bot_id = $1 AND id = $2 RETURNING follow_json`, - [id], + [identifier, id], ); return await parseFollow(rows[0]?.follow_json); } - async getSentFollow(id: Uuid): Promise { + async getSentFollow( + identifier: string, + id: Uuid, + ): Promise { await this.ensureReady(); const rows = await this.query<{ readonly follow_json: unknown }>( this.sql, `SELECT follow_json FROM ${this.table("sent_follows")} - WHERE id = $1`, - [id], + WHERE bot_id = $1 AND id = $2`, + [identifier, id], ); return await parseFollow(rows[0]?.follow_json); } - async addFollowee(followeeId: URL, follow: Follow): Promise { + async addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise { await this.ensureReady(); await this.query( this.sql, - `INSERT INTO ${this.table("followees")} (followee_id, follow_json) - VALUES ($1, $2::jsonb) - ON CONFLICT (followee_id) + `INSERT INTO ${this.table("followees")} + (bot_id, followee_id, follow_json) + VALUES ($1, $2, $3::jsonb) + ON CONFLICT (bot_id, followee_id) DO UPDATE SET follow_json = EXCLUDED.follow_json`, [ + identifier, followeeId.href, serializeJson(await follow.toJsonLd({ format: "compact" })), ], ); } - async removeFollowee(followeeId: URL): Promise { + async removeFollowee( + identifier: string, + followeeId: URL, + ): Promise { await this.ensureReady(); const rows = await this.query<{ readonly follow_json: unknown }>( this.sql, `DELETE FROM ${this.table("followees")} - WHERE followee_id = $1 + WHERE bot_id = $1 AND followee_id = $2 RETURNING follow_json`, - [followeeId.href], + [identifier, followeeId.href], ); return await parseFollow(rows[0]?.follow_json); } - async getFollowee(followeeId: URL): Promise { + async getFollowee( + identifier: string, + followeeId: URL, + ): Promise { await this.ensureReady(); const rows = await this.query<{ readonly follow_json: unknown }>( this.sql, `SELECT follow_json FROM ${this.table("followees")} - WHERE followee_id = $1`, - [followeeId.href], + WHERE bot_id = $1 AND followee_id = $2`, + [identifier, followeeId.href], ); return await parseFollow(rows[0]?.follow_json); } - async vote(messageId: Uuid, voterId: URL, option: string): Promise { + async *findFollowedBots(followeeId: URL): AsyncIterable { + await this.ensureReady(); + const rows = await this.query<{ readonly bot_id: string }>( + this.sql, + `SELECT bot_id + FROM ${this.table("followees")} + WHERE followee_id = $1 + ORDER BY bot_id ASC`, + [followeeId.href], + ); + for (const row of rows) yield row.bot_id; + } + + async vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise { await this.ensureReady(); await this.query( this.sql, - `INSERT INTO ${this.table("poll_votes")} (message_id, voter_id, option) - VALUES ($1, $2, $3) - ON CONFLICT (message_id, voter_id, option) + `INSERT INTO ${this.table("poll_votes")} + (bot_id, message_id, voter_id, option) + VALUES ($1, $2, $3, $4) + ON CONFLICT (bot_id, message_id, voter_id, option) DO NOTHING`, - [messageId, voterId.href, option], + [identifier, messageId, voterId.href, option], ); } - async countVoters(messageId: Uuid): Promise { + async countVoters(identifier: string, messageId: Uuid): Promise { await this.ensureReady(); const rows = await this.query<{ readonly count: number }>( this.sql, `SELECT COUNT(DISTINCT voter_id)::integer AS count FROM ${this.table("poll_votes")} - WHERE message_id = $1`, - [messageId], + WHERE bot_id = $1 AND message_id = $2`, + [identifier, messageId], ); return rows[0]?.count ?? 0; } - async countVotes(messageId: Uuid): Promise>> { + async countVotes( + identifier: string, + messageId: Uuid, + ): Promise>> { await this.ensureReady(); const rows = await this.query<{ readonly option: string; @@ -701,10 +808,10 @@ export class PostgresRepository implements Repository, AsyncDisposable { this.sql, `SELECT option, COUNT(*)::integer AS count FROM ${this.table("poll_votes")} - WHERE message_id = $1 + WHERE bot_id = $1 AND message_id = $2 GROUP BY option ORDER BY option ASC`, - [messageId], + [identifier, messageId], ); const result: Record = {}; for (const row of rows) { @@ -713,12 +820,17 @@ export class PostgresRepository implements Repository, AsyncDisposable { return result; } + forIdentifier(identifier: string): ActorScopedRepository { + return new ActorScopedRepository(this, identifier); + } + private table(name: string): string { return `"${this.schema}"."${name}"`; } private async lockFollowRequest( sql: Queryable, + identifier: string, followId: URL, ): Promise { await this.query( @@ -726,13 +838,14 @@ export class PostgresRepository implements Repository, AsyncDisposable { `SELECT pg_catalog.pg_advisory_xact_lock($1, pg_catalog.hashtext($2))`, [ followRequestAdvisoryLockNamespace, - `${this.schema}:${followId.href}`, + `${this.schema}:${identifier}:${followId.href}`, ], ); } private async lockFollower( sql: Queryable, + identifier: string, followerId: string, ): Promise { await this.query( @@ -740,36 +853,40 @@ export class PostgresRepository implements Repository, AsyncDisposable { `SELECT pg_catalog.pg_advisory_xact_lock($1, pg_catalog.hashtext($2))`, [ followerAdvisoryLockNamespace, - `${this.schema}:${followerId}`, + `${this.schema}:${identifier}:${followerId}`, ], ); } private async lockFollowers( sql: Queryable, + identifier: string, followerIds: readonly string[], ): Promise { const uniqueFollowerIds = [...new Set(followerIds)].sort(); for (const followerId of uniqueFollowerIds) { - await this.lockFollower(sql, followerId); + await this.lockFollower(sql, identifier, followerId); } } private async cleanupFollower( sql: Queryable, + identifier: string, followerId: string, ): Promise { - await this.lockFollower(sql, followerId); + await this.lockFollower(sql, identifier, followerId); await this.query( sql, `DELETE FROM ${this.table("followers")} - WHERE follower_id = $1 + WHERE bot_id = $1 + AND follower_id = $2 AND NOT EXISTS ( SELECT 1 FROM ${this.table("follow_requests")} - WHERE follower_id = $1 + WHERE bot_id = $1 + AND follower_id = $2 )`, - [followerId], + [identifier, followerId], ); } diff --git a/packages/botkit-sqlite/src/mod.test.ts b/packages/botkit-sqlite/src/mod.test.ts index c18b5e6..3e8627c 100644 --- a/packages/botkit-sqlite/src/mod.test.ts +++ b/packages/botkit-sqlite/src/mod.test.ts @@ -73,9 +73,9 @@ describe("SqliteRepository", () => { test("key pairs", async () => { const repo = createSqliteRepository(); try { - assert.deepStrictEqual(await repo.getKeyPairs(), undefined); - await repo.setKeyPairs(keyPairs); - assert.deepStrictEqual(await repo.getKeyPairs(), keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), undefined); + await repo.setKeyPairs("bot", keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), keyPairs); } finally { repo.close(); } @@ -84,9 +84,9 @@ describe("SqliteRepository", () => { test("messages basic operations", async () => { const repo = createSqliteRepository(); try { - assert.deepStrictEqual(await repo.countMessages(), 0); + assert.deepStrictEqual(await repo.countMessages("bot"), 0); assert.deepStrictEqual( - await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0"), + await repo.getMessage("bot", "01941f29-7c00-7fe8-ab0a-7b593990a3c0"), undefined, ); @@ -110,10 +110,15 @@ describe("SqliteRepository", () => { published: Temporal.Instant.from("2025-01-01T00:00:00Z"), }); - await repo.addMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0", message); - assert.deepStrictEqual(await repo.countMessages(), 1); + await repo.addMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + message, + ); + assert.deepStrictEqual(await repo.countMessages("bot"), 1); const retrieved = await repo.getMessage( + "bot", "01941f29-7c00-7fe8-ab0a-7b593990a3c0", ); assert.deepStrictEqual( @@ -122,13 +127,14 @@ describe("SqliteRepository", () => { ); const removed = await repo.removeMessage( + "bot", "01941f29-7c00-7fe8-ab0a-7b593990a3c0", ); assert.deepStrictEqual( await removed?.toJsonLd(), await message.toJsonLd(), ); - assert.deepStrictEqual(await repo.countMessages(), 0); + assert.deepStrictEqual(await repo.countMessages("bot"), 0); } finally { repo.close(); } @@ -145,23 +151,29 @@ describe("SqliteRepository", () => { "https://example.com/ap/follow/be2da56a-0ea3-4a6a-9dff-2a1837be67e0", ); - assert.deepStrictEqual(await repo.countFollowers(), 0); - assert.deepStrictEqual(await repo.hasFollower(follower.id!), false); + assert.deepStrictEqual(await repo.countFollowers("bot"), 0); + assert.deepStrictEqual( + await repo.hasFollower("bot", follower.id!), + false, + ); - await repo.addFollower(followRequestId, follower); - assert.deepStrictEqual(await repo.countFollowers(), 1); - assert.ok(await repo.hasFollower(follower.id!)); + await repo.addFollower("bot", followRequestId, follower); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); + assert.ok(await repo.hasFollower("bot", follower.id!)); - const followers = await Array.fromAsync(repo.getFollowers()); + const followers = await Array.fromAsync(repo.getFollowers("bot")); assert.deepStrictEqual(followers.length, 1); assert.deepStrictEqual( await followers[0].toJsonLd(), await follower.toJsonLd(), ); - await repo.removeFollower(followRequestId, follower.id!); - assert.deepStrictEqual(await repo.countFollowers(), 0); - assert.deepStrictEqual(await repo.hasFollower(follower.id!), false); + await repo.removeFollower("bot", followRequestId, follower.id!); + assert.deepStrictEqual(await repo.countFollowers("bot"), 0); + assert.deepStrictEqual( + await repo.hasFollower("bot", follower.id!), + false, + ); } finally { repo.close(); } @@ -175,34 +187,34 @@ describe("SqliteRepository", () => { const voter2 = new URL("https://example.com/ap/actor/bob"); // Initially, no votes exist - assert.deepStrictEqual(await repo.countVoters(messageId), 0); - assert.deepStrictEqual(await repo.countVotes(messageId), {}); + assert.deepStrictEqual(await repo.countVoters("bot", messageId), 0); + assert.deepStrictEqual(await repo.countVotes("bot", messageId), {}); // Single voter, single option - await repo.vote(messageId, voter1, "option1"); - assert.deepStrictEqual(await repo.countVoters(messageId), 1); - assert.deepStrictEqual(await repo.countVotes(messageId), { + await repo.vote("bot", messageId, voter1, "option1"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId), 1); + assert.deepStrictEqual(await repo.countVotes("bot", messageId), { "option1": 1, }); // Same voter votes for same option again (should be ignored) - await repo.vote(messageId, voter1, "option1"); - assert.deepStrictEqual(await repo.countVoters(messageId), 1); - assert.deepStrictEqual(await repo.countVotes(messageId), { + await repo.vote("bot", messageId, voter1, "option1"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId), 1); + assert.deepStrictEqual(await repo.countVotes("bot", messageId), { "option1": 1, }); // Different voter votes for same option - await repo.vote(messageId, voter2, "option1"); - assert.deepStrictEqual(await repo.countVoters(messageId), 2); - assert.deepStrictEqual(await repo.countVotes(messageId), { + await repo.vote("bot", messageId, voter2, "option1"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId), 2); + assert.deepStrictEqual(await repo.countVotes("bot", messageId), { "option1": 2, }); // Same voter votes for different option (multiple choice) - await repo.vote(messageId, voter1, "option2"); - assert.deepStrictEqual(await repo.countVoters(messageId), 2); - assert.deepStrictEqual(await repo.countVotes(messageId), { + await repo.vote("bot", messageId, voter1, "option2"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId), 2); + assert.deepStrictEqual(await repo.countVotes("bot", messageId), { "option1": 2, "option2": 1, }); @@ -218,7 +230,7 @@ describe("SqliteRepository", () => { try { // Create and populate first repository const repo1 = createSqliteRepository({ path: dbPath }); - await repo1.setKeyPairs(keyPairs); + await repo1.setKeyPairs("bot", keyPairs); const message = new Create({ id: new URL( @@ -240,17 +252,24 @@ describe("SqliteRepository", () => { published: Temporal.Instant.from("2025-01-01T00:00:00Z"), }); - await repo1.addMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0", message); + await repo1.addMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + message, + ); repo1.close(); // Open the same database file with a new repository instance const repo2 = createSqliteRepository({ path: dbPath }); try { // Verify data persists - assert.deepStrictEqual(await repo2.getKeyPairs(), keyPairs); - assert.deepStrictEqual(await repo2.countMessages(), 1); + assert.deepStrictEqual(await repo2.getKeyPairs("bot"), keyPairs); + assert.deepStrictEqual(await repo2.countMessages("bot"), 1); assert.deepStrictEqual( - await (await repo2.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo2.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await message.toJsonLd(), ); diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index 8423c18..33d4350 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -13,11 +13,12 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { - Repository, - RepositoryGetFollowersOptions, - RepositoryGetMessagesOptions, - Uuid, +import { + ActorScopedRepository, + type Repository, + type RepositoryGetFollowersOptions, + type RepositoryGetMessagesOptions, + type Uuid, } from "@fedify/botkit/repository"; import { exportJwk, importJwk } from "@fedify/fedify/sig"; import { @@ -94,90 +95,124 @@ export class SqliteRepository implements Repository, Disposable { this.db.exec(` CREATE TABLE IF NOT EXISTS key_pairs ( id INTEGER PRIMARY KEY, + bot_id TEXT NOT NULL, private_key_jwk TEXT NOT NULL, public_key_jwk TEXT NOT NULL ) `); + // Create index on bot_id for efficient per-bot lookup + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_key_pairs_bot_id ON key_pairs(bot_id) + `); + // Messages table this.db.exec(` CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + id TEXT NOT NULL, activity_json TEXT NOT NULL, - published INTEGER + published INTEGER, + PRIMARY KEY (bot_id, id) ) `); // Create index on published timestamp for efficient ordering this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_messages_published ON messages(published) + CREATE INDEX IF NOT EXISTS idx_messages_bot_published + ON messages(bot_id, published) `); // Followers table this.db.exec(` CREATE TABLE IF NOT EXISTS followers ( - follower_id TEXT PRIMARY KEY, - actor_json TEXT NOT NULL + bot_id TEXT NOT NULL, + follower_id TEXT NOT NULL, + actor_json TEXT NOT NULL, + PRIMARY KEY (bot_id, follower_id) ) `); // Follow requests mapping table this.db.exec(` CREATE TABLE IF NOT EXISTS follow_requests ( - follow_request_id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL, + follow_request_id TEXT NOT NULL, follower_id TEXT NOT NULL, - FOREIGN KEY (follower_id) REFERENCES followers(follower_id) + PRIMARY KEY (bot_id, follow_request_id), + FOREIGN KEY (bot_id, follower_id) + REFERENCES followers(bot_id, follower_id) ) `); // Sent follows table this.db.exec(` CREATE TABLE IF NOT EXISTS sent_follows ( - id TEXT PRIMARY KEY, - follow_json TEXT NOT NULL + bot_id TEXT NOT NULL, + id TEXT NOT NULL, + follow_json TEXT NOT NULL, + PRIMARY KEY (bot_id, id) ) `); // Followees table this.db.exec(` CREATE TABLE IF NOT EXISTS followees ( - followee_id TEXT PRIMARY KEY, - follow_json TEXT NOT NULL + bot_id TEXT NOT NULL, + followee_id TEXT NOT NULL, + follow_json TEXT NOT NULL, + PRIMARY KEY (bot_id, followee_id) ) `); + // Create index for reverse lookup of bots following an actor + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_followees_followee_id + ON followees(followee_id) + `); + // Poll votes table this.db.exec(` CREATE TABLE IF NOT EXISTS poll_votes ( + bot_id TEXT NOT NULL, message_id TEXT NOT NULL, voter_id TEXT NOT NULL, option TEXT NOT NULL, - PRIMARY KEY (message_id, voter_id, option) + PRIMARY KEY (bot_id, message_id, voter_id, option) ) `); // Create index for efficient vote counting this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_poll_votes_message_option - ON poll_votes(message_id, option) + CREATE INDEX IF NOT EXISTS idx_poll_votes_bot_message_option + ON poll_votes(bot_id, message_id, option) `); } - async setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { - const deleteStmt = this.db.prepare("DELETE FROM key_pairs"); + async setKeyPairs( + identifier: string, + keyPairs: CryptoKeyPair[], + ): Promise { + const deleteStmt = this.db.prepare( + "DELETE FROM key_pairs WHERE bot_id = ?", + ); const insertStmt = this.db.prepare(` - INSERT INTO key_pairs (private_key_jwk, public_key_jwk) - VALUES (?, ?) + INSERT INTO key_pairs (bot_id, private_key_jwk, public_key_jwk) + VALUES (?, ?, ?) `); this.db.exec("BEGIN TRANSACTION"); try { - deleteStmt.run(); + deleteStmt.run(identifier); for (const keyPair of keyPairs) { const privateJwk = await exportJwk(keyPair.privateKey); const publicJwk = await exportJwk(keyPair.publicKey); - insertStmt.run(JSON.stringify(privateJwk), JSON.stringify(publicJwk)); + insertStmt.run( + identifier, + JSON.stringify(privateJwk), + JSON.stringify(publicJwk), + ); } this.db.exec("COMMIT"); @@ -187,11 +222,12 @@ export class SqliteRepository implements Repository, Disposable { } } - async getKeyPairs(): Promise { + async getKeyPairs(identifier: string): Promise { const stmt = this.db.prepare(` SELECT private_key_jwk, public_key_jwk FROM key_pairs + WHERE bot_id = ? ORDER BY id `); - const rows = stmt.all() as Array<{ + const rows = stmt.all(identifier) as Array<{ private_key_jwk: string; public_key_jwk: string; }>; @@ -212,10 +248,14 @@ export class SqliteRepository implements Repository, Disposable { return keyPairs; } - async addMessage(id: Uuid, activity: Create | Announce): Promise { + async addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise { const stmt = this.db.prepare(` - INSERT INTO messages (id, activity_json, published) - VALUES (?, ?, ?) + INSERT INTO messages (bot_id, id, activity_json, published) + VALUES (?, ?, ?, ?) `); const activityJson = JSON.stringify( @@ -223,19 +263,22 @@ export class SqliteRepository implements Repository, Disposable { ); const published = activity.published?.epochMilliseconds ?? null; - stmt.run(id, activityJson, published); + stmt.run(identifier, id, activityJson, published); } async updateMessage( + identifier: string, id: Uuid, updater: ( existing: Create | Announce, ) => Create | Announce | undefined | Promise, ): Promise { const selectStmt = this.db.prepare(` - SELECT activity_json FROM messages WHERE id = ? + SELECT activity_json FROM messages WHERE bot_id = ? AND id = ? `); - const row = selectStmt.get(id) as { activity_json: string } | undefined; + const row = selectStmt.get(identifier, id) as + | { activity_json: string } + | undefined; if (!row) return false; @@ -250,9 +293,9 @@ export class SqliteRepository implements Repository, Disposable { if (newActivity == null) return false; const updateStmt = this.db.prepare(` - UPDATE messages - SET activity_json = ?, published = ? - WHERE id = ? + UPDATE messages + SET activity_json = ?, published = ? + WHERE bot_id = ? AND id = ? `); const newActivityJson = JSON.stringify( @@ -260,22 +303,27 @@ export class SqliteRepository implements Repository, Disposable { ); const published = newActivity.published?.epochMilliseconds ?? null; - updateStmt.run(newActivityJson, published, id); + updateStmt.run(newActivityJson, published, identifier, id); return true; } - async removeMessage(id: Uuid): Promise { + async removeMessage( + identifier: string, + id: Uuid, + ): Promise { const selectStmt = this.db.prepare(` - SELECT activity_json FROM messages WHERE id = ? + SELECT activity_json FROM messages WHERE bot_id = ? AND id = ? `); - const row = selectStmt.get(id) as { activity_json: string } | undefined; + const row = selectStmt.get(identifier, id) as + | { activity_json: string } + | undefined; if (!row) return undefined; const deleteStmt = this.db.prepare(` - DELETE FROM messages WHERE id = ? + DELETE FROM messages WHERE bot_id = ? AND id = ? `); - deleteStmt.run(id); + deleteStmt.run(identifier, id); try { const activityData = JSON.parse(row.activity_json); @@ -292,12 +340,13 @@ export class SqliteRepository implements Repository, Disposable { } async *getMessages( + identifier: string, options: RepositoryGetMessagesOptions = {}, ): AsyncIterable { const { order = "newest", until, since, limit } = options; - let sql = "SELECT activity_json FROM messages WHERE 1=1"; - const params: (number | string)[] = []; + let sql = "SELECT activity_json FROM messages WHERE bot_id = ?"; + const params: (number | string)[] = [identifier]; if (since != null) { sql += " AND published >= ?"; @@ -336,11 +385,16 @@ export class SqliteRepository implements Repository, Disposable { } } - async getMessage(id: Uuid): Promise { + async getMessage( + identifier: string, + id: Uuid, + ): Promise { const stmt = this.db.prepare(` - SELECT activity_json FROM messages WHERE id = ? + SELECT activity_json FROM messages WHERE bot_id = ? AND id = ? `); - const row = stmt.get(id) as { activity_json: string } | undefined; + const row = stmt.get(identifier, id) as + | { activity_json: string } + | undefined; if (!row) return undefined; @@ -358,13 +412,19 @@ export class SqliteRepository implements Repository, Disposable { return undefined; } - countMessages(): Promise { - const stmt = this.db.prepare("SELECT COUNT(*) as count FROM messages"); - const row = stmt.get() as { count: number }; + countMessages(identifier: string): Promise { + const stmt = this.db.prepare( + "SELECT COUNT(*) as count FROM messages WHERE bot_id = ?", + ); + const row = stmt.get(identifier) as { count: number }; return Promise.resolve(row.count); } - async addFollower(followRequestId: URL, follower: Actor): Promise { + async addFollower( + identifier: string, + followRequestId: URL, + follower: Actor, + ): Promise { if (follower.id == null) { throw new TypeError("The follower ID is missing."); } @@ -374,19 +434,24 @@ export class SqliteRepository implements Repository, Disposable { ); const insertFollowerStmt = this.db.prepare(` - INSERT OR REPLACE INTO followers (follower_id, actor_json) - VALUES (?, ?) + INSERT OR REPLACE INTO followers (bot_id, follower_id, actor_json) + VALUES (?, ?, ?) `); const insertRequestStmt = this.db.prepare(` - INSERT OR REPLACE INTO follow_requests (follow_request_id, follower_id) - VALUES (?, ?) + INSERT OR REPLACE INTO follow_requests + (bot_id, follow_request_id, follower_id) + VALUES (?, ?, ?) `); this.db.exec("BEGIN TRANSACTION"); try { - insertFollowerStmt.run(follower.id.href, followerJson); - insertRequestStmt.run(followRequestId.href, follower.id.href); + insertFollowerStmt.run(identifier, follower.id.href, followerJson); + insertRequestStmt.run( + identifier, + followRequestId.href, + follower.id.href, + ); this.db.exec("COMMIT"); } catch (error) { this.db.exec("ROLLBACK"); @@ -395,37 +460,45 @@ export class SqliteRepository implements Repository, Disposable { } async removeFollower( + identifier: string, followRequestId: URL, actorId: URL, ): Promise { // Check if the follow request exists and matches the actor const checkStmt = this.db.prepare(` - SELECT fr.follower_id, f.actor_json - FROM follow_requests fr - JOIN followers f ON fr.follower_id = f.follower_id - WHERE fr.follow_request_id = ? AND fr.follower_id = ? + SELECT fr.follower_id, f.actor_json + FROM follow_requests fr + JOIN followers f + ON fr.bot_id = f.bot_id AND fr.follower_id = f.follower_id + WHERE fr.bot_id = ? AND fr.follow_request_id = ? AND fr.follower_id = ? `); - const row = checkStmt.get(followRequestId.href, actorId.href) as { - follower_id: string; - actor_json: string; - } | undefined; + const row = checkStmt.get( + identifier, + followRequestId.href, + actorId.href, + ) as + | { + follower_id: string; + actor_json: string; + } + | undefined; if (!row) return undefined; // Remove the follower and follow request const deleteRequestStmt = this.db.prepare(` - DELETE FROM follow_requests WHERE follow_request_id = ? + DELETE FROM follow_requests WHERE bot_id = ? AND follow_request_id = ? `); const deleteFollowerStmt = this.db.prepare(` - DELETE FROM followers WHERE follower_id = ? + DELETE FROM followers WHERE bot_id = ? AND follower_id = ? `); this.db.exec("BEGIN TRANSACTION"); try { - deleteRequestStmt.run(followRequestId.href); - deleteFollowerStmt.run(actorId.href); + deleteRequestStmt.run(identifier, followRequestId.href); + deleteFollowerStmt.run(identifier, actorId.href); this.db.exec("COMMIT"); } catch (error) { this.db.exec("ROLLBACK"); @@ -446,21 +519,23 @@ export class SqliteRepository implements Repository, Disposable { return undefined; } - hasFollower(followerId: URL): Promise { + hasFollower(identifier: string, followerId: URL): Promise { const stmt = this.db.prepare(` - SELECT 1 FROM followers WHERE follower_id = ? + SELECT 1 FROM followers WHERE bot_id = ? AND follower_id = ? `); - const row = stmt.get(followerId.href); + const row = stmt.get(identifier, followerId.href); return Promise.resolve(row != null); } async *getFollowers( + identifier: string, options: RepositoryGetFollowersOptions = {}, ): AsyncIterable { const { offset = 0, limit } = options; - let sql = "SELECT actor_json FROM followers ORDER BY follower_id"; - const params: number[] = []; + let sql = + "SELECT actor_json FROM followers WHERE bot_id = ? ORDER BY follower_id"; + const params: (number | string)[] = [identifier]; if (limit != null) { sql += " LIMIT ? OFFSET ?"; @@ -488,40 +563,56 @@ export class SqliteRepository implements Repository, Disposable { } } - countFollowers(): Promise { - const stmt = this.db.prepare("SELECT COUNT(*) as count FROM followers"); - const row = stmt.get() as { count: number }; + countFollowers(identifier: string): Promise { + const stmt = this.db.prepare( + "SELECT COUNT(*) as count FROM followers WHERE bot_id = ?", + ); + const row = stmt.get(identifier) as { count: number }; return Promise.resolve(row.count); } - async addSentFollow(id: Uuid, follow: Follow): Promise { + async addSentFollow( + identifier: string, + id: Uuid, + follow: Follow, + ): Promise { const stmt = this.db.prepare(` - INSERT OR REPLACE INTO sent_follows (id, follow_json) - VALUES (?, ?) + INSERT OR REPLACE INTO sent_follows (bot_id, id, follow_json) + VALUES (?, ?, ?) `); const followJson = JSON.stringify( await follow.toJsonLd({ format: "compact" }), ); - stmt.run(id, followJson); + stmt.run(identifier, id, followJson); } - async removeSentFollow(id: Uuid): Promise { - const follow = await this.getSentFollow(id); + async removeSentFollow( + identifier: string, + id: Uuid, + ): Promise { + const follow = await this.getSentFollow(identifier, id); if (follow == null) return undefined; - const stmt = this.db.prepare("DELETE FROM sent_follows WHERE id = ?"); - stmt.run(id); + const stmt = this.db.prepare( + "DELETE FROM sent_follows WHERE bot_id = ? AND id = ?", + ); + stmt.run(identifier, id); return follow; } - async getSentFollow(id: Uuid): Promise { + async getSentFollow( + identifier: string, + id: Uuid, + ): Promise { const stmt = this.db.prepare(` - SELECT follow_json FROM sent_follows WHERE id = ? + SELECT follow_json FROM sent_follows WHERE bot_id = ? AND id = ? `); - const row = stmt.get(id) as { follow_json: string } | undefined; + const row = stmt.get(identifier, id) as + | { follow_json: string } + | undefined; if (!row) return undefined; @@ -534,34 +625,46 @@ export class SqliteRepository implements Repository, Disposable { } } - async addFollowee(followeeId: URL, follow: Follow): Promise { + async addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise { const stmt = this.db.prepare(` - INSERT OR REPLACE INTO followees (followee_id, follow_json) - VALUES (?, ?) + INSERT OR REPLACE INTO followees (bot_id, followee_id, follow_json) + VALUES (?, ?, ?) `); const followJson = JSON.stringify( await follow.toJsonLd({ format: "compact" }), ); - stmt.run(followeeId.href, followJson); + stmt.run(identifier, followeeId.href, followJson); } - async removeFollowee(followeeId: URL): Promise { - const follow = await this.getFollowee(followeeId); + async removeFollowee( + identifier: string, + followeeId: URL, + ): Promise { + const follow = await this.getFollowee(identifier, followeeId); if (follow == null) return undefined; - const stmt = this.db.prepare("DELETE FROM followees WHERE followee_id = ?"); - stmt.run(followeeId.href); + const stmt = this.db.prepare( + "DELETE FROM followees WHERE bot_id = ? AND followee_id = ?", + ); + stmt.run(identifier, followeeId.href); return follow; } - async getFollowee(followeeId: URL): Promise { + async getFollowee( + identifier: string, + followeeId: URL, + ): Promise { const stmt = this.db.prepare(` - SELECT follow_json FROM followees WHERE followee_id = ? + SELECT follow_json FROM followees WHERE bot_id = ? AND followee_id = ? `); - const row = stmt.get(followeeId.href) as + const row = stmt.get(identifier, followeeId.href) as | { follow_json: string } | undefined; @@ -579,34 +682,50 @@ export class SqliteRepository implements Repository, Disposable { } } - vote(messageId: Uuid, voterId: URL, option: string): Promise { + async *findFollowedBots(followeeId: URL): AsyncIterable { const stmt = this.db.prepare(` - INSERT OR IGNORE INTO poll_votes (message_id, voter_id, option) - VALUES (?, ?, ?) + SELECT bot_id FROM followees WHERE followee_id = ? ORDER BY bot_id + `); + const rows = stmt.all(followeeId.href) as { bot_id: string }[]; + for (const row of rows) yield row.bot_id; + } + + vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise { + const stmt = this.db.prepare(` + INSERT OR IGNORE INTO poll_votes (bot_id, message_id, voter_id, option) + VALUES (?, ?, ?, ?) `); - stmt.run(messageId, voterId.href, option); + stmt.run(identifier, messageId, voterId.href, option); return Promise.resolve(); } - countVoters(messageId: Uuid): Promise { + countVoters(identifier: string, messageId: Uuid): Promise { const stmt = this.db.prepare(` - SELECT COUNT(DISTINCT voter_id) as count - FROM poll_votes - WHERE message_id = ? + SELECT COUNT(DISTINCT voter_id) as count + FROM poll_votes + WHERE bot_id = ? AND message_id = ? `); - const row = stmt.get(messageId) as { count: number }; + const row = stmt.get(identifier, messageId) as { count: number }; return Promise.resolve(row.count); } - countVotes(messageId: Uuid): Promise>> { + countVotes( + identifier: string, + messageId: Uuid, + ): Promise>> { const stmt = this.db.prepare(` - SELECT option, COUNT(*) as count - FROM poll_votes - WHERE message_id = ? + SELECT option, COUNT(*) as count + FROM poll_votes + WHERE bot_id = ? AND message_id = ? GROUP BY option `); - const rows = stmt.all(messageId) as Array<{ + const rows = stmt.all(identifier, messageId) as Array<{ option: string; count: number; }>; @@ -618,4 +737,8 @@ export class SqliteRepository implements Repository, Disposable { return Promise.resolve(result); } + + forIdentifier(identifier: string): ActorScopedRepository { + return new ActorScopedRepository(this, identifier); + } } diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 752abb3..3391954 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -222,7 +222,7 @@ test("BotImpl.dispatchActor()", async () => { assert.ok(publicKey != null); assert.deepStrictEqual(publicKey.ownerId, actor.id); assert.ok(publicKey.publicKey != null); - const keys = await repository.getKeyPairs(); + const keys = await repository.getKeyPairs("bot"); assert.ok(keys != null); assert.deepStrictEqual(publicKey.publicKey, keys[0].publicKey); const assertionMethods = await Array.fromAsync(actor.getAssertionMethods()); @@ -267,7 +267,7 @@ test("BotImpl.dispatchActorKeyPairs()", async () => { ); // Generation: const keyPairs = await bot.dispatchActorKeyPairs(ctx, "bot"); - const storedKeyPairs = await repository.getKeyPairs(); + const storedKeyPairs = await repository.getKeyPairs("bot"); assert.deepStrictEqual(keyPairs, storedKeyPairs); // Retrieval: const keyPairs2 = await bot.dispatchActorKeyPairs(ctx, "bot"); @@ -298,6 +298,7 @@ test("BotImpl.dispatchFollowers()", async () => { assert.deepStrictEqual(empty, { items: [], nextCursor: null }); await repository.addFollower( + "bot", new URL("https://example.com/actor/1#follow"), new Person({ id: new URL("https://example.com/actor/1"), @@ -306,6 +307,7 @@ test("BotImpl.dispatchFollowers()", async () => { }), ); await repository.addFollower( + "bot", new URL("https://example.com/actor/2#follow"), new Person({ id: new URL("https://example.com/actor/2"), @@ -314,6 +316,7 @@ test("BotImpl.dispatchFollowers()", async () => { }), ); await repository.addFollower( + "bot", new URL("https://example.com/actor/3#follow"), new Person({ id: new URL("https://example.com/actor/3"), @@ -393,6 +396,7 @@ test("BotImpl.countFollowers()", async () => { assert.deepStrictEqual(await bot.countFollowers(ctx, "non-existent"), null); assert.deepStrictEqual(await bot.countFollowers(ctx, "bot"), 0); await repository.addFollower( + "bot", new URL("https://example.com/actor/1#follow"), new Person({ id: new URL("https://example.com/actor/1"), @@ -401,6 +405,7 @@ test("BotImpl.countFollowers()", async () => { }), ); await repository.addFollower( + "bot", new URL("https://example.com/actor/2#follow"), new Person({ id: new URL("https://example.com/actor/2"), @@ -409,6 +414,7 @@ test("BotImpl.countFollowers()", async () => { }), ); await repository.addFollower( + "bot", new URL("https://example.com/actor/3#follow"), new Person({ id: new URL("https://example.com/actor/3"), @@ -467,6 +473,7 @@ test("BotImpl.getPermissionChecker()", async () => { assert.deepStrictEqual(nonFollower(directPost), false); await repository.addFollower( + "bot", new URL("https://example.com/actor/john#follow"), new Person({ id: new URL("https://example.com/actor/john"), @@ -529,6 +536,7 @@ test("BotImpl.dispatchOutbox()", async () => { }); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -549,6 +557,7 @@ test("BotImpl.dispatchOutbox()", async () => { }), ); await repository.addMessage( + "bot", "46442170-836d-4a0d-9142-f31242abe2f9", new Create({ id: new URL( @@ -569,6 +578,7 @@ test("BotImpl.dispatchOutbox()", async () => { }), ); await repository.addMessage( + "bot", "8386a4c7-06f8-409f-ad72-2bba43e83363", new Create({ id: new URL( @@ -682,6 +692,7 @@ test("BotImpl.countOutbox()", async () => { assert.deepStrictEqual(await bot.countOutbox(ctx, "bot"), 0); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -700,6 +711,7 @@ test("BotImpl.countOutbox()", async () => { }), ); await repository.addMessage( + "bot", "46442170-836d-4a0d-9142-f31242abe2f9", new Create({ id: new URL( @@ -718,6 +730,7 @@ test("BotImpl.countOutbox()", async () => { }), ); await repository.addMessage( + "bot", "8386a4c7-06f8-409f-ad72-2bba43e83363", new Create({ id: new URL( @@ -754,6 +767,7 @@ test("BotImpl.dispatchFollow()", async () => { ); await repository.addSentFollow( + "bot", "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a", new Follow({ id: new URL( @@ -800,6 +814,7 @@ test("BotImpl.authorizeFollow()", async () => { undefined, ); await repository.addSentFollow( + "bot", "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a", new Follow({ id: new URL( @@ -872,6 +887,7 @@ test("BotImpl.dispatchCreate()", async () => { ); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -915,6 +931,7 @@ test("BotImpl.dispatchCreate()", async () => { ); await repository.addMessage( + "bot", "8386a4c7-06f8-409f-ad72-2bba43e83363", new Create({ id: new URL( @@ -948,6 +965,7 @@ test("BotImpl.dispatchCreate()", async () => { ); await repository.addMessage( + "bot", "ce8081ac-f238-484b-9a70-5d8a4b66d829", new Announce({ id: new URL( @@ -984,6 +1002,7 @@ test("BotImpl.dispatchMessage()", async () => { ); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -1039,6 +1058,7 @@ test("BotImpl.dispatchMessage()", async () => { ); await repository.addMessage( + "bot", "8386a4c7-06f8-409f-ad72-2bba43e83363", new Create({ id: new URL( @@ -1074,6 +1094,7 @@ test("BotImpl.dispatchMessage()", async () => { ); await repository.addMessage( + "bot", "ce8081ac-f238-484b-9a70-5d8a4b66d829", new Announce({ id: new URL( @@ -1112,6 +1133,7 @@ test("BotImpl.dispatchAnnounce()", async () => { ); await repository.addMessage( + "bot", "ce8081ac-f238-484b-9a70-5d8a4b66d829", new Announce({ id: new URL( @@ -1135,6 +1157,7 @@ test("BotImpl.dispatchAnnounce()", async () => { ); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -1160,6 +1183,7 @@ test("BotImpl.dispatchAnnounce()", async () => { ); await repository.addMessage( + "bot", "d4a7ef9b-682c-4de9-b23c-87747d6725cb", new Announce({ id: new URL( @@ -1298,7 +1322,7 @@ for (const policy of ["accept", "reject", "manual"] as const) { object: new URL("https://example.com/ap/actor/bot"), }); await bot.onFollowed(ctx, followWithoutActor); - assert.deepStrictEqual(await repository.countFollowers(), 0); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); }); await t.test("with wrong actor", async () => { @@ -1308,7 +1332,7 @@ for (const policy of ["accept", "reject", "manual"] as const) { object: new URL("https://example.com/ap/actor/bot"), }); await bot.onFollowed(ctx, followWithWrongActor); - assert.deepStrictEqual(await repository.countFollowers(), 0); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); }); const actor = new Person({ @@ -1323,7 +1347,7 @@ for (const policy of ["accept", "reject", "manual"] as const) { object: new URL("https://example.com/ap/actor/non-existent"), }); await bot.onFollowed(ctx, followWithWrongRecipient); - assert.deepStrictEqual(await repository.countFollowers(), 0); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); }); await t.test("with correct follow", async () => { @@ -1334,14 +1358,15 @@ for (const policy of ["accept", "reject", "manual"] as const) { }); await bot.onFollowed(ctx, follow); if (policy === "accept") { - assert.deepStrictEqual(await repository.countFollowers(), 1); + assert.deepStrictEqual(await repository.countFollowers("bot"), 1); assert.ok( await repository.hasFollower( + "bot", new URL("https://example.com/ap/actor/john"), ), ); const [storedFollower] = await Array.fromAsync( - repository.getFollowers(), + repository.getFollowers("bot"), ); assert.ok(storedFollower instanceof Person); assert.deepStrictEqual(storedFollower.id, actor.id); @@ -1358,7 +1383,7 @@ for (const policy of ["accept", "reject", "manual"] as const) { assert.deepStrictEqual(ctx.forwardedRecipients, []); assert.deepStrictEqual(followRequests.length, 1); } else { - assert.deepStrictEqual(await repository.countFollowers(), 0); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); if (policy === "reject") { assert.deepStrictEqual(ctx.sentActivities.length, 1); const { activity, recipients } = ctx.sentActivities[0]; @@ -1396,6 +1421,7 @@ test("BotImpl.onUnfollowed()", async (t) => { const ctx = createMockInboxContext(bot, "https://example.com", "bot"); await repository.addFollower( + "bot", new URL("https://example.com/ap/actor/john/follows/bot"), new Person({ id: new URL("https://example.com/ap/actor/john"), @@ -1404,8 +1430,8 @@ test("BotImpl.onUnfollowed()", async (t) => { ); async function assertNoEffect() { - assert.deepStrictEqual(await repository.countFollowers(), 1); - const [follower] = await Array.fromAsync(repository.getFollowers()); + assert.deepStrictEqual(await repository.countFollowers("bot"), 1); + const [follower] = await Array.fromAsync(repository.getFollowers("bot")); assert.ok(follower instanceof Person); assert.deepStrictEqual( follower.id, @@ -1467,7 +1493,7 @@ test("BotImpl.onUnfollowed()", async (t) => { }), }); await bot.onUnfollowed(ctx, undo); - assert.deepStrictEqual(await repository.countFollowers(), 0); + assert.deepStrictEqual(await repository.countFollowers("bot"), 0); assert.deepStrictEqual(ctx.sentActivities, []); assert.deepStrictEqual(ctx.forwardedRecipients, []); assert.deepStrictEqual(unfollowed.length, 1); @@ -1528,6 +1554,7 @@ test("BotImpl.onFollowAccepted()", async (t) => { await t.test("with non-actor", async () => { await repository.addSentFollow( + "bot", "2ca58e2a-a34a-43e6-81af-c4f21ffed0c5", new Follow({ id: new URL( @@ -1551,6 +1578,7 @@ test("BotImpl.onFollowAccepted()", async (t) => { await t.test("with actor without URI", async () => { await repository.addSentFollow( + "bot", "a99ff3bf-72a2-412b-83b9-cba894d38805", new Follow({ id: new URL( @@ -1576,6 +1604,7 @@ test("BotImpl.onFollowAccepted()", async (t) => { await t.test("with actor", async () => { await repository.addSentFollow( + "bot", "3bca0b8e-503a-47ea-ad69-6b7c29369fbd", new Follow({ id: new URL( @@ -1607,6 +1636,7 @@ test("BotImpl.onFollowAccepted()", async (t) => { new URL("https://example.com/ap/actor/john"), ); const follow = await repository.getFollowee( + "bot", new URL("https://example.com/ap/actor/john"), ); assert.ok(follow != null); @@ -1667,6 +1697,7 @@ test("BotImpl.onFollowRejected()", async (t) => { await t.test("with non-actor", async () => { await repository.addSentFollow( + "bot", "2ca58e2a-a34a-43e6-81af-c4f21ffed0c5", new Follow({ id: new URL( @@ -1690,6 +1721,7 @@ test("BotImpl.onFollowRejected()", async (t) => { await t.test("with actor without URI", async () => { await repository.addSentFollow( + "bot", "a99ff3bf-72a2-412b-83b9-cba894d38805", new Follow({ id: new URL( @@ -1715,6 +1747,7 @@ test("BotImpl.onFollowRejected()", async (t) => { await t.test("with actor", async () => { await repository.addSentFollow( + "bot", "3bca0b8e-503a-47ea-ad69-6b7c29369fbd", new Follow({ id: new URL( @@ -1746,7 +1779,10 @@ test("BotImpl.onFollowRejected()", async (t) => { new URL("https://example.com/ap/actor/john"), ); assert.deepStrictEqual( - await repository.getSentFollow("3bca0b8e-503a-47ea-ad69-6b7c29369fbd"), + await repository.getSentFollow( + "bot", + "3bca0b8e-503a-47ea-ad69-6b7c29369fbd", + ), undefined, ); }); @@ -1793,6 +1829,7 @@ test("BotImpl.onCreated()", async (t) => { }); await repository.addMessage( + "bot", "a6358f1b-c978-49d3-8065-37a1df6168de", new Create({ id: new URL( @@ -2394,6 +2431,7 @@ test("BotImpl.fetch() includes FEP-5711 inverse properties", async () => { const actorId = new URL("https://example.com/ap/actor/bot"); await repository.addFollower( + "bot", new URL("https://example.com/actor/1#follow"), new Person({ id: new URL("https://example.com/actor/1"), @@ -2402,6 +2440,7 @@ test("BotImpl.fetch() includes FEP-5711 inverse properties", async () => { }), ); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -2766,7 +2805,7 @@ test("BotImpl.onVote()", async (t) => { }), published: Temporal.Now.instant(), }); - await repository.addMessage(pollId, poll); + await repository.addMessage("bot", pollId, poll); // Create a voter const voter = new Person({ @@ -2824,7 +2863,7 @@ test("BotImpl.onVote()", async (t) => { ); // Check that vote count was updated in repository - const updatedPoll = await repository.getMessage(pollId); + const updatedPoll = await repository.getMessage("bot", pollId); assert.ok(updatedPoll instanceof Create); const updatedQuestion = await updatedPoll.getObject(ctx); assert.ok(updatedQuestion instanceof Question); @@ -2873,7 +2912,7 @@ test("BotImpl.onVote()", async (t) => { }), published: Temporal.Now.instant(), }); - await repository.addMessage(multiPollId, multiPoll); + await repository.addMessage("bot", multiPollId, multiPoll); ctx.sentActivities = []; ctx.forwardedRecipients = []; @@ -2967,7 +3006,7 @@ test("BotImpl.onVote()", async (t) => { }), published: Temporal.Now.instant(), }); - await repository.addMessage(expiredPollId, expiredPoll); + await repository.addMessage("bot", expiredPollId, expiredPoll); ctx.sentActivities = []; ctx.forwardedRecipients = []; diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 1b16dcf..4359313 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -95,7 +95,11 @@ import type { Message, MessageClass, SharedMessage } from "./message.ts"; import { app } from "./pages.tsx"; import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; -import { KvRepository, type Repository, type Uuid } from "./repository.ts"; +import { + type ActorScopedRepository, + KvRepository, + type Uuid, +} from "./repository.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; import type { Text } from "./text.ts"; @@ -118,7 +122,7 @@ export class BotImpl implements Bot { #properties: { pairs: PropertyValue[]; tags: (Link | Object)[] } | null; readonly followerPolicy: "accept" | "reject" | "manual"; readonly customEmojis: Record; - readonly repository: Repository; + readonly repository: ActorScopedRepository; readonly software?: Software; readonly behindProxy: boolean; readonly pages: Required; @@ -153,7 +157,8 @@ export class BotImpl implements Bot { this.#properties = null; this.followerPolicy = options.followerPolicy ?? "accept"; this.customEmojis = {}; - this.repository = options.repository ?? new KvRepository(options.kv); + this.repository = (options.repository ?? new KvRepository(options.kv)) + .forIdentifier(this.identifier); this.software = options.software; this.pages = { color: "green", diff --git a/packages/botkit/src/follow-impl.test.ts b/packages/botkit/src/follow-impl.test.ts index 6c72a1e..867fed4 100644 --- a/packages/botkit/src/follow-impl.test.ts +++ b/packages/botkit/src/follow-impl.test.ts @@ -69,9 +69,14 @@ test("FollowRequestImpl.accept()", async () => { await followRequest.accept(); assert.deepStrictEqual(followRequest.state, "accepted"); assert.ok( - await repository.hasFollower(new URL("https://example.com/ap/actor/john")), + await repository.hasFollower( + "bot", + new URL("https://example.com/ap/actor/john"), + ), + ); + const [storedFollower] = await Array.fromAsync( + repository.getFollowers("bot"), ); - const [storedFollower] = await Array.fromAsync(repository.getFollowers()); assert.ok(storedFollower != null); assert.deepStrictEqual(storedFollower.id, follower.id); assert.deepStrictEqual( @@ -121,7 +126,10 @@ test("FollowRequestImpl.reject()", async () => { await followRequest.reject(); assert.deepStrictEqual(followRequest.state, "rejected"); assert.deepStrictEqual( - await repository.hasFollower(new URL("https://example.com/ap/actor/john")), + await repository.hasFollower( + "bot", + new URL("https://example.com/ap/actor/john"), + ), false, ); assert.deepStrictEqual(ctx.sentActivities.length, 1); diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index 09fe43c..d7c9054 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -213,6 +213,7 @@ test("AuthorizedMessageImpl.delete()", async () => { true, ); await repository.addMessage( + "bot", "c1c792ce-a0be-4685-b396-e59e5ef8c788", new Create({ id: new URL( @@ -225,7 +226,7 @@ test("AuthorizedMessageImpl.delete()", async () => { }), ); await msg.delete(); - assert.deepStrictEqual(await repository.countMessages(), 0); + assert.deepStrictEqual(await repository.countMessages("bot"), 0); assert.deepStrictEqual(ctx.sentActivities.length, 1); const { recipients, activity } = ctx.sentActivities[0]; assert.deepStrictEqual(recipients, "followers"); @@ -265,8 +266,8 @@ test("MessageImpl.reply()", async () => { {}, ); const reply = await originalMsg.reply(text`Hello, John!`); - assert.deepStrictEqual(await repository.countMessages(), 1); - const [create] = await Array.fromAsync(repository.getMessages()); + assert.deepStrictEqual(await repository.countMessages("bot"), 1); + const [create] = await Array.fromAsync(repository.getMessages("bot")); assert.ok(create != null); assert.deepStrictEqual(ctx.sentActivities.length, 2); const { recipients, activity } = ctx.sentActivities[0]; @@ -322,8 +323,8 @@ test("MessageImpl.share()", async (t) => { const sharedMsg = await originalMsg.share(); await t.test("share()", async () => { - assert.deepStrictEqual(await repository.countMessages(), 1); - const [announce] = await Array.fromAsync(repository.getMessages()); + assert.deepStrictEqual(await repository.countMessages("bot"), 1); + const [announce] = await Array.fromAsync(repository.getMessages("bot")); assert.ok(announce != null); assert.deepStrictEqual(ctx.sentActivities.length, 2); const { recipients, activity } = ctx.sentActivities[0]; @@ -368,7 +369,7 @@ test("MessageImpl.share()", async (t) => { ctx.sentActivities = []; await sharedMsg.unshare(); - assert.deepStrictEqual(await repository.countMessages(), 0); + assert.deepStrictEqual(await repository.countMessages("bot"), 0); assert.deepStrictEqual(ctx.sentActivities.length, 2); const { recipients, activity } = ctx.sentActivities[0]; assert.deepStrictEqual(recipients, "followers"); @@ -487,7 +488,7 @@ test("AuthorizedMessage.update()", async (t) => { await t.test(visibility, async () => { const msg = await session.publish(text`Hello, ${actorA}`, { visibility }); - assert.deepStrictEqual(await repository.countMessages(), 1); + assert.deepStrictEqual(await repository.countMessages("bot"), 1); const originalRaw = msg.raw; ctx.sentActivities = []; const before = Temporal.Now.instant(); @@ -535,7 +536,7 @@ test("AuthorizedMessage.update()", async (t) => { assert.deepStrictEqual(tags[0].href, actorB.id); assert.deepStrictEqual(msg.raw.published, originalRaw.published); assert.deepStrictEqual(msg.raw.updated, msg.updated); - const [create] = await Array.fromAsync(repository.getMessages()); + const [create] = await Array.fromAsync(repository.getMessages("bot")); assert.deepStrictEqual( await (await create.getObject())?.toJsonLd({ format: "compact" }), await msg.raw.toJsonLd({ format: "compact" }), diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index 7594fbf..71130c5 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -70,9 +70,11 @@ export { type Reaction, } from "./reaction.ts"; export { + ActorScopedRepository, Announce, Create, KvRepository, + type KvRepositoryOptions, MemoryCachedRepository, MemoryRepository, type Repository, diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 0376526..7954f1a 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -23,6 +23,7 @@ import { MemoryCachedRepository, MemoryRepository, type Repository, + type Uuid, } from "./repository.ts"; function createKvRepository(): Repository { @@ -104,30 +105,33 @@ for (const name in factories) { const repo = factory(); test("key pairs", async () => { - assert.deepStrictEqual(await repo.getKeyPairs(), undefined); - await repo.setKeyPairs(keyPairs); - assert.deepStrictEqual(await repo.getKeyPairs(), keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), undefined); + await repo.setKeyPairs("bot", keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), keyPairs); }); test("messages", async () => { - assert.deepStrictEqual(await repo.countMessages(), 0); + assert.deepStrictEqual(await repo.countMessages("bot"), 0); assert.deepStrictEqual( - await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0"), + await repo.getMessage("bot", "01941f29-7c00-7fe8-ab0a-7b593990a3c0"), undefined, ); assert.deepStrictEqual( - await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306"), + await repo.getMessage("bot", "0194244f-d800-7873-8993-ef71ccd47306"), undefined, ); assert.deepStrictEqual( - await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4"), + await repo.getMessage("bot", "01942976-3400-7f34-872e-2cbf0f9eeac4"), undefined, ); assert.deepStrictEqual( - await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14"), + await repo.getMessage("bot", "01942e9c-9000-7480-a553-7a6ce737ce14"), undefined, ); - assert.deepStrictEqual(await Array.fromAsync(repo.getMessages()), []); + assert.deepStrictEqual( + await Array.fromAsync(repo.getMessages("bot")), + [], + ); const messageA = new Create({ id: new URL( @@ -227,93 +231,129 @@ for (const name in factories) { updated: Temporal.Instant.from("2025-01-03T12:00:00Z"), }); - await repo.addMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0", messageA); - assert.deepStrictEqual(await repo.countMessages(), 1); + await repo.addMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + messageA, + ); + assert.deepStrictEqual(await repo.countMessages("bot"), 1); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306"), + await repo.getMessage("bot", "0194244f-d800-7873-8993-ef71ccd47306"), undefined, ); assert.deepStrictEqual( - await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4"), + await repo.getMessage("bot", "01942976-3400-7f34-872e-2cbf0f9eeac4"), undefined, ); assert.deepStrictEqual( - await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14"), + await repo.getMessage("bot", "01942e9c-9000-7480-a553-7a6ce737ce14"), undefined, ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages())).map((m) => m.toJsonLd()), + (await Array.fromAsync(repo.getMessages("bot"))).map((m) => + m.toJsonLd() + ), ), [await messageA.toJsonLd()], ); - await repo.addMessage("0194244f-d800-7873-8993-ef71ccd47306", messageB); - assert.deepStrictEqual(await repo.countMessages(), 2); + await repo.addMessage( + "bot", + "0194244f-d800-7873-8993-ef71ccd47306", + messageB, + ); + assert.deepStrictEqual(await repo.countMessages("bot"), 2); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306")) + await (await repo.getMessage( + "bot", + "0194244f-d800-7873-8993-ef71ccd47306", + )) ?.toJsonLd(), await messageB.toJsonLd(), ); assert.deepStrictEqual( - await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4"), + await repo.getMessage("bot", "01942976-3400-7f34-872e-2cbf0f9eeac4"), undefined, ); assert.deepStrictEqual( - await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14"), + await repo.getMessage("bot", "01942e9c-9000-7480-a553-7a6ce737ce14"), undefined, ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages())).map((m) => m.toJsonLd()), + (await Array.fromAsync(repo.getMessages("bot"))).map((m) => + m.toJsonLd() + ), ), [await messageB.toJsonLd(), await messageA.toJsonLd()], ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages({ order: "oldest" }))).map(( - m, - ) => m.toJsonLd()), + (await Array.fromAsync(repo.getMessages("bot", { order: "oldest" }))) + .map(( + m, + ) => m.toJsonLd()), ), [await messageA.toJsonLd(), await messageB.toJsonLd()], ); - await repo.addMessage("01942976-3400-7f34-872e-2cbf0f9eeac4", messageC); - assert.deepStrictEqual(await repo.countMessages(), 3); + await repo.addMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + messageC, + ); + assert.deepStrictEqual(await repo.countMessages("bot"), 3); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306")) + await (await repo.getMessage( + "bot", + "0194244f-d800-7873-8993-ef71ccd47306", + )) ?.toJsonLd(), await messageB.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4")) + await (await repo.getMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + )) ?.toJsonLd(), await messageC.toJsonLd(), ); assert.deepStrictEqual( - await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14"), + await repo.getMessage("bot", "01942e9c-9000-7480-a553-7a6ce737ce14"), undefined, ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages({ order: "newest" }))).map(( - m, - ) => m.toJsonLd()), + (await Array.fromAsync(repo.getMessages("bot", { order: "newest" }))) + .map(( + m, + ) => m.toJsonLd()), ), [ await messageC.toJsonLd(), @@ -323,9 +363,10 @@ for (const name in factories) { ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages({ order: "oldest" }))).map(( - m, - ) => m.toJsonLd()), + (await Array.fromAsync(repo.getMessages("bot", { order: "oldest" }))) + .map(( + m, + ) => m.toJsonLd()), ), [ await messageA.toJsonLd(), @@ -334,31 +375,47 @@ for (const name in factories) { ], ); - await repo.addMessage("01942e9c-9000-7480-a553-7a6ce737ce14", messageD); - assert.deepStrictEqual(await repo.countMessages(), 4); + await repo.addMessage( + "bot", + "01942e9c-9000-7480-a553-7a6ce737ce14", + messageD, + ); + assert.deepStrictEqual(await repo.countMessages("bot"), 4); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306")) + await (await repo.getMessage( + "bot", + "0194244f-d800-7873-8993-ef71ccd47306", + )) ?.toJsonLd(), await messageB.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4")) + await (await repo.getMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + )) ?.toJsonLd(), await messageC.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14")) + await (await repo.getMessage( + "bot", + "01942e9c-9000-7480-a553-7a6ce737ce14", + )) ?.toJsonLd(), await messageD.toJsonLd(), ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages())).map(( + (await Array.fromAsync(repo.getMessages("bot"))).map(( m, ) => m.toJsonLd()), ), @@ -372,7 +429,7 @@ for (const name in factories) { assert.deepStrictEqual( await Promise.all( (await Array.fromAsync( - repo.getMessages({ + repo.getMessages("bot", { order: "oldest", until: Temporal.Instant.from("2025-01-03T00:00:00Z"), }), @@ -387,7 +444,7 @@ for (const name in factories) { assert.deepStrictEqual( await Promise.all( (await Array.fromAsync( - repo.getMessages({ + repo.getMessages("bot", { since: Temporal.Instant.from("2025-01-02T00:00:00Z"), }), )).map((m) => m.toJsonLd()), @@ -401,7 +458,7 @@ for (const name in factories) { assert.deepStrictEqual( await Promise.all( (await Array.fromAsync( - repo.getMessages({ + repo.getMessages("bot", { until: Temporal.Instant.from("2025-01-03T00:00:00Z"), since: Temporal.Instant.from("2025-01-02T00:00:00Z"), }), @@ -414,37 +471,48 @@ for (const name in factories) { ); const removed = await repo.removeMessage( + "bot", "0194244f-d800-7873-8993-ef71ccd47306", ); assert.deepStrictEqual( await removed?.toJsonLd(), await messageB.toJsonLd(), ); - assert.deepStrictEqual(await repo.countMessages(), 3); + assert.deepStrictEqual(await repo.countMessages("bot"), 3); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306"), + await repo.getMessage("bot", "0194244f-d800-7873-8993-ef71ccd47306"), undefined, ); assert.deepStrictEqual( - await (await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4")) + await (await repo.getMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + )) ?.toJsonLd(), await messageC.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14")) + await (await repo.getMessage( + "bot", + "01942e9c-9000-7480-a553-7a6ce737ce14", + )) ?.toJsonLd(), await messageD.toJsonLd(), ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages({ order: "newest" }))).map(( - m, - ) => m.toJsonLd()), + (await Array.fromAsync(repo.getMessages("bot", { order: "newest" }))) + .map(( + m, + ) => m.toJsonLd()), ), [ await messageD.toJsonLd(), @@ -454,9 +522,10 @@ for (const name in factories) { ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getMessages({ order: "oldest" }))).map(( - m, - ) => m.toJsonLd()), + (await Array.fromAsync(repo.getMessages("bot", { order: "oldest" }))) + .map(( + m, + ) => m.toJsonLd()), ), [ await messageA.toJsonLd(), @@ -466,6 +535,7 @@ for (const name in factories) { ); await repo.updateMessage( + "bot", "01942976-3400-7f34-872e-2cbf0f9eeac4", async (messageC) => messageC.clone({ @@ -473,29 +543,39 @@ for (const name in factories) { updated: messageC2.updated, }), ); - assert.deepStrictEqual(await repo.countMessages(), 3); + assert.deepStrictEqual(await repo.countMessages("bot"), 3); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306"), + await repo.getMessage("bot", "0194244f-d800-7873-8993-ef71ccd47306"), undefined, ); assert.deepStrictEqual( - await (await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4")) + await (await repo.getMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + )) ?.toJsonLd(), await messageC2.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14")) + await (await repo.getMessage( + "bot", + "01942e9c-9000-7480-a553-7a6ce737ce14", + )) ?.toJsonLd(), await messageD.toJsonLd(), ); let updaterCalled = false; const updated = await repo.updateMessage( + "bot", "00000000-0000-0000-0000-000000000000", (message) => { updaterCalled = true; @@ -504,49 +584,68 @@ for (const name in factories) { ); assert.deepStrictEqual(updated, false); assert.deepStrictEqual(updaterCalled, false); - assert.deepStrictEqual(await repo.countMessages(), 3); + assert.deepStrictEqual(await repo.countMessages("bot"), 3); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306"), + await repo.getMessage("bot", "0194244f-d800-7873-8993-ef71ccd47306"), undefined, ); assert.deepStrictEqual( - await (await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4")) + await (await repo.getMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + )) ?.toJsonLd(), await messageC2.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14")) + await (await repo.getMessage( + "bot", + "01942e9c-9000-7480-a553-7a6ce737ce14", + )) ?.toJsonLd(), await messageD.toJsonLd(), ); const updated2 = await repo.updateMessage( + "bot", "01942e9c-9000-7480-a553-7a6ce737ce14", (_) => undefined, ); assert.deepStrictEqual(updated2, false); - assert.deepStrictEqual(await repo.countMessages(), 3); + assert.deepStrictEqual(await repo.countMessages("bot"), 3); assert.deepStrictEqual( - await (await repo.getMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0")) + await (await repo.getMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + )) ?.toJsonLd(), await messageA.toJsonLd(), ); assert.deepStrictEqual( - await repo.getMessage("0194244f-d800-7873-8993-ef71ccd47306"), + await repo.getMessage("bot", "0194244f-d800-7873-8993-ef71ccd47306"), undefined, ); assert.deepStrictEqual( - await (await repo.getMessage("01942976-3400-7f34-872e-2cbf0f9eeac4")) + await (await repo.getMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + )) ?.toJsonLd(), await messageC2.toJsonLd(), ); assert.deepStrictEqual( - await (await repo.getMessage("01942e9c-9000-7480-a553-7a6ce737ce14")) + await (await repo.getMessage( + "bot", + "01942e9c-9000-7480-a553-7a6ce737ce14", + )) ?.toJsonLd(), await messageD.toJsonLd(), ); @@ -568,70 +667,95 @@ for (const name in factories) { "https://example.com/ap/follow/8b76286d-5eef-4f02-8a16-080ff2b0e2ca", ); - assert.deepStrictEqual(await repo.countFollowers(), 0); - assert.deepStrictEqual(await repo.hasFollower(followerA.id!), false); - assert.deepStrictEqual(await repo.hasFollower(followerB.id!), false); - assert.deepStrictEqual(await Array.fromAsync(repo.getFollowers()), []); + assert.deepStrictEqual(await repo.countFollowers("bot"), 0); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerA.id!), + false, + ); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerB.id!), + false, + ); + assert.deepStrictEqual( + await Array.fromAsync(repo.getFollowers("bot")), + [], + ); - await repo.addFollower(followFromA, followerA); - assert.deepStrictEqual(await repo.countFollowers(), 1); - assert.ok(await repo.hasFollower(followerA.id!)); - assert.deepStrictEqual(await repo.hasFollower(followerB.id!), false); + await repo.addFollower("bot", followFromA, followerA); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); + assert.ok(await repo.hasFollower("bot", followerA.id!)); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerB.id!), + false, + ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getFollowers())).map((f) => f.toJsonLd()), + (await Array.fromAsync(repo.getFollowers("bot"))).map((f) => + f.toJsonLd() + ), ), [await followerA.toJsonLd()], ); - await repo.addFollower(followFromB, followerB); - assert.deepStrictEqual(await repo.countFollowers(), 2); - assert.ok(await repo.hasFollower(followerA.id!)); - assert.ok(await repo.hasFollower(followerB.id!)); + await repo.addFollower("bot", followFromB, followerB); + assert.deepStrictEqual(await repo.countFollowers("bot"), 2); + assert.ok(await repo.hasFollower("bot", followerA.id!)); + assert.ok(await repo.hasFollower("bot", followerB.id!)); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getFollowers())).map((f) => f.toJsonLd()), + (await Array.fromAsync(repo.getFollowers("bot"))).map((f) => + f.toJsonLd() + ), ), [await followerA.toJsonLd(), await followerB.toJsonLd()], ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getFollowers({ offset: 1 }))).map((f) => - f.toJsonLd() - ), + (await Array.fromAsync(repo.getFollowers("bot", { offset: 1 }))).map(( + f, + ) => f.toJsonLd()), ), [await followerB.toJsonLd()], ); assert.deepStrictEqual( await Promise.all( - (await Array.fromAsync(repo.getFollowers({ limit: 1 }))).map((f) => - f.toJsonLd() - ), + (await Array.fromAsync(repo.getFollowers("bot", { limit: 1 }))).map(( + f, + ) => f.toJsonLd()), ), [await followerA.toJsonLd()], ); assert.deepStrictEqual( - await repo.removeFollower(followFromA, followerB.id!), + await repo.removeFollower("bot", followFromA, followerB.id!), undefined, ); assert.deepStrictEqual( - await repo.removeFollower(followFromB, followerA.id!), + await repo.removeFollower("bot", followFromB, followerA.id!), undefined, ); - assert.deepStrictEqual(await repo.countFollowers(), 2); - assert.ok(await repo.hasFollower(followerA.id!)); - assert.ok(await repo.hasFollower(followerB.id!)); + assert.deepStrictEqual(await repo.countFollowers("bot"), 2); + assert.ok(await repo.hasFollower("bot", followerA.id!)); + assert.ok(await repo.hasFollower("bot", followerB.id!)); - await repo.removeFollower(followFromA, followerA.id!); - assert.deepStrictEqual(await repo.countFollowers(), 1); - assert.deepStrictEqual(await repo.hasFollower(followerA.id!), false); - assert.ok(await repo.hasFollower(followerB.id!)); + await repo.removeFollower("bot", followFromA, followerA.id!); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerA.id!), + false, + ); + assert.ok(await repo.hasFollower("bot", followerB.id!)); - await repo.removeFollower(followFromB, followerB.id!); - assert.deepStrictEqual(await repo.countFollowers(), 0); - assert.deepStrictEqual(await repo.hasFollower(followerA.id!), false); - assert.deepStrictEqual(await repo.hasFollower(followerB.id!), false); + await repo.removeFollower("bot", followFromB, followerB.id!); + assert.deepStrictEqual(await repo.countFollowers("bot"), 0); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerA.id!), + false, + ); + assert.deepStrictEqual( + await repo.hasFollower("bot", followerB.id!), + false, + ); }); test("sent follows", async () => { @@ -644,20 +768,30 @@ for (const name in factories) { }); assert.deepStrictEqual( - await repo.getSentFollow("03a395a2-353a-4894-afdb-2cab31a7b004"), + await repo.getSentFollow("bot", "03a395a2-353a-4894-afdb-2cab31a7b004"), undefined, ); - await repo.addSentFollow("03a395a2-353a-4894-afdb-2cab31a7b004", follow); + await repo.addSentFollow( + "bot", + "03a395a2-353a-4894-afdb-2cab31a7b004", + follow, + ); assert.deepStrictEqual( - await (await repo.getSentFollow("03a395a2-353a-4894-afdb-2cab31a7b004")) + await (await repo.getSentFollow( + "bot", + "03a395a2-353a-4894-afdb-2cab31a7b004", + )) ?.toJsonLd(), await follow.toJsonLd(), ); - await repo.removeSentFollow("03a395a2-353a-4894-afdb-2cab31a7b004"); + await repo.removeSentFollow( + "bot", + "03a395a2-353a-4894-afdb-2cab31a7b004", + ); assert.deepStrictEqual( - await repo.getSentFollow("03a395a2-353a-4894-afdb-2cab31a7b004"), + await repo.getSentFollow("bot", "03a395a2-353a-4894-afdb-2cab31a7b004"), undefined, ); }); @@ -672,16 +806,22 @@ for (const name in factories) { object: followeeId, }); - assert.deepStrictEqual(await repo.getFollowee(followeeId), undefined); + assert.deepStrictEqual( + await repo.getFollowee("bot", followeeId), + undefined, + ); - await repo.addFollowee(followeeId, follow); + await repo.addFollowee("bot", followeeId, follow); assert.deepStrictEqual( - await (await repo.getFollowee(followeeId))?.toJsonLd(), + await (await repo.getFollowee("bot", followeeId))?.toJsonLd(), await follow.toJsonLd(), ); - await repo.removeFollowee(followeeId); - assert.deepStrictEqual(await repo.getFollowee(followeeId), undefined); + await repo.removeFollowee("bot", followeeId); + assert.deepStrictEqual( + await repo.getFollowee("bot", followeeId), + undefined, + ); }); test("poll voting", async () => { @@ -692,71 +832,71 @@ for (const name in factories) { const voter3 = new URL("https://example.com/ap/actor/charlie"); // Initially, no votes exist - assert.deepStrictEqual(await repo.countVoters(messageId1), 0); - assert.deepStrictEqual(await repo.countVotes(messageId1), {}); - assert.deepStrictEqual(await repo.countVoters(messageId2), 0); - assert.deepStrictEqual(await repo.countVotes(messageId2), {}); + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 0); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), {}); + assert.deepStrictEqual(await repo.countVoters("bot", messageId2), 0); + assert.deepStrictEqual(await repo.countVotes("bot", messageId2), {}); // Single voter, single option - await repo.vote(messageId1, voter1, "option1"); - assert.deepStrictEqual(await repo.countVoters(messageId1), 1); - assert.deepStrictEqual(await repo.countVotes(messageId1), { + await repo.vote("bot", messageId1, voter1, "option1"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 1); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), { "option1": 1, }); // Same voter votes for same option again (should be ignored) - await repo.vote(messageId1, voter1, "option1"); - assert.deepStrictEqual(await repo.countVoters(messageId1), 1); - assert.deepStrictEqual(await repo.countVotes(messageId1), { + await repo.vote("bot", messageId1, voter1, "option1"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 1); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), { "option1": 1, }); // Same voter votes for different option (multiple choice) - await repo.vote(messageId1, voter1, "option2"); - assert.deepStrictEqual(await repo.countVoters(messageId1), 1); - assert.deepStrictEqual(await repo.countVotes(messageId1), { + await repo.vote("bot", messageId1, voter1, "option2"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 1); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), { "option1": 1, "option2": 1, }); // Different voter votes for same option - await repo.vote(messageId1, voter2, "option1"); - assert.deepStrictEqual(await repo.countVoters(messageId1), 2); - assert.deepStrictEqual(await repo.countVotes(messageId1), { + await repo.vote("bot", messageId1, voter2, "option1"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 2); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), { "option1": 2, "option2": 1, }); // Third voter votes for new option - await repo.vote(messageId1, voter3, "option3"); - assert.deepStrictEqual(await repo.countVoters(messageId1), 3); - assert.deepStrictEqual(await repo.countVotes(messageId1), { + await repo.vote("bot", messageId1, voter3, "option3"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 3); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), { "option1": 2, "option2": 1, "option3": 1, }); // Votes for different message should be separate - await repo.vote(messageId2, voter1, "optionA"); - await repo.vote(messageId2, voter2, "optionB"); - assert.deepStrictEqual(await repo.countVoters(messageId2), 2); - assert.deepStrictEqual(await repo.countVotes(messageId2), { + await repo.vote("bot", messageId2, voter1, "optionA"); + await repo.vote("bot", messageId2, voter2, "optionB"); + assert.deepStrictEqual(await repo.countVoters("bot", messageId2), 2); + assert.deepStrictEqual(await repo.countVotes("bot", messageId2), { "optionA": 1, "optionB": 1, }); // Original message votes should remain unchanged - assert.deepStrictEqual(await repo.countVoters(messageId1), 3); - assert.deepStrictEqual(await repo.countVotes(messageId1), { + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 3); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), { "option1": 2, "option2": 1, "option3": 1, }); // Test with empty options (edge case) - await repo.vote(messageId1, voter1, ""); - assert.deepStrictEqual(await repo.countVoters(messageId1), 3); - assert.deepStrictEqual(await repo.countVotes(messageId1), { + await repo.vote("bot", messageId1, voter1, ""); + assert.deepStrictEqual(await repo.countVoters("bot", messageId1), 3); + assert.deepStrictEqual(await repo.countVotes("bot", messageId1), { "option1": 2, "option2": 1, "option3": 1, @@ -765,3 +905,256 @@ for (const name in factories) { }); }); } + +function createNote(uuid: Uuid, actor: string): Create { + return new Create({ + id: new URL(`https://example.com/ap/actor/${actor}/create/${uuid}`), + actor: new URL(`https://example.com/ap/actor/${actor}`), + to: new URL(`https://example.com/ap/actor/${actor}/followers`), + cc: PUBLIC_COLLECTION, + object: new Note({ + id: new URL(`https://example.com/ap/actor/${actor}/note/${uuid}`), + attribution: new URL(`https://example.com/ap/actor/${actor}`), + to: new URL(`https://example.com/ap/actor/${actor}/followers`), + cc: PUBLIC_COLLECTION, + content: "Hello, world!", + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }), + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }); +} + +for (const name in factories) { + const factory = factories[name]; + + describe(`${name}: bot isolation`, () => { + test("key pairs are isolated by bot identifier", async () => { + const repo = factory(); + await repo.setKeyPairs("botA", keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("botB"), undefined); + assert.deepStrictEqual(await repo.getKeyPairs("botA"), keyPairs); + }); + + test("messages are isolated by bot identifier", async () => { + const repo = factory(); + const messageId: Uuid = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = createNote(messageId, "botA"); + await repo.addMessage("botA", messageId, message); + assert.deepStrictEqual( + await repo.getMessage("botB", messageId), + undefined, + ); + assert.deepStrictEqual(await repo.countMessages("botB"), 0); + assert.deepStrictEqual( + await Array.fromAsync(repo.getMessages("botB")), + [], + ); + assert.deepStrictEqual(await repo.countMessages("botA"), 1); + assert.deepStrictEqual( + await (await repo.getMessage("botA", messageId))?.toJsonLd(), + await message.toJsonLd(), + ); + + // Removing through the wrong bot identifier must not delete the message: + assert.deepStrictEqual( + await repo.removeMessage("botB", messageId), + undefined, + ); + assert.deepStrictEqual(await repo.countMessages("botA"), 1); + + // Updating through the wrong bot identifier must not touch the message: + let updaterCalled = false; + const updated = await repo.updateMessage("botB", messageId, (message) => { + updaterCalled = true; + return message; + }); + assert.deepStrictEqual(updated, false); + assert.deepStrictEqual(updaterCalled, false); + }); + + test("followers are isolated by bot identifier", async () => { + const repo = factory(); + const follower = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }); + const followId = new URL( + "https://example.com/ap/follow/be2da56a-0ea3-4a6a-9dff-2a1837be67e0", + ); + await repo.addFollower("botA", followId, follower); + assert.deepStrictEqual( + await repo.hasFollower("botB", follower.id!), + false, + ); + assert.deepStrictEqual(await repo.countFollowers("botB"), 0); + assert.deepStrictEqual( + await Array.fromAsync(repo.getFollowers("botB")), + [], + ); + assert.ok(await repo.hasFollower("botA", follower.id!)); + + // Removing through the wrong bot identifier must not delete the follower: + assert.deepStrictEqual( + await repo.removeFollower("botB", followId, follower.id!), + undefined, + ); + assert.ok(await repo.hasFollower("botA", follower.id!)); + }); + + test("sent follows are isolated by bot identifier", async () => { + const repo = factory(); + const followUuid: Uuid = "03a395a2-353a-4894-afdb-2cab31a7b004"; + const follow = new Follow({ + id: new URL(`https://example.com/ap/actor/botA/follow/${followUuid}`), + actor: new URL("https://example.com/ap/actor/botA"), + object: new URL("https://example.com/ap/actor/john"), + }); + await repo.addSentFollow("botA", followUuid, follow); + assert.deepStrictEqual( + await repo.getSentFollow("botB", followUuid), + undefined, + ); + assert.deepStrictEqual( + await repo.removeSentFollow("botB", followUuid), + undefined, + ); + assert.deepStrictEqual( + await (await repo.getSentFollow("botA", followUuid))?.toJsonLd(), + await follow.toJsonLd(), + ); + }); + + test("followees are isolated by bot identifier", async () => { + const repo = factory(); + const followeeId = new URL("https://example.com/ap/actor/john"); + const follow = new Follow({ + id: new URL( + "https://example.com/ap/actor/botA/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/botA"), + object: followeeId, + }); + await repo.addFollowee("botA", followeeId, follow); + assert.deepStrictEqual( + await repo.getFollowee("botB", followeeId), + undefined, + ); + assert.deepStrictEqual( + await repo.removeFollowee("botB", followeeId), + undefined, + ); + assert.deepStrictEqual( + await (await repo.getFollowee("botA", followeeId))?.toJsonLd(), + await follow.toJsonLd(), + ); + }); + + test("poll votes are isolated by bot identifier", async () => { + const repo = factory(); + const messageId: Uuid = "01945678-1234-7890-abcd-ef0123456789"; + const voter = new URL("https://example.com/ap/actor/alice"); + await repo.vote("botA", messageId, voter, "option1"); + assert.deepStrictEqual(await repo.countVoters("botB", messageId), 0); + assert.deepStrictEqual(await repo.countVotes("botB", messageId), {}); + assert.deepStrictEqual(await repo.countVoters("botA", messageId), 1); + assert.deepStrictEqual(await repo.countVotes("botA", messageId), { + option1: 1, + }); + }); + }); + + describe(`${name}: findFollowedBots()`, () => { + test("yields the identifiers of bots following the given actor", async () => { + const repo = factory(); + const followeeId = new URL("https://example.com/ap/actor/john"); + const otherFolloweeId = new URL("https://example.com/ap/actor/jane"); + const followA = new Follow({ + id: new URL( + "https://example.com/ap/actor/botA/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/botA"), + object: followeeId, + }); + const followB = new Follow({ + id: new URL( + "https://example.com/ap/actor/botB/follow/e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c", + ), + actor: new URL("https://example.com/ap/actor/botB"), + object: followeeId, + }); + + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + [], + ); + + await repo.addFollowee("botA", followeeId, followA); + await repo.addFollowee("botB", followeeId, followB); + const bots = await Array.fromAsync(repo.findFollowedBots(followeeId)); + bots.sort(); + assert.deepStrictEqual(bots, ["botA", "botB"]); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(otherFolloweeId)), + [], + ); + + // Adding the same followee twice must not duplicate the identifier: + await repo.addFollowee("botA", followeeId, followA); + const bots2 = await Array.fromAsync(repo.findFollowedBots(followeeId)); + bots2.sort(); + assert.deepStrictEqual(bots2, ["botA", "botB"]); + + await repo.removeFollowee("botA", followeeId); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + ["botB"], + ); + + await repo.removeFollowee("botB", followeeId); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + [], + ); + }); + }); + + describe(`${name}: forIdentifier()`, () => { + test("returns a repository view scoped to the given identifier", async () => { + const repo = factory(); + const scoped = repo.forIdentifier("botA"); + assert.deepStrictEqual(scoped.identifier, "botA"); + + // Writes through the scoped view are visible through the root: + const messageId: Uuid = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = createNote(messageId, "botA"); + await scoped.addMessage(messageId, message); + assert.deepStrictEqual( + await (await repo.getMessage("botA", messageId))?.toJsonLd(), + await message.toJsonLd(), + ); + assert.deepStrictEqual( + await repo.getMessage("botB", messageId), + undefined, + ); + assert.deepStrictEqual(await scoped.countMessages(), 1); + + // Writes through the root are visible through the scoped view: + const follower = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }); + const followId = new URL( + "https://example.com/ap/follow/be2da56a-0ea3-4a6a-9dff-2a1837be67e0", + ); + await repo.addFollower("botA", followId, follower); + assert.ok(await scoped.hasFollower(follower.id!)); + assert.deepStrictEqual(await scoped.countFollowers(), 1); + + // Key pairs round-trip through the scoped view: + await scoped.setKeyPairs(keyPairs); + assert.deepStrictEqual(await scoped.getKeyPairs(), keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("botA"), keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("botB"), undefined); + }); + }); +} diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index 2b9a050..e90deed 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -38,31 +38,48 @@ export type Uuid = ReturnType; /** * A repository for storing bot data. + * + * Since BotKit 0.5.0, a single repository can store data for multiple bot + * actors hosted on the same instance. Every method takes the identifier of + * the bot actor that owns the data as its first parameter, and data belonging + * to different identifiers are completely isolated from each other. + * + * If you deal with a single bot actor, you can use + * the {@link Repository.forIdentifier} method to get + * an {@link ActorScopedRepository} view which binds the identifier once. * @since 0.3.0 */ export interface Repository { /** - * Sets the key pairs of the bot actor. + * Sets the key pairs of a bot actor. + * @param identifier The identifier of the bot actor that owns the key pairs. * @param keyPairs The key pairs to set. */ - setKeyPairs(keyPairs: CryptoKeyPair[]): Promise; + setKeyPairs(identifier: string, keyPairs: CryptoKeyPair[]): Promise; /** - * Gets the key pairs of the bot actor. + * Gets the key pairs of a bot actor. + * @param identifier The identifier of the bot actor that owns the key pairs. * @returns The key pairs of the bot actor. If the key pairs do not exist, * `undefined` will be returned. */ - getKeyPairs(): Promise; + getKeyPairs(identifier: string): Promise; /** * Adds a message to the repository. + * @param identifier The identifier of the bot actor that owns the message. * @param id The UUID of the message. * @param activity The activity to add. */ - addMessage(id: Uuid, activity: Create | Announce): Promise; + addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise; /** * Updates a message in the repository. + * @param identifier The identifier of the bot actor that owns the message. * @param id The UUID of the message. * @param updater The function to update the message. The function will be * called with the existing message, and the return value will @@ -75,6 +92,7 @@ export interface Repository { * exist. */ updateMessage( + identifier: string, id: Uuid, updater: ( existing: Create | Announce, @@ -83,120 +101,181 @@ export interface Repository { /** * Removes a message from the repository. + * @param identifier The identifier of the bot actor that owns the message. * @param id The UUID of the message to remove. * @returns The removed activity. If the message does not exist, `undefined` * will be returned. */ - removeMessage(id: Uuid): Promise; + removeMessage( + identifier: string, + id: Uuid, + ): Promise; /** * Gets messages from the repository. + * @param identifier The identifier of the bot actor that owns the messages. * @param options The options for getting messages. * @returns An async iterable of message activities. */ getMessages( + identifier: string, options?: RepositoryGetMessagesOptions, ): AsyncIterable; /** * Gets a message from the repository. + * @param identifier The identifier of the bot actor that owns the message. * @param id The UUID of the message to get. * @returns The message activity, or `undefined` if the message does not * exist. */ - getMessage(id: Uuid): Promise; + getMessage( + identifier: string, + id: Uuid, + ): Promise; /** * Counts the number of messages in the repository. + * @param identifier The identifier of the bot actor that owns the messages. * @returns The number of messages in the repository. */ - countMessages(): Promise; + countMessages(identifier: string): Promise; /** * Adds a follower to the repository. + * @param identifier The identifier of the bot actor that is followed. * @param followId The URL of the follow request. * @param follower The actor who follows the bot. */ - addFollower(followId: URL, follower: Actor): Promise; + addFollower( + identifier: string, + followId: URL, + follower: Actor, + ): Promise; /** * Removes a follower from the repository. + * @param identifier The identifier of the bot actor that is followed. * @param followId The URL of the follow request. * @param followerId The ID of the actor to remove. * @returns The removed actor. If the follower does not exist or the follow * request is not about the follower, `undefined` will be returned. */ - removeFollower(followId: URL, followerId: URL): Promise; + removeFollower( + identifier: string, + followId: URL, + followerId: URL, + ): Promise; /** * Checks if the repository has a follower. + * @param identifier The identifier of the bot actor that is followed. * @param followerId The ID of the follower to check. * @returns `true` if the repository has the follower, `false` otherwise. */ - hasFollower(followerId: URL): Promise; + hasFollower(identifier: string, followerId: URL): Promise; /** * Gets followers from the repository. + * @param identifier The identifier of the bot actor that is followed. * @param options The options for getting followers. * @returns An async iterable of actors who follow the bot. */ - getFollowers(options?: RepositoryGetFollowersOptions): AsyncIterable; + getFollowers( + identifier: string, + options?: RepositoryGetFollowersOptions, + ): AsyncIterable; /** * Counts the number of followers in the repository. + * @param identifier The identifier of the bot actor that is followed. * @returns The number of followers in the repository. */ - countFollowers(): Promise; + countFollowers(identifier: string): Promise; /** * Adds a sent follow request to the repository. + * @param identifier The identifier of the bot actor that sent the follow + * request. * @param id The UUID of the follow request. * @param follow The follow activity to add. */ - addSentFollow(id: Uuid, follow: Follow): Promise; + addSentFollow(identifier: string, id: Uuid, follow: Follow): Promise; /** * Removes a sent follow request from the repository. + * @param identifier The identifier of the bot actor that sent the follow + * request. * @param id The UUID of the follow request to remove. * @returns The removed follow activity. If the follow request does not * exist, `undefined` will be returned. */ - removeSentFollow(id: Uuid): Promise; + removeSentFollow(identifier: string, id: Uuid): Promise; /** * Gets a sent follow request from the repository. + * @param identifier The identifier of the bot actor that sent the follow + * request. * @param id The UUID of the follow request to get. * @returns The `Follow` activity, or `undefined` if the follow request does * not exist. */ - getSentFollow(id: Uuid): Promise; + getSentFollow(identifier: string, id: Uuid): Promise; /** * Adds a followee to the repository. + * @param identifier The identifier of the bot actor that follows + * the followee. * @param followeeId The ID of the followee to add. * @param follow The follow activity to add. */ - addFollowee(followeeId: URL, follow: Follow): Promise; + addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise; /** * Removes a followee from the repository. + * @param identifier The identifier of the bot actor that follows + * the followee. * @param followeeId The ID of the followee to remove. * @returns The `Follow` activity that was removed. If the followee does not * exist, `undefined` will be returned. */ - removeFollowee(followeeId: URL): Promise; + removeFollowee( + identifier: string, + followeeId: URL, + ): Promise; /** * Gets a followee from the repository. + * @param identifier The identifier of the bot actor that follows + * the followee. * @param followeeId The ID of the followee to get. * @returns The `Follow` activity, or `undefined` if the followee does not * exist. */ - getFollowee(followeeId: URL): Promise; + getFollowee(identifier: string, followeeId: URL): Promise; + + /** + * Finds the identifiers of the bot actors that follow the given actor. + * This is the reverse lookup of {@link Repository.getFollowee}: it answers + * the question “which bots on this instance follow this remote actor?”, + * which is used for routing incoming activities from followed actors to + * the right bots. + * @param followeeId The ID of the followee to look up. + * @returns An async iterable of the identifiers of the bot actors that + * follow the given actor. If no bots follow the actor, an empty + * iterable will be returned. + * @since 0.5.0 + */ + findFollowedBots(followeeId: URL): AsyncIterable; /** * Records a vote in a poll. If the same voter had already voted for the * same option in a poll, the vote will be silently ignored. + * @param identifier The identifier of the bot actor that owns the poll. * @param messageId The UUID of the poll message to vote on. * @param voterId The ID of the voter. It should be a URL of the actor who is * voting. @@ -206,22 +285,29 @@ export interface Repository { * voting for, which is one of multiple calls to this method. * @since 0.3.0 */ - vote(messageId: Uuid, voterId: URL, option: string): Promise; + vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise; /** * Counts the number of voters in a poll. Even if the poll allows multiple * selections, each voter is counted only once. + * @param identifier The identifier of the bot actor that owns the poll. * @param messageId The UUID of the poll message to count voters for. * @returns The number of voters in the poll. If the poll does not exist, * 0 will be returned. * @since 0.3.0 */ - countVoters(messageId: Uuid): Promise; + countVoters(identifier: string, messageId: Uuid): Promise; /** * Counts the votes for each option in a poll. If the poll allows multiple * selections, each option is counted separately, and the same voter can * vote for multiple options. + * @param identifier The identifier of the bot actor that owns the poll. * @param messageId The UUID of the poll message to count votes for. * @returns A record where the keys are the options and the values are * the number of votes for each option. If the poll does not exist, @@ -229,7 +315,285 @@ export interface Repository { * present in the record if no votes were cast for them. * @since 0.3.0 */ - countVotes(messageId: Uuid): Promise>>; + countVotes( + identifier: string, + messageId: Uuid, + ): Promise>>; + + /** + * Returns a view of this repository which is scoped to the given bot actor + * identifier. The returned view exposes the same operations without + * the `identifier` parameter. + * @param identifier The identifier of the bot actor to scope the view to. + * @returns The scoped repository view. + * @since 0.5.0 + */ + forIdentifier(identifier: string): ActorScopedRepository; + + /** + * Migrates data stored by BotKit 0.4 or earlier, which was not scoped by + * bot actor identifiers, so that it belongs to the given identifier. + * Implementations should make this operation idempotent: calling it again + * after a successful migration should be a no-op. + * + * This method is optional; repositories which have no legacy data format + * do not need to implement it. + * @param identifier The identifier of the bot actor that adopts the legacy + * data. + * @since 0.5.0 + */ + migrate?(identifier: string): Promise; +} + +/** + * A view of a {@link Repository} which is scoped to a single bot actor + * identifier. It exposes the same operations as {@link Repository} without + * the `identifier` parameter, which is bound at construction time. + * @since 0.5.0 + */ +export class ActorScopedRepository { + /** + * The underlying repository. + */ + readonly repository: Repository; + + /** + * The identifier of the bot actor this view is scoped to. + */ + readonly identifier: string; + + /** + * Creates a new scoped repository view. + * @param repository The underlying repository. + * @param identifier The identifier of the bot actor to scope the view to. + */ + constructor(repository: Repository, identifier: string) { + this.repository = repository; + this.identifier = identifier; + } + + /** + * Sets the key pairs of the bot actor. + * @param keyPairs The key pairs to set. + */ + setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { + return this.repository.setKeyPairs(this.identifier, keyPairs); + } + + /** + * Gets the key pairs of the bot actor. + * @returns The key pairs of the bot actor. If the key pairs do not exist, + * `undefined` will be returned. + */ + getKeyPairs(): Promise { + return this.repository.getKeyPairs(this.identifier); + } + + /** + * Adds a message to the repository. + * @param id The UUID of the message. + * @param activity The activity to add. + */ + addMessage(id: Uuid, activity: Create | Announce): Promise { + return this.repository.addMessage(this.identifier, id, activity); + } + + /** + * Updates a message in the repository. + * @param id The UUID of the message. + * @param updater The function to update the message. See also + * {@link Repository.updateMessage}. + * @returns `true` if the message was updated, `false` if the message does not + * exist. + */ + updateMessage( + id: Uuid, + updater: ( + existing: Create | Announce, + ) => Create | Announce | undefined | Promise, + ): Promise { + return this.repository.updateMessage(this.identifier, id, updater); + } + + /** + * Removes a message from the repository. + * @param id The UUID of the message to remove. + * @returns The removed activity. If the message does not exist, `undefined` + * will be returned. + */ + removeMessage(id: Uuid): Promise { + return this.repository.removeMessage(this.identifier, id); + } + + /** + * Gets messages from the repository. + * @param options The options for getting messages. + * @returns An async iterable of message activities. + */ + getMessages( + options?: RepositoryGetMessagesOptions, + ): AsyncIterable { + return this.repository.getMessages(this.identifier, options); + } + + /** + * Gets a message from the repository. + * @param id The UUID of the message to get. + * @returns The message activity, or `undefined` if the message does not + * exist. + */ + getMessage(id: Uuid): Promise { + return this.repository.getMessage(this.identifier, id); + } + + /** + * Counts the number of messages in the repository. + * @returns The number of messages in the repository. + */ + countMessages(): Promise { + return this.repository.countMessages(this.identifier); + } + + /** + * Adds a follower to the repository. + * @param followId The URL of the follow request. + * @param follower The actor who follows the bot. + */ + addFollower(followId: URL, follower: Actor): Promise { + return this.repository.addFollower(this.identifier, followId, follower); + } + + /** + * Removes a follower from the repository. + * @param followId The URL of the follow request. + * @param followerId The ID of the actor to remove. + * @returns The removed actor. If the follower does not exist or the follow + * request is not about the follower, `undefined` will be returned. + */ + removeFollower(followId: URL, followerId: URL): Promise { + return this.repository.removeFollower( + this.identifier, + followId, + followerId, + ); + } + + /** + * Checks if the repository has a follower. + * @param followerId The ID of the follower to check. + * @returns `true` if the repository has the follower, `false` otherwise. + */ + hasFollower(followerId: URL): Promise { + return this.repository.hasFollower(this.identifier, followerId); + } + + /** + * Gets followers from the repository. + * @param options The options for getting followers. + * @returns An async iterable of actors who follow the bot. + */ + getFollowers(options?: RepositoryGetFollowersOptions): AsyncIterable { + return this.repository.getFollowers(this.identifier, options); + } + + /** + * Counts the number of followers in the repository. + * @returns The number of followers in the repository. + */ + countFollowers(): Promise { + return this.repository.countFollowers(this.identifier); + } + + /** + * Adds a sent follow request to the repository. + * @param id The UUID of the follow request. + * @param follow The follow activity to add. + */ + addSentFollow(id: Uuid, follow: Follow): Promise { + return this.repository.addSentFollow(this.identifier, id, follow); + } + + /** + * Removes a sent follow request from the repository. + * @param id The UUID of the follow request to remove. + * @returns The removed follow activity. If the follow request does not + * exist, `undefined` will be returned. + */ + removeSentFollow(id: Uuid): Promise { + return this.repository.removeSentFollow(this.identifier, id); + } + + /** + * Gets a sent follow request from the repository. + * @param id The UUID of the follow request to get. + * @returns The `Follow` activity, or `undefined` if the follow request does + * not exist. + */ + getSentFollow(id: Uuid): Promise { + return this.repository.getSentFollow(this.identifier, id); + } + + /** + * Adds a followee to the repository. + * @param followeeId The ID of the followee to add. + * @param follow The follow activity to add. + */ + addFollowee(followeeId: URL, follow: Follow): Promise { + return this.repository.addFollowee(this.identifier, followeeId, follow); + } + + /** + * Removes a followee from the repository. + * @param followeeId The ID of the followee to remove. + * @returns The `Follow` activity that was removed. If the followee does not + * exist, `undefined` will be returned. + */ + removeFollowee(followeeId: URL): Promise { + return this.repository.removeFollowee(this.identifier, followeeId); + } + + /** + * Gets a followee from the repository. + * @param followeeId The ID of the followee to get. + * @returns The `Follow` activity, or `undefined` if the followee does not + * exist. + */ + getFollowee(followeeId: URL): Promise { + return this.repository.getFollowee(this.identifier, followeeId); + } + + /** + * Records a vote in a poll. If the same voter had already voted for the + * same option in a poll, the vote will be silently ignored. + * @param messageId The UUID of the poll message to vote on. + * @param voterId The ID of the voter. + * @param option The option that the voter is voting for. + */ + vote(messageId: Uuid, voterId: URL, option: string): Promise { + return this.repository.vote(this.identifier, messageId, voterId, option); + } + + /** + * Counts the number of voters in a poll. Even if the poll allows multiple + * selections, each voter is counted only once. + * @param messageId The UUID of the poll message to count voters for. + * @returns The number of voters in the poll. If the poll does not exist, + * 0 will be returned. + */ + countVoters(messageId: Uuid): Promise { + return this.repository.countVoters(this.identifier, messageId); + } + + /** + * Counts the votes for each option in a poll. + * @param messageId The UUID of the poll message to count votes for. + * @returns A record where the keys are the options and the values are + * the number of votes for each option. See also + * {@link Repository.countVotes}. + */ + countVotes(messageId: Uuid): Promise>> { + return this.repository.countVotes(this.identifier, messageId); + } } /** @@ -280,52 +644,17 @@ export interface RepositoryGetFollowersOptions { } /** - * The prefixes for key-value store keys used by the bot. - * @since 0.3.0 + * Options for creating a {@link KvRepository}. + * @since 0.5.0 */ -export interface KvStoreRepositoryPrefixes { - /** - * The key prefix used for storing the key pairs of the bot actor. - * @default `["_botkit", "keyPairs"]` - */ - readonly keyPairs: KvKey; - - /** - * The key prefix used for storing published messages. - * @default `["_botkit", "messages"]` - */ - readonly messages: KvKey; - - /** - * The key prefix used for storing followers. - * @default `["_botkit", "followers"]` - */ - readonly followers: KvKey; - - /** - * The key prefix used for storing incoming follow requests. - * @default `["_botkit", "followRequests"]` - */ - readonly followRequests: KvKey; - - /** - * The key prefix used for storing followees. - * @default `["_botkit", "followees"]` - */ - readonly followees: KvKey; - - /** - * The key prefix used for storing outgoing follow requests. - * @default `["_botkit", "follows"]` - */ - readonly follows: KvKey; - +export interface KvRepositoryOptions { /** - * The key prefix used for storing poll votes. - * @default `["_botkit", "polls"]` - * @since 0.3.0 + * The key prefix under which all BotKit data is stored. Data belonging to + * a bot actor is stored under `[...prefix, "bots", identifier, ...]`, and + * instance-wide indices are stored under `[...prefix, "index", ...]`. + * @default `["_botkit"]` */ - readonly polls: KvKey; + readonly prefix?: KvKey; } /** @@ -333,14 +662,19 @@ export interface KvStoreRepositoryPrefixes { */ export class KvRepository implements Repository { readonly kv: KvStore; - readonly prefixes: KvStoreRepositoryPrefixes; + + /** + * The key prefix under which all BotKit data is stored. + * @since 0.5.0 + */ + readonly prefix: KvKey; /** * Creates a new key-value store repository. * @param kv The key-value store to use. - * @param prefixes The prefixes for key-value store keys. + * @param options The options for the repository. */ - constructor(kv: KvStore, prefixes?: KvStoreRepositoryPrefixes) { + constructor(kv: KvStore, options: KvRepositoryOptions = {}) { if (kv.cas == null) { logger.warn( "The given KvStore {kv} does not support CAS operations. " + @@ -349,19 +683,21 @@ export class KvRepository implements Repository { ); } this.kv = kv; - this.prefixes = { - keyPairs: ["_botkit", "keyPairs"], - messages: ["_botkit", "messages"], - followers: ["_botkit", "followers"], - followRequests: ["_botkit", "followRequests"], - followees: ["_botkit", "followees"], - follows: ["_botkit", "follows"], - polls: ["_botkit", "polls"], - ...prefixes ?? {}, - }; - } - - async setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { + this.prefix = options.prefix ?? ["_botkit"]; + } + + #key(identifier: string, ...rest: readonly string[]): KvKey { + return [...this.prefix, "bots", identifier, ...rest]; + } + + #followeeIndexKey(followeeId: URL): KvKey { + return [...this.prefix, "index", "followees", followeeId.href]; + } + + async setKeyPairs( + identifier: string, + keyPairs: CryptoKeyPair[], + ): Promise { const pairs: KeyPair[] = []; for (const keyPair of keyPairs) { const pair: KeyPair = { @@ -370,11 +706,13 @@ export class KvRepository implements Repository { }; pairs.push(pair); } - await this.kv.set(this.prefixes.keyPairs, pairs); + await this.kv.set(this.#key(identifier, "keyPairs"), pairs); } - async getKeyPairs(): Promise { - const keyPairs = await this.kv.get(this.prefixes.keyPairs); + async getKeyPairs(identifier: string): Promise { + const keyPairs = await this.kv.get( + this.#key(identifier, "keyPairs"), + ); if (keyPairs == null) return undefined; const promises = keyPairs.map(async (pair) => ({ privateKey: await importJwk(pair.private, "private"), @@ -383,14 +721,18 @@ export class KvRepository implements Repository { return await Promise.all(promises); } - async addMessage(id: Uuid, activity: Create | Announce): Promise { - const messageKey: KvKey = [...this.prefixes.messages, id]; + async addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise { + const messageKey = this.#key(identifier, "messages", id); await this.kv.set( messageKey, await activity.toJsonLd({ format: "compact" }), ); - const lockKey: KvKey = [...this.prefixes.messages, "lock"]; - const listKey: KvKey = this.prefixes.messages; + const lockKey = this.#key(identifier, "messages", "lock"); + const listKey = this.#key(identifier, "messages"); do { await this.kv.set(lockKey, id); const set = new Set(await this.kv.get(listKey) ?? []); @@ -402,12 +744,13 @@ export class KvRepository implements Repository { } async updateMessage( + identifier: string, id: Uuid, updater: ( existing: Create | Announce, ) => Create | Announce | undefined | Promise, ): Promise { - const kvKey: KvKey = [...this.prefixes.messages, id]; + const kvKey = this.#key(identifier, "messages", id); const createJson = await this.kv.get(kvKey); if (createJson == null) return false; const activity = await Activity.fromJsonLd(createJson); @@ -423,9 +766,12 @@ export class KvRepository implements Repository { return true; } - async removeMessage(id: Uuid): Promise { - const listKey: KvKey = this.prefixes.messages; - const lockKey: KvKey = [...listKey, "lock"]; + async removeMessage( + identifier: string, + id: Uuid, + ): Promise { + const listKey = this.#key(identifier, "messages"); + const lockKey = this.#key(identifier, "messages", "lock"); const lockId = `${id}:delete`; do { await this.kv.set(lockKey, lockId); @@ -435,7 +781,7 @@ export class KvRepository implements Repository { list.sort((a, b) => a < b ? -1 : a > b ? 1 : 0); await this.kv.set(listKey, list); } while (await this.kv.get(lockKey) !== lockId); - const messageKey: KvKey = [...listKey, id]; + const messageKey = this.#key(identifier, "messages", id); const activityJson = await this.kv.get(messageKey); if (activityJson == null) return; await this.kv.delete(messageKey); @@ -447,12 +793,14 @@ export class KvRepository implements Repository { } async *getMessages( + identifier: string, options: RepositoryGetMessagesOptions = {}, ): AsyncIterable { const { order, until, since, limit } = options; const untilTs = until == null ? null : until.epochMilliseconds; const sinceTs = since == null ? null : since.epochMilliseconds; - let messageIds = await this.kv.get(this.prefixes.messages) ?? []; + let messageIds = + await this.kv.get(this.#key(identifier, "messages")) ?? []; if (sinceTs != null) { const offset = messageIds.findIndex((id) => extractTimestamp(id) >= sinceTs @@ -472,7 +820,9 @@ export class KvRepository implements Repository { messageIds = messageIds.slice(0, limit); } for (const id of messageIds) { - const messageJson = await this.kv.get([...this.prefixes.messages, id]); + const messageJson = await this.kv.get( + this.#key(identifier, "messages", id), + ); if (messageJson == null) continue; try { const activity = await Activity.fromJsonLd(messageJson); @@ -485,8 +835,11 @@ export class KvRepository implements Repository { } } - async getMessage(id: Uuid): Promise { - const json = await this.kv.get([...this.prefixes.messages, id]); + async getMessage( + identifier: string, + id: Uuid, + ): Promise { + const json = await this.kv.get(this.#key(identifier, "messages", id)); if (json == null) return undefined; let activity: Activity; try { @@ -501,47 +854,54 @@ export class KvRepository implements Repository { return undefined; } - async countMessages(): Promise { - const messageIds = await this.kv.get(this.prefixes.messages) ?? - []; + async countMessages(identifier: string): Promise { + const messageIds = + await this.kv.get(this.#key(identifier, "messages")) ?? []; return messageIds.length; } - async addFollower(followRequestId: URL, follower: Actor): Promise { + async addFollower( + identifier: string, + followRequestId: URL, + follower: Actor, + ): Promise { if (follower.id == null) { throw new TypeError("The follower ID is missing."); } - const followerKey: KvKey = [...this.prefixes.followers, follower.id.href]; + const followerKey = this.#key(identifier, "followers", follower.id.href); await this.kv.set( followerKey, await follower.toJsonLd({ format: "compact" }), ); - const lockKey: KvKey = [...this.prefixes.followers, "lock"]; - const listKey: KvKey = this.prefixes.followers; + const lockKey = this.#key(identifier, "followers", "lock"); + const listKey = this.#key(identifier, "followers"); do { await this.kv.set(lockKey, follower.id.href); const list = await this.kv.get(listKey) ?? []; if (!list.includes(follower.id.href)) list.push(follower.id.href); await this.kv.set(listKey, list); } while (await this.kv.get(lockKey) !== follower.id.href); - const followRequestKey: KvKey = [ - ...this.prefixes.followRequests, + const followRequestKey = this.#key( + identifier, + "followRequests", followRequestId.href, - ]; + ); await this.kv.set(followRequestKey, follower.id.href); } async removeFollower( + identifier: string, followRequestId: URL, actorId: URL, ): Promise { - const followRequestKey: KvKey = [ - ...this.prefixes.followRequests, + const followRequestKey = this.#key( + identifier, + "followRequests", followRequestId.href, - ]; + ); const followerId = await this.kv.get(followRequestKey); if (followerId == null) return undefined; - const followerKey: KvKey = [...this.prefixes.followers, followerId]; + const followerKey = this.#key(identifier, "followers", followerId); if (followerId !== actorId.href) return undefined; const followerJson = await this.kv.get(followerKey); if (followerJson == null) return undefined; @@ -552,8 +912,8 @@ export class KvRepository implements Repository { return undefined; } if (!isActor(follower)) return undefined; - const lockKey: KvKey = [...this.prefixes.followers, "lock"]; - const listKey: KvKey = this.prefixes.followers; + const lockKey = this.#key(identifier, "followers", "lock"); + const listKey = this.#key(identifier, "followers"); do { await this.kv.set(lockKey, followerId); let list = await this.kv.get(listKey) ?? []; @@ -565,25 +925,25 @@ export class KvRepository implements Repository { return follower; } - async hasFollower(followerId: URL): Promise { - return await this.kv.get([ - ...this.prefixes.followers, - followerId.href, - ]) != null; + async hasFollower(identifier: string, followerId: URL): Promise { + return await this.kv.get( + this.#key(identifier, "followers", followerId.href), + ) != null; } async *getFollowers( + identifier: string, options: RepositoryGetFollowersOptions = {}, ): AsyncIterable { const { offset = 0, limit } = options; - let followerIds = await this.kv.get(this.prefixes.followers) ?? - []; + let followerIds = + await this.kv.get(this.#key(identifier, "followers")) ?? []; followerIds = followerIds.slice(offset); if (limit != null) { followerIds = followerIds.slice(0, limit); } for (const id of followerIds) { - const json = await this.kv.get([...this.prefixes.followers, id]); + const json = await this.kv.get(this.#key(identifier, "followers", id)); let actor: Object; try { actor = await Object.fromJsonLd(json); @@ -595,28 +955,38 @@ export class KvRepository implements Repository { } } - async countFollowers(): Promise { - const followerIds = await this.kv.get(this.prefixes.followers) ?? - []; + async countFollowers(identifier: string): Promise { + const followerIds = + await this.kv.get(this.#key(identifier, "followers")) ?? []; return followerIds.length; } - async addSentFollow(id: Uuid, follow: Follow): Promise { + async addSentFollow( + identifier: string, + id: Uuid, + follow: Follow, + ): Promise { await this.kv.set( - [...this.prefixes.follows, id], + this.#key(identifier, "follows", id), await follow.toJsonLd({ format: "compact" }), ); } - async removeSentFollow(id: Uuid): Promise { - const follow = await this.getSentFollow(id); + async removeSentFollow( + identifier: string, + id: Uuid, + ): Promise { + const follow = await this.getSentFollow(identifier, id); if (follow == null) return undefined; - await this.kv.delete([...this.prefixes.follows, id]); + await this.kv.delete(this.#key(identifier, "follows", id)); return follow; } - async getSentFollow(id: Uuid): Promise { - const followJson = await this.kv.get([...this.prefixes.follows, id]); + async getSentFollow( + identifier: string, + id: Uuid, + ): Promise { + const followJson = await this.kv.get(this.#key(identifier, "follows", id)); if (followJson == null) return undefined; try { return await Follow.fromJsonLd(followJson); @@ -625,25 +995,42 @@ export class KvRepository implements Repository { } } - async addFollowee(followeeId: URL, follow: Follow): Promise { + async addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise { await this.kv.set( - [...this.prefixes.followees, followeeId.href], + this.#key(identifier, "followees", followeeId.href), await follow.toJsonLd({ format: "compact" }), ); - } - - async removeFollowee(followeeId: URL): Promise { - const follow = await this.getFollowee(followeeId); - if (follow == null) return undefined; - await this.kv.delete([...this.prefixes.followees, followeeId.href]); + await this.#addToFolloweeIndex(identifier, followeeId); + } + + async removeFollowee( + identifier: string, + followeeId: URL, + ): Promise { + const follow = await this.getFollowee(identifier, followeeId); + if (follow == null) { + // The followee record may have been deleted by an earlier attempt that + // failed before cleaning up the reverse index; repair it so that + // retries converge. + await this.#removeFromFolloweeIndex(identifier, followeeId); + return undefined; + } + await this.kv.delete(this.#key(identifier, "followees", followeeId.href)); + await this.#removeFromFolloweeIndex(identifier, followeeId); return follow; } - async getFollowee(followeeId: URL): Promise { - const json = await this.kv.get([ - ...this.prefixes.followees, - followeeId.href, - ]); + async getFollowee( + identifier: string, + followeeId: URL, + ): Promise { + const json = await this.kv.get( + this.#key(identifier, "followees", followeeId.href), + ); if (json == null) return undefined; try { return await Follow.fromJsonLd(json); @@ -652,14 +1039,68 @@ export class KvRepository implements Repository { } } - async vote(messageId: Uuid, voterId: URL, option: string): Promise { - const key: KvKey = [...this.prefixes.polls, messageId, option]; + async *findFollowedBots(followeeId: URL): AsyncIterable { + const identifiers = await this.kv.get( + this.#followeeIndexKey(followeeId), + ) ?? []; + for (const identifier of identifiers) yield identifier; + } + + async #addToFolloweeIndex( + identifier: string, + followeeId: URL, + ): Promise { + const key = this.#followeeIndexKey(followeeId); + while (true) { + const prev = await this.kv.get(key); + if (prev != null && prev.includes(identifier)) return; + const next = prev == null ? [identifier] : [...prev, identifier]; + if (this.kv.cas == null) { + await this.kv.set(key, next); + return; + } + if (await this.kv.cas(key, prev, next)) return; + logger.trace( + "CAS operation failed, retrying to index followee {followeeId} for bot {identifier}.", + { followeeId: followeeId.href, identifier }, + ); + } + } + + async #removeFromFolloweeIndex( + identifier: string, + followeeId: URL, + ): Promise { + const key = this.#followeeIndexKey(followeeId); + while (true) { + const prev = await this.kv.get(key); + if (prev == null || !prev.includes(identifier)) return; + const next = prev.filter((id) => id !== identifier); + if (this.kv.cas == null) { + await this.kv.set(key, next); + return; + } + if (await this.kv.cas(key, prev, next)) return; + logger.trace( + "CAS operation failed, retrying to unindex followee {followeeId} for bot {identifier}.", + { followeeId: followeeId.href, identifier }, + ); + } + } + + async vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise { + const key = this.#key(identifier, "polls", messageId, option); while (true) { const prev = await this.kv.get(key); if (prev != null && prev.includes(voterId.href)) return; const next = prev == null ? [voterId.href] : [...prev, voterId.href]; if (this.kv.cas == null) { - this.kv.set(key, next); + await this.kv.set(key, next); break; } else { const success = await this.kv.cas(key, prev, next); @@ -675,7 +1116,7 @@ export class KvRepository implements Repository { ); } } - const optionsKey: KvKey = [...this.prefixes.polls, messageId]; + const optionsKey = this.#key(identifier, "polls", messageId); while (true) { const prevOptions = await this.kv.get(optionsKey); if (prevOptions != null && prevOptions.includes(option)) return; @@ -683,7 +1124,7 @@ export class KvRepository implements Repository { ? [option] : [...prevOptions, option]; if (this.kv.cas == null) { - this.kv.set(optionsKey, nextOptions); + await this.kv.set(optionsKey, nextOptions); break; } else { const success = await this.kv.cas(optionsKey, prevOptions, nextOptions); @@ -700,18 +1141,15 @@ export class KvRepository implements Repository { } } - async countVoters(messageId: Uuid): Promise { - const options = await this.kv.get([ - ...this.prefixes.polls, - messageId, - ]) ?? []; + async countVoters(identifier: string, messageId: Uuid): Promise { + const options = await this.kv.get( + this.#key(identifier, "polls", messageId), + ) ?? []; const result = new Set(); for (const option of options) { - const voters = await this.kv.get([ - ...this.prefixes.polls, - messageId, - option, - ]); + const voters = await this.kv.get( + this.#key(identifier, "polls", messageId, option), + ); if (voters != null) { for (const voter of voters) result.add(voter); } @@ -719,22 +1157,26 @@ export class KvRepository implements Repository { return result.size; } - async countVotes(messageId: Uuid): Promise>> { - const options = await this.kv.get([ - ...this.prefixes.polls, - messageId, - ]) ?? []; + async countVotes( + identifier: string, + messageId: Uuid, + ): Promise>> { + const options = await this.kv.get( + this.#key(identifier, "polls", messageId), + ) ?? []; const result: Record = {}; for (const option of options) { - const voters = await this.kv.get([ - ...this.prefixes.polls, - messageId, - option, - ]); + const voters = await this.kv.get( + this.#key(identifier, "polls", messageId, option), + ); result[option] = voters == null ? 0 : voters.length; } return result; } + + forIdentifier(identifier: string): ActorScopedRepository { + return new ActorScopedRepository(this, identifier); + } } interface KeyPair { @@ -758,58 +1200,89 @@ function extractTimestamp(uuid: string): number { return parseInt(timestampHex, 16); } +interface MemoryActorData { + keyPairs?: CryptoKeyPair[]; + messages: Map; + followers: Map; + followRequests: Record; + sentFollows: Record; + followees: Record; + polls: Record>>; +} + /** * A repository for storing bot data in memory. This repository is not * persistent and is only suitable for testing or development. */ export class MemoryRepository implements Repository { - keyPairs?: CryptoKeyPair[]; - messages: Map = new Map(); - followers: Map = new Map(); - followRequests: Record = {}; - sentFollows: Record = {}; - followees: Record = {}; - polls: Record>> = {}; + #data: Map = new Map(); + + #bucket(identifier: string): MemoryActorData { + let data = this.#data.get(identifier); + if (data == null) { + data = { + messages: new Map(), + followers: new Map(), + followRequests: {}, + sentFollows: {}, + followees: {}, + polls: {}, + }; + this.#data.set(identifier, data); + } + return data; + } - setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { - this.keyPairs = keyPairs; + setKeyPairs(identifier: string, keyPairs: CryptoKeyPair[]): Promise { + this.#bucket(identifier).keyPairs = keyPairs; return Promise.resolve(); } - getKeyPairs(): Promise { - return Promise.resolve(this.keyPairs); + getKeyPairs(identifier: string): Promise { + return Promise.resolve(this.#data.get(identifier)?.keyPairs); } - addMessage(id: Uuid, activity: Create | Announce): Promise { - this.messages.set(id, activity); + addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise { + this.#bucket(identifier).messages.set(id, activity); return Promise.resolve(); } async updateMessage( + identifier: string, id: Uuid, updater: ( existing: Create | Announce, ) => Create | Announce | undefined | Promise, ): Promise { - const existing = this.messages.get(id); - if (existing == null) return false; + const messages = this.#data.get(identifier)?.messages; + const existing = messages?.get(id); + if (messages == null || existing == null) return false; const newActivity = await updater(existing); if (newActivity == null) return false; - this.messages.set(id, newActivity); + messages.set(id, newActivity); return true; } - removeMessage(id: Uuid): Promise { - const activity = this.messages.get(id); - this.messages.delete(id); + removeMessage( + identifier: string, + id: Uuid, + ): Promise { + const messages = this.#data.get(identifier)?.messages; + const activity = messages?.get(id); + messages?.delete(id); return Promise.resolve(activity); } async *getMessages( + identifier: string, options: RepositoryGetMessagesOptions = {}, ): AsyncIterable { const { order, until, since, limit } = options; - let messages = [...this.messages.values()]; + let messages = [...this.#data.get(identifier)?.messages.values() ?? []]; if (since != null) { messages = messages.filter((message) => message.published != null && @@ -834,48 +1307,65 @@ export class MemoryRepository implements Repository { ); } if (limit != null) { - messages.slice(0, limit); + messages = messages.slice(0, limit); } for (const message of messages) yield message; } - getMessage(id: Uuid): Promise { - return Promise.resolve(this.messages.get(id)); + getMessage( + identifier: string, + id: Uuid, + ): Promise { + return Promise.resolve(this.#data.get(identifier)?.messages.get(id)); } - countMessages(): Promise { - return Promise.resolve(this.messages.size); + countMessages(identifier: string): Promise { + return Promise.resolve(this.#data.get(identifier)?.messages.size ?? 0); } - addFollower(followId: URL, follower: Actor): Promise { + addFollower( + identifier: string, + followId: URL, + follower: Actor, + ): Promise { if (follower.id == null) { throw new TypeError("The follower ID is missing."); } - this.followers.set(follower.id.href, follower); - this.followRequests[followId.href] = follower.id.href; + const data = this.#bucket(identifier); + data.followers.set(follower.id.href, follower); + data.followRequests[followId.href] = follower.id.href; return Promise.resolve(); } - removeFollower(followId: URL, followerId: URL): Promise { - const existing = this.followRequests[followId.href]; + removeFollower( + identifier: string, + followId: URL, + followerId: URL, + ): Promise { + const data = this.#data.get(identifier); + if (data == null) return Promise.resolve(undefined); + const existing = data.followRequests[followId.href]; if (existing == null || existing !== followerId.href) { return Promise.resolve(undefined); } - delete this.followRequests[followId.href]; - const follower = this.followers.get(followerId.href); - this.followers.delete(followerId.href); + delete data.followRequests[followId.href]; + const follower = data.followers.get(followerId.href); + data.followers.delete(followerId.href); return Promise.resolve(follower); } - hasFollower(followerId: URL): Promise { - return Promise.resolve(this.followers.has(followerId.href)); + hasFollower(identifier: string, followerId: URL): Promise { + return Promise.resolve( + this.#data.get(identifier)?.followers.has(followerId.href) ?? false, + ); } async *getFollowers( + identifier: string, options: RepositoryGetFollowersOptions = {}, ): AsyncIterable { const { offset = 0, limit } = options; - let followers = [...this.followers.values()]; + let followers = [...this.#data.get(identifier)?.followers.values() ?? []]; followers.sort((a, b) => b.id!.href.localeCompare(a.id!.href) ?? 0); if (offset > 0) { followers = followers.slice(offset); @@ -888,49 +1378,76 @@ export class MemoryRepository implements Repository { } } - countFollowers(): Promise { - return Promise.resolve(this.followers.size); + countFollowers(identifier: string): Promise { + return Promise.resolve(this.#data.get(identifier)?.followers.size ?? 0); } - addSentFollow(id: Uuid, follow: Follow): Promise { - this.sentFollows[id] = follow; + addSentFollow(identifier: string, id: Uuid, follow: Follow): Promise { + this.#bucket(identifier).sentFollows[id] = follow; return Promise.resolve(); } - removeSentFollow(id: Uuid): Promise { - const follow = this.sentFollows[id]; - delete this.sentFollows[id]; + removeSentFollow(identifier: string, id: Uuid): Promise { + const sentFollows = this.#data.get(identifier)?.sentFollows; + if (sentFollows == null) return Promise.resolve(undefined); + const follow = sentFollows[id]; + delete sentFollows[id]; return Promise.resolve(follow); } - getSentFollow(id: Uuid): Promise { - return Promise.resolve(this.sentFollows[id]); + getSentFollow(identifier: string, id: Uuid): Promise { + return Promise.resolve(this.#data.get(identifier)?.sentFollows[id]); } - addFollowee(followeeId: URL, follow: Follow): Promise { - this.followees[followeeId.href] = follow; + addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise { + this.#bucket(identifier).followees[followeeId.href] = follow; return Promise.resolve(); } - removeFollowee(followeeId: URL): Promise { - const follow = this.followees[followeeId.href]; - delete this.followees[followeeId.href]; + removeFollowee( + identifier: string, + followeeId: URL, + ): Promise { + const followees = this.#data.get(identifier)?.followees; + if (followees == null) return Promise.resolve(undefined); + const follow = followees[followeeId.href]; + delete followees[followeeId.href]; return Promise.resolve(follow); } - getFollowee(followeeId: URL): Promise { - return Promise.resolve(this.followees[followeeId.href]); + getFollowee( + identifier: string, + followeeId: URL, + ): Promise { + return Promise.resolve( + this.#data.get(identifier)?.followees[followeeId.href], + ); } - vote(messageId: Uuid, voterId: URL, option: string): Promise { - const poll = this.polls[messageId] ??= {}; + async *findFollowedBots(followeeId: URL): AsyncIterable { + for (const [identifier, data] of this.#data) { + if (followeeId.href in data.followees) yield identifier; + } + } + + vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise { + const poll = this.#bucket(identifier).polls[messageId] ??= {}; const voters = poll[option] ??= new Set(); voters.add(voterId.href); return Promise.resolve(); } - countVoters(messageId: Uuid): Promise { - const poll = this.polls[messageId]; + countVoters(identifier: string, messageId: Uuid): Promise { + const poll = this.#data.get(identifier)?.polls[messageId]; if (poll == null) return Promise.resolve(0); let voters = new Set(); for (const votersSet of globalThis.Object.values(poll)) { @@ -939,8 +1456,11 @@ export class MemoryRepository implements Repository { return Promise.resolve(voters.size); } - countVotes(messageId: Uuid): Promise>> { - const poll = this.polls[messageId]; + countVotes( + identifier: string, + messageId: Uuid, + ): Promise>> { + const poll = this.#data.get(identifier)?.polls[messageId]; if (poll == null) return Promise.resolve({}); const counts: Record = {}; for (const [option, voters] of globalThis.Object.entries(poll)) { @@ -948,6 +1468,10 @@ export class MemoryRepository implements Repository { } return Promise.resolve(counts); } + + forIdentifier(identifier: string): ActorScopedRepository { + return new ActorScopedRepository(this, identifier); + } } /** @@ -956,9 +1480,10 @@ export class MemoryRepository implements Repository { * of accesses to the underlying persistent storage, but it increases memory * usage. The cache is not persistent and will be lost when the process exits. * - * Note: List operations like `getMessages` and `getFollowers`, and count - * operations like `countMessages` and `countFollowers` are not cached and - * always delegate to the underlying repository. + * Note: List operations like `getMessages` and `getFollowers`, count + * operations like `countMessages` and `countFollowers`, and reverse lookups + * like `findFollowedBots` are not cached and always delegate to the + * underlying repository. * @since 0.3.0 */ export class MemoryCachedRepository implements Repository { @@ -976,182 +1501,259 @@ export class MemoryCachedRepository implements Repository { this.cache = cache ?? new MemoryRepository(); } - async setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { - await this.underlying.setKeyPairs(keyPairs); - await this.cache.setKeyPairs(keyPairs); + async setKeyPairs( + identifier: string, + keyPairs: CryptoKeyPair[], + ): Promise { + await this.underlying.setKeyPairs(identifier, keyPairs); + await this.cache.setKeyPairs(identifier, keyPairs); } - async getKeyPairs(): Promise { - let keyPairs = await this.cache.getKeyPairs(); + async getKeyPairs(identifier: string): Promise { + let keyPairs = await this.cache.getKeyPairs(identifier); if (keyPairs === undefined) { - keyPairs = await this.underlying.getKeyPairs(); - if (keyPairs !== undefined) await this.cache.setKeyPairs(keyPairs); + keyPairs = await this.underlying.getKeyPairs(identifier); + if (keyPairs !== undefined) { + await this.cache.setKeyPairs(identifier, keyPairs); + } } return keyPairs; } - async addMessage(id: Uuid, activity: Create | Announce): Promise { - await this.underlying.addMessage(id, activity); - await this.cache.addMessage(id, activity); + async addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise { + await this.underlying.addMessage(identifier, id, activity); + await this.cache.addMessage(identifier, id, activity); } async updateMessage( + identifier: string, id: Uuid, updater: ( existing: Create | Announce, ) => Create | Announce | undefined | Promise, ): Promise { // Apply update to underlying first - const updated = await this.underlying.updateMessage(id, updater); + const updated = await this.underlying.updateMessage( + identifier, + id, + updater, + ); if (updated) { // If successful, fetch the updated message and update the cache - const updatedMessage = await this.underlying.getMessage(id); + const updatedMessage = await this.underlying.getMessage(identifier, id); if (updatedMessage) { - await this.cache.addMessage(id, updatedMessage); // Use addMessage which acts like set + // Use addMessage which acts like set + await this.cache.addMessage(identifier, id, updatedMessage); } else { // Should not happen if updateMessage returned true, but handle defensively - await this.cache.removeMessage(id); + await this.cache.removeMessage(identifier, id); } } return updated; } - async removeMessage(id: Uuid): Promise { - const removedActivity = await this.underlying.removeMessage(id); + async removeMessage( + identifier: string, + id: Uuid, + ): Promise { + const removedActivity = await this.underlying.removeMessage(identifier, id); if (removedActivity !== undefined) { - await this.cache.removeMessage(id); + await this.cache.removeMessage(identifier, id); } return removedActivity; } // getMessages is not cached due to complexity with options getMessages( + identifier: string, options?: RepositoryGetMessagesOptions, ): AsyncIterable { - return this.underlying.getMessages(options); + return this.underlying.getMessages(identifier, options); } - async getMessage(id: Uuid): Promise { - let message = await this.cache.getMessage(id); + async getMessage( + identifier: string, + id: Uuid, + ): Promise { + let message = await this.cache.getMessage(identifier, id); if (message === undefined) { - message = await this.underlying.getMessage(id); + message = await this.underlying.getMessage(identifier, id); if (message !== undefined) { - await this.cache.addMessage(id, message); // Use addMessage which acts like set + // Use addMessage which acts like set + await this.cache.addMessage(identifier, id, message); } } return message; } // countMessages is not cached - countMessages(): Promise { - return this.underlying.countMessages(); + countMessages(identifier: string): Promise { + return this.underlying.countMessages(identifier); } - async addFollower(followId: URL, follower: Actor): Promise { - await this.underlying.addFollower(followId, follower); - await this.cache.addFollower(followId, follower); + async addFollower( + identifier: string, + followId: URL, + follower: Actor, + ): Promise { + await this.underlying.addFollower(identifier, followId, follower); + await this.cache.addFollower(identifier, followId, follower); } async removeFollower( + identifier: string, followId: URL, followerId: URL, ): Promise { const removedFollower = await this.underlying.removeFollower( + identifier, followId, followerId, ); if (removedFollower !== undefined) { - await this.cache.removeFollower(followId, followerId); + await this.cache.removeFollower(identifier, followId, followerId); } return removedFollower; } - async hasFollower(followerId: URL): Promise { + async hasFollower(identifier: string, followerId: URL): Promise { // Check cache first for potentially faster response - if (await this.cache.hasFollower(followerId)) { + if (await this.cache.hasFollower(identifier, followerId)) { return true; } // If not in cache, check underlying and update cache if found - const exists = await this.underlying.hasFollower(followerId); + const exists = await this.underlying.hasFollower(identifier, followerId); // Note: We don't automatically add to cache here, as we don't have the Actor object // It will be cached if addFollower is called or if getFollowers iterates over it (though getFollowers isn't cached) return exists; } // getFollowers is not cached due to complexity with options - getFollowers(options?: RepositoryGetFollowersOptions): AsyncIterable { + getFollowers( + identifier: string, + options?: RepositoryGetFollowersOptions, + ): AsyncIterable { // We could potentially cache followers as they are iterated, // but for simplicity, delegate directly for now. - return this.underlying.getFollowers(options); + return this.underlying.getFollowers(identifier, options); } // countFollowers is not cached - countFollowers(): Promise { - return this.underlying.countFollowers(); + countFollowers(identifier: string): Promise { + return this.underlying.countFollowers(identifier); } - async addSentFollow(id: Uuid, follow: Follow): Promise { - await this.underlying.addSentFollow(id, follow); - await this.cache.addSentFollow(id, follow); + async addSentFollow( + identifier: string, + id: Uuid, + follow: Follow, + ): Promise { + await this.underlying.addSentFollow(identifier, id, follow); + await this.cache.addSentFollow(identifier, id, follow); } - async removeSentFollow(id: Uuid): Promise { - const removedFollow = await this.underlying.removeSentFollow(id); + async removeSentFollow( + identifier: string, + id: Uuid, + ): Promise { + const removedFollow = await this.underlying.removeSentFollow( + identifier, + id, + ); if (removedFollow !== undefined) { - await this.cache.removeSentFollow(id); + await this.cache.removeSentFollow(identifier, id); } return removedFollow; } - async getSentFollow(id: Uuid): Promise { - let follow = await this.cache.getSentFollow(id); + async getSentFollow( + identifier: string, + id: Uuid, + ): Promise { + let follow = await this.cache.getSentFollow(identifier, id); if (follow === undefined) { - follow = await this.underlying.getSentFollow(id); + follow = await this.underlying.getSentFollow(identifier, id); if (follow !== undefined) { - await this.cache.addSentFollow(id, follow); + await this.cache.addSentFollow(identifier, id, follow); } } return follow; } - async addFollowee(followeeId: URL, follow: Follow): Promise { - await this.underlying.addFollowee(followeeId, follow); - await this.cache.addFollowee(followeeId, follow); + async addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise { + await this.underlying.addFollowee(identifier, followeeId, follow); + await this.cache.addFollowee(identifier, followeeId, follow); } - async removeFollowee(followeeId: URL): Promise { - const removedFollow = await this.underlying.removeFollowee(followeeId); - if (removedFollow !== undefined) { - await this.cache.removeFollowee(followeeId); - } + async removeFollowee( + identifier: string, + followeeId: URL, + ): Promise { + const removedFollow = await this.underlying.removeFollowee( + identifier, + followeeId, + ); + // Invalidate the cache even when the underlying repository returned + // nothing: a retried removal may have already deleted the record while + // the cache still holds it. + await this.cache.removeFollowee(identifier, followeeId); return removedFollow; } - async getFollowee(followeeId: URL): Promise { - let follow = await this.cache.getFollowee(followeeId); + async getFollowee( + identifier: string, + followeeId: URL, + ): Promise { + let follow = await this.cache.getFollowee(identifier, followeeId); if (follow === undefined) { - follow = await this.underlying.getFollowee(followeeId); + follow = await this.underlying.getFollowee(identifier, followeeId); if (follow !== undefined) { - await this.cache.addFollowee(followeeId, follow); + await this.cache.addFollowee(identifier, followeeId, follow); } } return follow; } - async vote(messageId: Uuid, voterId: URL, option: string): Promise { - await this.cache.vote(messageId, voterId, option); - await this.underlying.vote(messageId, voterId, option); + // findFollowedBots is not cached, since the cache may not have the complete + // set of followees + findFollowedBots(followeeId: URL): AsyncIterable { + return this.underlying.findFollowedBots(followeeId); + } + + async vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise { + await this.cache.vote(identifier, messageId, voterId, option); + await this.underlying.vote(identifier, messageId, voterId, option); } - async countVoters(messageId: Uuid): Promise { - const voters = await this.cache.countVoters(messageId); + async countVoters(identifier: string, messageId: Uuid): Promise { + const voters = await this.cache.countVoters(identifier, messageId); if (voters > 0) return voters; - return this.underlying.countVoters(messageId); + return this.underlying.countVoters(identifier, messageId); } - async countVotes(messageId: Uuid): Promise>> { - const votes = await this.cache.countVotes(messageId); + async countVotes( + identifier: string, + messageId: Uuid, + ): Promise>> { + const votes = await this.cache.countVotes(identifier, messageId); if (globalThis.Object.keys(votes).length > 0) return votes; - return await this.underlying.countVotes(messageId); + return await this.underlying.countVotes(identifier, messageId); + } + + forIdentifier(identifier: string): ActorScopedRepository { + return new ActorScopedRepository(this, identifier); } } diff --git a/packages/botkit/src/session-impl.test.ts b/packages/botkit/src/session-impl.test.ts index e505914..ddc2d8f 100644 --- a/packages/botkit/src/session-impl.test.ts +++ b/packages/botkit/src/session-impl.test.ts @@ -61,7 +61,10 @@ test("SessionImpl.follow()", async (t) => { assert.deepStrictEqual(activity.actorId, ctx.getActorUri(bot.identifier)); assert.deepStrictEqual(activity.objectId, actor.id); assert.deepStrictEqual(activity.toIds, [actor.id]); - const follow = await repository.getSentFollow(parsed.values.id as Uuid); + const follow = await repository.getSentFollow( + "bot", + parsed.values.id as Uuid, + ); assert.ok(follow != null); assert.deepStrictEqual( await follow.toJsonLd({ format: "compact" }), @@ -72,6 +75,7 @@ test("SessionImpl.follow()", async (t) => { await t.test("follow again", async () => { ctx.sentActivities = []; await repository.addFollowee( + "bot", new URL("https://example.com/ap/actor/alice"), new Follow({ id: new URL( @@ -134,6 +138,7 @@ test("SessionImpl.unfollow()", async (t) => { await t.test("unfollow", async () => { await repository.addFollowee( + "bot", new URL("https://example.com/ap/actor/alice"), new Follow({ id: new URL( @@ -163,6 +168,7 @@ test("SessionImpl.unfollow()", async (t) => { assert.deepStrictEqual(object.actorId, ctx.getActorUri(bot.identifier)); assert.deepStrictEqual( await repository.getFollowee( + "bot", new URL("https://example.com/ap/actor/alice"), ), undefined, @@ -229,6 +235,7 @@ describe("SessionImpl.follows()", () => { preferredUsername: "alice", }); await repository.addFollowee( + "bot", new URL("https://example.com/ap/actor/alice"), new Follow({ id: new URL( @@ -788,10 +795,26 @@ test("SessionImpl.getOutbox()", async (t) => { }), published: Temporal.Instant.from("2025-01-04T00:00:00Z"), }); - await repository.addMessage("01941f29-7c00-7fe8-ab0a-7b593990a3c0", messageA); - await repository.addMessage("0194244f-d800-7873-8993-ef71ccd47306", messageB); - await repository.addMessage("01942976-3400-7f34-872e-2cbf0f9eeac4", messageC); - await repository.addMessage("01942e9c-9000-7480-a553-7a6ce737ce14", messageD); + await repository.addMessage( + "bot", + "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + messageA, + ); + await repository.addMessage( + "bot", + "0194244f-d800-7873-8993-ef71ccd47306", + messageB, + ); + await repository.addMessage( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + messageC, + ); + await repository.addMessage( + "bot", + "01942e9c-9000-7480-a553-7a6ce737ce14", + messageD, + ); await t.test("default", async () => { const outbox = session.getOutbox({ order: "oldest" }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a3c18c..671b0c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -163,7 +163,7 @@ importers: packages/botkit-postgres: dependencies: '@fedify/botkit': - specifier: ^0.4.0 + specifier: 'workspace:' version: link:../botkit '@fedify/fedify': specifier: ^2.1.2 From e5b4c1b80b2c3116d5d1fd19cea3fc17369c229b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 18:55:21 +0900 Subject: [PATCH 02/36] Migrate legacy KV data to bot-scoped keys BotKit 0.4 and earlier stored KV data under unscoped keys such as ["_botkit", "messages"]. KvRepository.migrate() adopts that data for the given bot actor identifier so that existing single-bot deployments keep their data after upgrading to the bot-scoped key layout. Enumerable categories (key pairs, messages, poll votes, and followers) are copied eagerly. Legacy keys are kept rather than deleted, and the completion marker is written last, so that an interrupted migration is simply retried on the next run without data loss; each record is copied only when the scoped key is missing, so retries do not clobber data written after a previous run. Sent follows, followees, and follow requests are keyed by UUIDs or URLs and have no index list in the legacy layout, so they cannot be enumerated through the KvStore interface. These categories are instead migrated lazily: once migrate() has armed the fallback, a lookup that misses the scoped key consults the legacy key and moves the record over. Lazily moved followees are also inserted into the reverse lookup index used by findFollowedBots(), and the index is re-asserted on later lookups so that a crash between the move and the indexing is repaired by retries. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/repository.test.ts | 251 ++++++++++++++++++++++++- packages/botkit/src/repository.ts | 169 ++++++++++++++++- 2 files changed, 415 insertions(+), 5 deletions(-) diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 7954f1a..943f530 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { MemoryKvStore } from "@fedify/fedify/federation"; -import { importJwk } from "@fedify/fedify/sig"; +import { exportJwk, importJwk } from "@fedify/fedify/sig"; import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; @@ -1158,3 +1158,252 @@ for (const name in factories) { }); }); } + +describe("KvRepository.migrate()", () => { + async function seedLegacyData(kv: MemoryKvStore): Promise<{ + messageId: Uuid; + messageJson: unknown; + followerId: URL; + followRequestId: URL; + followeeId: URL; + followeeFollowJson: unknown; + sentFollowId: Uuid; + sentFollowJson: unknown; + }> { + // Simulates the key layout of BotKit 0.4 and earlier, which was not + // scoped by bot identifiers. + const messageId: Uuid = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = createNote(messageId, "bot"); + const messageJson = await message.toJsonLd({ format: "compact" }); + await kv.set(["_botkit", "messages"], [messageId]); + await kv.set(["_botkit", "messages", messageId], messageJson); + + const keyPairsData = []; + for (const pair of keyPairs) { + keyPairsData.push({ + private: await exportJwk(pair.privateKey), + public: await exportJwk(pair.publicKey), + }); + } + await kv.set(["_botkit", "keyPairs"], keyPairsData); + + const follower = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }); + const followRequestId = new URL( + "https://example.com/ap/follow/be2da56a-0ea3-4a6a-9dff-2a1837be67e0", + ); + await kv.set(["_botkit", "followers"], [follower.id!.href]); + await kv.set( + ["_botkit", "followers", follower.id!.href], + await follower.toJsonLd({ format: "compact" }), + ); + await kv.set( + ["_botkit", "followRequests", followRequestId.href], + follower.id!.href, + ); + + const followeeId = new URL("https://example.com/ap/actor/jane"); + const followeeFollow = new Follow({ + id: new URL( + "https://example.com/ap/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/bot"), + object: followeeId, + }); + const followeeFollowJson = await followeeFollow.toJsonLd({ + format: "compact", + }); + await kv.set(["_botkit", "followees", followeeId.href], followeeFollowJson); + + const sentFollowId: Uuid = "e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c"; + const sentFollow = new Follow({ + id: new URL(`https://example.com/ap/follow/${sentFollowId}`), + actor: new URL("https://example.com/ap/actor/bot"), + object: new URL("https://example.com/ap/actor/joe"), + }); + const sentFollowJson = await sentFollow.toJsonLd({ format: "compact" }); + await kv.set(["_botkit", "follows", sentFollowId], sentFollowJson); + + // Poll votes for the message: + await kv.set(["_botkit", "polls", messageId], ["option1", "option2"]); + await kv.set(["_botkit", "polls", messageId, "option1"], [ + "https://example.com/ap/actor/alice", + "https://example.com/ap/actor/bob", + ]); + await kv.set(["_botkit", "polls", messageId, "option2"], [ + "https://example.com/ap/actor/alice", + ]); + + return { + messageId, + messageJson, + followerId: follower.id!, + followRequestId, + followeeId, + followeeFollowJson, + sentFollowId, + sentFollowJson, + }; + } + + test("adopts legacy unscoped data", async () => { + const kv = new MemoryKvStore(); + const seed = await seedLegacyData(kv); + const repo = new KvRepository(kv); + + await repo.migrate("bot"); + + assert.deepStrictEqual(await repo.getKeyPairs("bot"), keyPairs); + assert.deepStrictEqual(await repo.countMessages("bot"), 1); + assert.deepStrictEqual( + await (await repo.getMessage("bot", seed.messageId))?.toJsonLd({ + format: "compact", + }), + seed.messageJson, + ); + assert.deepStrictEqual( + (await Array.fromAsync(repo.getMessages("bot"))).length, + 1, + ); + assert.ok(await repo.hasFollower("bot", seed.followerId)); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); + assert.deepStrictEqual(await repo.countVoters("bot", seed.messageId), 2); + assert.deepStrictEqual(await repo.countVotes("bot", seed.messageId), { + option1: 2, + option2: 1, + }); + + // Data must belong to the migrated identifier only: + assert.deepStrictEqual(await repo.getKeyPairs("other"), undefined); + assert.deepStrictEqual(await repo.countMessages("other"), 0); + + // Legacy keys are kept (copy, not move), so a partially failed run can + // be retried without data loss: + assert.ok(await kv.get(["_botkit", "messages", seed.messageId]) != null); + }); + + test("is idempotent", async () => { + const kv = new MemoryKvStore(); + const seed = await seedLegacyData(kv); + const repo = new KvRepository(kv); + + await repo.migrate("bot"); + await repo.migrate("bot"); + + assert.deepStrictEqual(await repo.countMessages("bot"), 1); + assert.deepStrictEqual(await repo.countFollowers("bot"), 1); + + // A migrated message that is removed afterwards must not reappear: + await repo.removeMessage("bot", seed.messageId); + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.countMessages("bot"), 0); + }); + + test("does nothing without legacy data", async () => { + const kv = new MemoryKvStore(); + const repo = new KvRepository(kv); + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.countMessages("bot"), 0); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), undefined); + }); + + test("does not clobber scoped data written before migration", async () => { + const kv = new MemoryKvStore(); + await seedLegacyData(kv); + const repo = new KvRepository(kv); + await repo.setKeyPairs("bot", keyPairs.slice(0, 1)); + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), [keyPairs[0]]); + }); + + test("falls back to legacy keys for sent follows", async () => { + const kv = new MemoryKvStore(); + const seed = await seedLegacyData(kv); + const repo = new KvRepository(kv); + await repo.migrate("bot"); + + // Sent follows are keyed by UUID and have no index list, so they are + // migrated lazily on first access: + const follow = await repo.getSentFollow("bot", seed.sentFollowId); + assert.deepStrictEqual( + await follow?.toJsonLd({ format: "compact" }), + seed.sentFollowJson, + ); + // The record is moved to the scoped key: + assert.deepStrictEqual( + await kv.get(["_botkit", "follows", seed.sentFollowId]), + undefined, + ); + assert.deepStrictEqual( + await (await repo.getSentFollow("bot", seed.sentFollowId))?.toJsonLd({ + format: "compact", + }), + seed.sentFollowJson, + ); + + // The fallback applies only to the migrated identifier: + const kv2 = new MemoryKvStore(); + const seed2 = await seedLegacyData(kv2); + const repo2 = new KvRepository(kv2); + await repo2.migrate("bot"); + assert.deepStrictEqual( + await repo2.getSentFollow("other", seed2.sentFollowId), + undefined, + ); + }); + + test("falls back to legacy keys for followees", async () => { + const kv = new MemoryKvStore(); + const seed = await seedLegacyData(kv); + const repo = new KvRepository(kv); + await repo.migrate("bot"); + + const follow = await repo.getFollowee("bot", seed.followeeId); + assert.deepStrictEqual( + await follow?.toJsonLd({ format: "compact" }), + seed.followeeFollowJson, + ); + // The record is moved to the scoped key and indexed for reverse lookup: + assert.deepStrictEqual( + await kv.get(["_botkit", "followees", seed.followeeId.href]), + undefined, + ); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(seed.followeeId)), + ["bot"], + ); + }); + + test("falls back to legacy keys for follow requests", async () => { + const kv = new MemoryKvStore(); + const seed = await seedLegacyData(kv); + const repo = new KvRepository(kv); + await repo.migrate("bot"); + + // removeFollower() consults the follow request record, which is keyed by + // URL and migrated lazily: + const removed = await repo.removeFollower( + "bot", + seed.followRequestId, + seed.followerId, + ); + assert.ok(removed != null); + assert.deepStrictEqual( + await repo.hasFollower("bot", seed.followerId), + false, + ); + assert.deepStrictEqual(await repo.countFollowers("bot"), 0); + }); + + test("does not fall back before migrate() is called", async () => { + const kv = new MemoryKvStore(); + const seed = await seedLegacyData(kv); + const repo = new KvRepository(kv); + assert.deepStrictEqual( + await repo.getSentFollow("bot", seed.sentFollowId), + undefined, + ); + }); +}); diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index e90deed..338051c 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -686,14 +686,157 @@ export class KvRepository implements Repository { this.prefix = options.prefix ?? ["_botkit"]; } + /** + * The identifier for which legacy (pre-0.5) unscoped keys are consulted as + * a fallback. Armed by {@link KvRepository.migrate}. + */ + #legacyFallbackIdentifier?: string; + #key(identifier: string, ...rest: readonly string[]): KvKey { return [...this.prefix, "bots", identifier, ...rest]; } + #legacyKey(...rest: readonly [string, ...string[]]): KvKey { + return [...this.prefix, ...rest]; + } + #followeeIndexKey(followeeId: URL): KvKey { return [...this.prefix, "index", "followees", followeeId.href]; } + /** + * Reads a record stored under the legacy (pre-0.5) unscoped key layout and + * moves it to the scoped location, if the given identifier has adopted the + * legacy data via {@link KvRepository.migrate}. + * @param identifier The identifier of the bot actor to move the record to. + * @param path The key path of the record, relative to both the legacy + * prefix and the scoped prefix. + * @returns The moved record, or `undefined` if it does not exist. + */ + async #moveLegacyRecord( + identifier: string, + path: readonly [string, ...string[]], + ): Promise { + if (identifier !== this.#legacyFallbackIdentifier) return undefined; + const legacyKey = this.#legacyKey(...path); + const value = await this.kv.get(legacyKey); + if (value == null) return undefined; + await this.kv.set(this.#key(identifier, ...path), value); + await this.kv.delete(legacyKey); + logger.debug( + "Lazily migrated the legacy record {path} to bot {identifier}.", + { path, identifier }, + ); + return value; + } + + /** + * Migrates data stored by BotKit 0.4 or earlier, which was not scoped by + * bot actor identifiers, so that it belongs to the given identifier. + * + * Categories that are enumerable (key pairs, messages, poll votes, and + * followers) are copied eagerly; the legacy keys are kept so that + * a partially failed run can be retried without data loss. Categories + * keyed by URLs or UUIDs without an index (sent follows, followees, and + * follow requests) are migrated lazily: after this method is called, + * a lookup that misses the scoped key falls back to the legacy key and + * moves the record over. + * + * Calling this method again after a successful migration is a no-op, + * except that it re-arms the lazy fallback for the given identifier. + * @param identifier The identifier of the bot actor that adopts the legacy + * data. + * @since 0.5.0 + */ + async migrate(identifier: string): Promise { + this.#legacyFallbackIdentifier = identifier; + const markerKey: KvKey = [...this.prefix, "migrated", identifier]; + if (await this.kv.get(markerKey) != null) return; + logger.info( + "Migrating legacy repository data to bot {identifier}...", + { identifier }, + ); + + // Key pairs: + const legacyKeyPairs = await this.kv.get(this.#legacyKey("keyPairs")); + if (legacyKeyPairs != null) { + const scopedKey = this.#key(identifier, "keyPairs"); + if (await this.kv.get(scopedKey) == null) { + await this.kv.set(scopedKey, legacyKeyPairs); + } + } + + // Messages and their poll votes: + const legacyMessageIds = + await this.kv.get(this.#legacyKey("messages")) ?? []; + for (const id of legacyMessageIds) { + const messageKey = this.#key(identifier, "messages", id); + if (await this.kv.get(messageKey) == null) { + const json = await this.kv.get(this.#legacyKey("messages", id)); + if (json != null) await this.kv.set(messageKey, json); + } + const options = await this.kv.get( + this.#legacyKey("polls", id), + ); + if (options != null) { + const scopedOptionsKey = this.#key(identifier, "polls", id); + if (await this.kv.get(scopedOptionsKey) == null) { + for (const option of options) { + const voters = await this.kv.get( + this.#legacyKey("polls", id, option), + ); + if (voters != null) { + await this.kv.set( + this.#key(identifier, "polls", id, option), + voters, + ); + } + } + await this.kv.set(scopedOptionsKey, options); + } + } + } + if (legacyMessageIds.length > 0) { + const listKey = this.#key(identifier, "messages"); + const set = new Set([ + ...(await this.kv.get(listKey) ?? []), + ...legacyMessageIds, + ]); + const list = [...set]; + list.sort((a, b) => a < b ? -1 : a > b ? 1 : 0); + await this.kv.set(listKey, list); + } + + // Followers: + const legacyFollowerIds = + await this.kv.get(this.#legacyKey("followers")) ?? []; + for (const followerId of legacyFollowerIds) { + const followerKey = this.#key(identifier, "followers", followerId); + if (await this.kv.get(followerKey) == null) { + const json = await this.kv.get( + this.#legacyKey("followers", followerId), + ); + if (json != null) await this.kv.set(followerKey, json); + } + } + if (legacyFollowerIds.length > 0) { + const listKey = this.#key(identifier, "followers"); + const merged = await this.kv.get(listKey) ?? []; + for (const followerId of legacyFollowerIds) { + if (!merged.includes(followerId)) merged.push(followerId); + } + await this.kv.set(listKey, merged); + } + + // The marker is written last so that an interrupted migration is + // retried on the next run: + await this.kv.set(markerKey, true); + logger.info( + "Finished migrating legacy repository data to bot {identifier}.", + { identifier }, + ); + } + async setKeyPairs( identifier: string, keyPairs: CryptoKeyPair[], @@ -899,7 +1042,11 @@ export class KvRepository implements Repository { "followRequests", followRequestId.href, ); - const followerId = await this.kv.get(followRequestKey); + const followerId = await this.kv.get(followRequestKey) ?? + await this.#moveLegacyRecord( + identifier, + ["followRequests", followRequestId.href], + ); if (followerId == null) return undefined; const followerKey = this.#key(identifier, "followers", followerId); if (followerId !== actorId.href) return undefined; @@ -986,7 +1133,9 @@ export class KvRepository implements Repository { identifier: string, id: Uuid, ): Promise { - const followJson = await this.kv.get(this.#key(identifier, "follows", id)); + const followJson = + await this.kv.get(this.#key(identifier, "follows", id)) ?? + await this.#moveLegacyRecord(identifier, ["follows", id]); if (followJson == null) return undefined; try { return await Follow.fromJsonLd(followJson); @@ -1028,10 +1177,22 @@ export class KvRepository implements Repository { identifier: string, followeeId: URL, ): Promise { - const json = await this.kv.get( + let json = await this.kv.get( this.#key(identifier, "followees", followeeId.href), ); - if (json == null) return undefined; + if (json == null) { + json = await this.#moveLegacyRecord( + identifier, + ["followees", followeeId.href], + ); + if (json == null) return undefined; + // The moved record also needs to appear in the reverse lookup index: + await this.#addToFolloweeIndex(identifier, followeeId); + } else if (identifier === this.#legacyFallbackIdentifier) { + // A previous lazy migration may have moved the record but failed + // before indexing it; re-assert the index so that retries repair it: + await this.#addToFolloweeIndex(identifier, followeeId); + } try { return await Follow.fromJsonLd(json); } catch { From a6d775dc458e770767ca0c24106231d5e1c8a867 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 19:02:23 +0900 Subject: [PATCH 03/36] Migrate legacy SQLite databases to bot_id schema Databases created by @fedify/botkit-sqlite 0.4 or earlier have no bot_id column, and SQLite cannot add a column to a composite primary key. Opening such a database now rebuilds the affected tables (create new, copy rows with an empty-string bot ID, drop, rename) in a single transaction before the usual schema initialization, with foreign key enforcement turned off during the rebuild since follow_requests references followers. The key_pairs table keeps its integer primary key, so a plain ALTER TABLE suffices there. SqliteRepository.migrate() then assigns the carried-over rows to the given bot actor identifier. To distinguish rows carried over from a legacy database from data legitimately stored under an empty-string identifier, the rebuild records a marker in a botkit_metadata table; migrate() acts only while the marker exists and removes it in the same transaction, which also makes repeated calls no-ops. The foreign key between followers and follow_requests is deferred to the commit while both tables move in tandem. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit-sqlite/src/mod.test.ts | 378 ++++++++++++++++++++++++- packages/botkit-sqlite/src/mod.ts | 227 +++++++++++++++ 2 files changed, 604 insertions(+), 1 deletion(-) diff --git a/packages/botkit-sqlite/src/mod.test.ts b/packages/botkit-sqlite/src/mod.test.ts index 3e8627c..8d5ca53 100644 --- a/packages/botkit-sqlite/src/mod.test.ts +++ b/packages/botkit-sqlite/src/mod.test.ts @@ -18,11 +18,12 @@ import { type SqliteRepositoryOptions, } from "@fedify/botkit-sqlite"; import { importJwk } from "@fedify/fedify/sig"; -import { Create, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; +import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; import assert from "node:assert"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { DatabaseSync } from "node:sqlite"; import { describe, test } from "node:test"; if (!("Temporal" in globalThis)) { @@ -282,3 +283,378 @@ describe("SqliteRepository", () => { } }); }); + +describe("SqliteRepository multitenancy", () => { + test("isolates data by bot identifier", async () => { + const repo = createSqliteRepository(); + try { + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0" as const; + const message = new Create({ + id: new URL( + `https://example.com/ap/actor/botA/create/${messageId}`, + ), + actor: new URL("https://example.com/ap/actor/botA"), + object: new Note({ + id: new URL(`https://example.com/ap/actor/botA/note/${messageId}`), + content: "Hello, world!", + }), + }); + await repo.addMessage("botA", messageId, message); + assert.deepStrictEqual( + await repo.getMessage("botB", messageId), + undefined, + ); + assert.deepStrictEqual(await repo.countMessages("botB"), 0); + assert.deepStrictEqual(await repo.countMessages("botA"), 1); + assert.deepStrictEqual( + await repo.removeMessage("botB", messageId), + undefined, + ); + assert.deepStrictEqual(await repo.countMessages("botA"), 1); + + const follower = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }); + const followId = new URL( + "https://example.com/ap/follow/be2da56a-0ea3-4a6a-9dff-2a1837be67e0", + ); + await repo.addFollower("botA", followId, follower); + assert.deepStrictEqual( + await repo.hasFollower("botB", follower.id!), + false, + ); + assert.ok(await repo.hasFollower("botA", follower.id!)); + assert.deepStrictEqual( + await repo.removeFollower("botB", followId, follower.id!), + undefined, + ); + assert.ok(await repo.hasFollower("botA", follower.id!)); + + await repo.setKeyPairs("botA", keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("botB"), undefined); + assert.deepStrictEqual(await repo.getKeyPairs("botA"), keyPairs); + + await repo.vote("botA", messageId, follower.id!, "option1"); + assert.deepStrictEqual(await repo.countVoters("botB", messageId), 0); + assert.deepStrictEqual(await repo.countVoters("botA", messageId), 1); + } finally { + repo.close(); + } + }); + + test("findFollowedBots()", async () => { + const repo = createSqliteRepository(); + try { + const followeeId = new URL("https://example.com/ap/actor/john"); + const followA = new Follow({ + id: new URL( + "https://example.com/ap/actor/botA/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/botA"), + object: followeeId, + }); + const followB = new Follow({ + id: new URL( + "https://example.com/ap/actor/botB/follow/e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c", + ), + actor: new URL("https://example.com/ap/actor/botB"), + object: followeeId, + }); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + [], + ); + await repo.addFollowee("botA", followeeId, followA); + await repo.addFollowee("botB", followeeId, followB); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + ["botA", "botB"], + ); + await repo.removeFollowee("botA", followeeId); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + ["botB"], + ); + } finally { + repo.close(); + } + }); +}); + +describe("SqliteRepository legacy schema migration", () => { + function createLegacyDatabase(path: string): void { + // The schema used by @fedify/botkit-sqlite 0.4 and earlier: + const db = new DatabaseSync(path); + db.exec("PRAGMA foreign_keys = ON;"); + db.exec(` + CREATE TABLE key_pairs ( + id INTEGER PRIMARY KEY, + private_key_jwk TEXT NOT NULL, + public_key_jwk TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE messages ( + id TEXT PRIMARY KEY, + activity_json TEXT NOT NULL, + published INTEGER + ) + `); + db.exec( + "CREATE INDEX idx_messages_published ON messages(published)", + ); + db.exec(` + CREATE TABLE followers ( + follower_id TEXT PRIMARY KEY, + actor_json TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE follow_requests ( + follow_request_id TEXT PRIMARY KEY, + follower_id TEXT NOT NULL, + FOREIGN KEY (follower_id) REFERENCES followers(follower_id) + ) + `); + db.exec(` + CREATE TABLE sent_follows ( + id TEXT PRIMARY KEY, + follow_json TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE followees ( + followee_id TEXT PRIMARY KEY, + follow_json TEXT NOT NULL + ) + `); + db.exec(` + CREATE TABLE poll_votes ( + message_id TEXT NOT NULL, + voter_id TEXT NOT NULL, + option TEXT NOT NULL, + PRIMARY KEY (message_id, voter_id, option) + ) + `); + db.close(); + } + + async function seedLegacyDatabase(path: string): Promise<{ + messageId: string; + followerId: string; + followRequestId: string; + followeeId: string; + sentFollowId: string; + }> { + const db = new DatabaseSync(path); + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = new Create({ + id: new URL(`https://example.com/ap/create/${messageId}`), + actor: new URL("https://example.com/ap/actor/bot"), + object: new Note({ + id: new URL(`https://example.com/ap/note/${messageId}`), + content: "Hello, world!", + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }), + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }); + db.prepare( + "INSERT INTO messages (id, activity_json, published) VALUES (?, ?, ?)", + ).run( + messageId, + JSON.stringify(await message.toJsonLd({ format: "compact" })), + Temporal.Instant.from("2025-01-01T00:00:00Z").epochMilliseconds, + ); + + const follower = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }); + const followRequestId = + "https://example.com/ap/follow/be2da56a-0ea3-4a6a-9dff-2a1837be67e0"; + db.prepare( + "INSERT INTO followers (follower_id, actor_json) VALUES (?, ?)", + ).run( + follower.id!.href, + JSON.stringify(await follower.toJsonLd({ format: "compact" })), + ); + db.prepare( + "INSERT INTO follow_requests (follow_request_id, follower_id) VALUES (?, ?)", + ).run(followRequestId, follower.id!.href); + + const followeeId = "https://example.com/ap/actor/jane"; + const followeeFollow = new Follow({ + id: new URL( + "https://example.com/ap/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/bot"), + object: new URL(followeeId), + }); + db.prepare( + "INSERT INTO followees (followee_id, follow_json) VALUES (?, ?)", + ).run( + followeeId, + JSON.stringify(await followeeFollow.toJsonLd({ format: "compact" })), + ); + + const sentFollowId = "e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c"; + const sentFollow = new Follow({ + id: new URL(`https://example.com/ap/follow/${sentFollowId}`), + actor: new URL("https://example.com/ap/actor/bot"), + object: new URL("https://example.com/ap/actor/joe"), + }); + db.prepare( + "INSERT INTO sent_follows (id, follow_json) VALUES (?, ?)", + ).run( + sentFollowId, + JSON.stringify(await sentFollow.toJsonLd({ format: "compact" })), + ); + + db.prepare( + "INSERT INTO poll_votes (message_id, voter_id, option) VALUES (?, ?, ?)", + ).run(messageId, "https://example.com/ap/actor/alice", "option1"); + + db.close(); + return { + messageId, + followerId: follower.id!.href, + followRequestId, + followeeId, + sentFollowId, + }; + } + + test("rebuilds a legacy database and adopts its data", async () => { + const dir = await mkdtemp(join(tmpdir(), "botkit-sqlite-test-")); + try { + const path = join(dir, "legacy.db"); + createLegacyDatabase(path); + const seed = await seedLegacyDatabase(path); + + // Opening a legacy database rebuilds the schema with bot_id columns; + // existing rows get the empty-string bot ID: + const repo = new SqliteRepository({ path }); + try { + assert.deepStrictEqual(await repo.countMessages("bot"), 0); + + // migrate() assigns the legacy rows to the given identifier: + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.countMessages("bot"), 1); + assert.ok( + await repo.getMessage( + "bot", + seed + .messageId as `${string}-${string}-${string}-${string}-${string}`, + ) != null, + ); + assert.ok(await repo.hasFollower("bot", new URL(seed.followerId))); + assert.ok( + await repo.getFollowee("bot", new URL(seed.followeeId)) != null, + ); + assert.ok( + await repo.getSentFollow( + "bot", + seed + .sentFollowId as `${string}-${string}-${string}-${string}-${string}`, + ) != null, + ); + assert.deepStrictEqual( + await repo.countVoters( + "bot", + seed + .messageId as `${string}-${string}-${string}-${string}-${string}`, + ), + 1, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findFollowedBots(new URL(seed.followeeId)), + ), + ["bot"], + ); + + // removeFollower() exercises the migrated follow_requests rows: + const removed = await repo.removeFollower( + "bot", + new URL(seed.followRequestId), + new URL(seed.followerId), + ); + assert.ok(removed != null); + + // migrate() is idempotent: + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.countMessages("bot"), 1); + } finally { + repo.close(); + } + + // Reopening the migrated database works without another rebuild: + const repo2 = new SqliteRepository({ path }); + try { + assert.deepStrictEqual(await repo2.countMessages("bot"), 1); + } finally { + repo2.close(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + test("does not touch fresh databases", async () => { + const repo = createSqliteRepository(); + try { + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.countMessages("bot"), 0); + } finally { + repo.close(); + } + }); +}); + +describe("SqliteRepository.migrate() with empty-string identifiers", () => { + test("does not reassign data stored under an empty identifier", async () => { + const repo = createSqliteRepository(); + try { + // A fresh 0.5 database has no legacy marker, so data stored under + // the empty-string identifier must never be adopted by migrate(): + await repo.setKeyPairs("", keyPairs); + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.getKeyPairs(""), keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot"), undefined); + } finally { + repo.close(); + } + }); + + test("adopts legacy rows only once", async () => { + const dir = await mkdtemp(join(tmpdir(), "botkit-sqlite-test-")); + try { + const path = join(dir, "legacy.db"); + const db = new DatabaseSync(path); + db.exec(` + CREATE TABLE key_pairs ( + id INTEGER PRIMARY KEY, + private_key_jwk TEXT NOT NULL, + public_key_jwk TEXT NOT NULL + ) + `); + db.close(); + + const repo = new SqliteRepository({ path }); + try { + await repo.migrate("bot"); + // After adoption, rows written under the empty-string identifier + // stay put even if migrate() is called again: + await repo.setKeyPairs("", keyPairs); + await repo.migrate("bot2"); + assert.deepStrictEqual(await repo.getKeyPairs(""), keyPairs); + assert.deepStrictEqual(await repo.getKeyPairs("bot2"), undefined); + } finally { + repo.close(); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index 33d4350..d73f3c8 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -90,7 +90,234 @@ export class SqliteRepository implements Repository, Disposable { this.db.close(); } + private tableExists(table: string): boolean { + const stmt = this.db.prepare(` + SELECT COUNT(*) AS count FROM sqlite_master + WHERE type = 'table' AND name = ? + `); + const row = stmt.get(table) as { count: number }; + return row.count > 0; + } + + private hasBotIdColumn(table: string): boolean { + const stmt = this.db.prepare(` + SELECT COUNT(*) AS count FROM pragma_table_info(?) + WHERE name = 'bot_id' + `); + const row = stmt.get(table) as { count: number }; + return row.count > 0; + } + + /** + * Rebuilds tables created by \@fedify/botkit-sqlite 0.4 or earlier, which + * had no `bot_id` column, into the bot-scoped schema. Existing rows get + * the empty-string bot ID; use {@link SqliteRepository.migrate} to assign + * them to a bot actor identifier. + */ + private rebuildLegacyTables(): void { + const tables = [ + "key_pairs", + "messages", + "followers", + "follow_requests", + "sent_follows", + "followees", + "poll_votes", + ].filter((table) => this.tableExists(table) && !this.hasBotIdColumn(table)); + if (tables.length < 1) return; + logger.info( + "Rebuilding legacy tables without a bot_id column: {tables}.", + { tables }, + ); + // The marker lets migrate() distinguish rows carried over from a legacy + // database (bot_id = '') from data legitimately stored under an + // empty-string identifier: + this.db.exec(` + CREATE TABLE IF NOT EXISTS botkit_metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `); + // SQLite cannot add a column to a composite primary key, so the tables + // are rebuilt (create new, copy, drop, rename) in a single transaction. + // Foreign key enforcement is turned off during the rebuild since + // follow_requests references followers. + this.db.exec("PRAGMA foreign_keys = OFF;"); + this.db.exec("BEGIN TRANSACTION"); + try { + if (tables.includes("key_pairs")) { + // The primary key does not change; adding the column suffices: + this.db.exec(` + ALTER TABLE key_pairs ADD COLUMN bot_id TEXT NOT NULL DEFAULT '' + `); + } + if (tables.includes("messages")) { + this.db.exec(` + CREATE TABLE messages_new ( + bot_id TEXT NOT NULL, + id TEXT NOT NULL, + activity_json TEXT NOT NULL, + published INTEGER, + PRIMARY KEY (bot_id, id) + ) + `); + this.db.exec(` + INSERT INTO messages_new (bot_id, id, activity_json, published) + SELECT '', id, activity_json, published FROM messages + `); + this.db.exec("DROP TABLE messages"); + this.db.exec("ALTER TABLE messages_new RENAME TO messages"); + } + if (tables.includes("followers")) { + this.db.exec(` + CREATE TABLE followers_new ( + bot_id TEXT NOT NULL, + follower_id TEXT NOT NULL, + actor_json TEXT NOT NULL, + PRIMARY KEY (bot_id, follower_id) + ) + `); + this.db.exec(` + INSERT INTO followers_new (bot_id, follower_id, actor_json) + SELECT '', follower_id, actor_json FROM followers + `); + this.db.exec("DROP TABLE followers"); + this.db.exec("ALTER TABLE followers_new RENAME TO followers"); + } + if (tables.includes("follow_requests")) { + this.db.exec(` + CREATE TABLE follow_requests_new ( + bot_id TEXT NOT NULL, + follow_request_id TEXT NOT NULL, + follower_id TEXT NOT NULL, + PRIMARY KEY (bot_id, follow_request_id), + FOREIGN KEY (bot_id, follower_id) + REFERENCES followers(bot_id, follower_id) + ) + `); + this.db.exec(` + INSERT INTO follow_requests_new + (bot_id, follow_request_id, follower_id) + SELECT '', follow_request_id, follower_id FROM follow_requests + `); + this.db.exec("DROP TABLE follow_requests"); + this.db.exec( + "ALTER TABLE follow_requests_new RENAME TO follow_requests", + ); + } + if (tables.includes("sent_follows")) { + this.db.exec(` + CREATE TABLE sent_follows_new ( + bot_id TEXT NOT NULL, + id TEXT NOT NULL, + follow_json TEXT NOT NULL, + PRIMARY KEY (bot_id, id) + ) + `); + this.db.exec(` + INSERT INTO sent_follows_new (bot_id, id, follow_json) + SELECT '', id, follow_json FROM sent_follows + `); + this.db.exec("DROP TABLE sent_follows"); + this.db.exec("ALTER TABLE sent_follows_new RENAME TO sent_follows"); + } + if (tables.includes("followees")) { + this.db.exec(` + CREATE TABLE followees_new ( + bot_id TEXT NOT NULL, + followee_id TEXT NOT NULL, + follow_json TEXT NOT NULL, + PRIMARY KEY (bot_id, followee_id) + ) + `); + this.db.exec(` + INSERT INTO followees_new (bot_id, followee_id, follow_json) + SELECT '', followee_id, follow_json FROM followees + `); + this.db.exec("DROP TABLE followees"); + this.db.exec("ALTER TABLE followees_new RENAME TO followees"); + } + if (tables.includes("poll_votes")) { + this.db.exec(` + CREATE TABLE poll_votes_new ( + bot_id TEXT NOT NULL, + message_id TEXT NOT NULL, + voter_id TEXT NOT NULL, + option TEXT NOT NULL, + PRIMARY KEY (bot_id, message_id, voter_id, option) + ) + `); + this.db.exec(` + INSERT INTO poll_votes_new (bot_id, message_id, voter_id, option) + SELECT '', message_id, voter_id, option FROM poll_votes + `); + this.db.exec("DROP TABLE poll_votes"); + this.db.exec("ALTER TABLE poll_votes_new RENAME TO poll_votes"); + } + this.db.exec(` + INSERT OR REPLACE INTO botkit_metadata (key, value) + VALUES ('legacy_data', '1') + `); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + this.db.exec("PRAGMA foreign_keys = ON;"); + throw error; + } + this.db.exec("PRAGMA foreign_keys = ON;"); + logger.info("Finished rebuilding legacy tables."); + } + + /** + * Migrates data stored by \@fedify/botkit-sqlite 0.4 or earlier, which was + * not scoped by bot actor identifiers, so that it belongs to the given + * identifier. Rows carried over from a legacy database have the + * empty-string bot ID; this method assigns them to the identifier in + * a single transaction. It only acts when the database was actually + * rebuilt from a legacy schema, so data legitimately stored under an + * empty-string identifier is never touched, and calling it again is + * a no-op. + * @param identifier The identifier of the bot actor that adopts the legacy + * data. + * @since 0.5.0 + */ + migrate(identifier: string): Promise { + if (!this.tableExists("botkit_metadata")) return Promise.resolve(); + const marker = this.db.prepare( + "SELECT value FROM botkit_metadata WHERE key = 'legacy_data'", + ).get() as { value: string } | undefined; + if (marker == null) return Promise.resolve(); + this.db.exec("BEGIN TRANSACTION"); + // Updating followers and follow_requests rows in tandem temporarily + // breaks the foreign key between them; defer the check to the commit: + this.db.exec("PRAGMA defer_foreign_keys = ON"); + try { + for ( + const table of [ + "key_pairs", + "messages", + "followers", + "follow_requests", + "sent_follows", + "followees", + "poll_votes", + ] + ) { + this.db.prepare(`UPDATE ${table} SET bot_id = ? WHERE bot_id = ''`) + .run(identifier); + } + this.db.exec("DELETE FROM botkit_metadata WHERE key = 'legacy_data'"); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + return Promise.resolve(); + } + private initializeTables(): void { + this.rebuildLegacyTables(); + // Key pairs table this.db.exec(` CREATE TABLE IF NOT EXISTS key_pairs ( From 45a6e6518f96f3b4303e82ead35009de76225615 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 19:07:55 +0900 Subject: [PATCH 04/36] Migrate legacy PostgreSQL schemas to bot_id layout Schemas created by @fedify/botkit-postgres 0.4 have no bot_id column. Schema initialization now detects such tables through the information schema and upgrades them in place: the bot_id column is added with an empty-string backfill, primary keys are recreated as composite keys, the old follow_requests foreign key is replaced with a composite one, and indexes whose shape changed are dropped so that the regular initialization recreates them. The whole upgrade is sent as a single parameter-less multi-statement query, which PostgreSQL executes over the simple query protocol in one implicit transaction on one connection, so the upgrade stays atomic even when the client is a connection pool. PostgresRepository.migrate() assigns the carried-over rows to the given bot actor identifier. Like the SQLite counterpart, the upgrade records a marker in a botkit_metadata table and migrate() acts only while the marker exists, so data legitimately stored under an empty-string identifier is never adopted and repeated calls are no-ops. The composite foreign key is declared deferrable, and migrate() defers it while followers and follow_requests move in tandem. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit-postgres/src/mod.test.ts | 340 +++++++++++++++++++++++ packages/botkit-postgres/src/mod.ts | 185 ++++++++++++ 2 files changed, 525 insertions(+) diff --git a/packages/botkit-postgres/src/mod.test.ts b/packages/botkit-postgres/src/mod.test.ts index 132d3af..b115ffd 100644 --- a/packages/botkit-postgres/src/mod.test.ts +++ b/packages/botkit-postgres/src/mod.test.ts @@ -122,6 +122,7 @@ if (postgresUrl == null) { assert.deepStrictEqual( tables.map((row) => row.table_name), [ + "botkit_metadata", "follow_requests", "followees", "followers", @@ -1029,3 +1030,342 @@ if (postgresUrl == null) { }); }); } + +if (postgresUrl != null) { + describe("PostgresRepository multitenancy", () => { + test("isolates data by bot identifier", async () => { + const harness = createHarness(); + try { + const repo = harness.repository; + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0" as const; + const message = new Create({ + id: new URL(`https://example.com/ap/actor/botA/create/${messageId}`), + actor: new URL("https://example.com/ap/actor/botA"), + object: new Note({ + id: new URL(`https://example.com/ap/actor/botA/note/${messageId}`), + content: "Hello, world!", + }), + }); + await repo.addMessage("botA", messageId, message); + assert.equal(await repo.getMessage("botB", messageId), undefined); + assert.equal(await repo.countMessages("botB"), 0); + assert.equal(await repo.countMessages("botA"), 1); + assert.equal(await repo.removeMessage("botB", messageId), undefined); + assert.equal(await repo.countMessages("botA"), 1); + + await repo.setKeyPairs("botA", keyPairs); + assert.equal(await repo.getKeyPairs("botB"), undefined); + assert.deepStrictEqual(await repo.getKeyPairs("botA"), keyPairs); + } finally { + await harness.cleanup(); + } + }); + + test("findFollowedBots()", async () => { + const harness = createHarness(); + try { + const repo = harness.repository; + const followeeId = new URL("https://example.com/ap/actor/john"); + const followA = new Follow({ + id: new URL( + "https://example.com/ap/actor/botA/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/botA"), + object: followeeId, + }); + const followB = new Follow({ + id: new URL( + "https://example.com/ap/actor/botB/follow/e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c", + ), + actor: new URL("https://example.com/ap/actor/botB"), + object: followeeId, + }); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + [], + ); + await repo.addFollowee("botA", followeeId, followA); + await repo.addFollowee("botB", followeeId, followB); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + ["botA", "botB"], + ); + await repo.removeFollowee("botA", followeeId); + assert.deepStrictEqual( + await Array.fromAsync(repo.findFollowedBots(followeeId)), + ["botB"], + ); + } finally { + await harness.cleanup(); + } + }); + }); + + describe("PostgresRepository legacy schema migration", () => { + async function createLegacySchema( + sql: postgres.Sql, + schema: string, + ): Promise { + // The schema used by @fedify/botkit-postgres 0.4: + await sql.unsafe(`CREATE SCHEMA "${schema}"`); + await sql.unsafe(` + CREATE TABLE "${schema}"."key_pairs" ( + position INTEGER PRIMARY KEY, + private_key_jwk JSONB NOT NULL, + public_key_jwk JSONB NOT NULL + ) + `); + await sql.unsafe(` + CREATE TABLE "${schema}"."messages" ( + id TEXT PRIMARY KEY, + activity_json JSONB NOT NULL, + published BIGINT + ) + `); + await sql.unsafe(` + CREATE INDEX "idx_messages_published" + ON "${schema}"."messages" (published, id) + `); + await sql.unsafe(` + CREATE TABLE "${schema}"."followers" ( + follower_id TEXT PRIMARY KEY, + actor_json JSONB NOT NULL + ) + `); + await sql.unsafe(` + CREATE TABLE "${schema}"."follow_requests" ( + follow_request_id TEXT PRIMARY KEY, + follower_id TEXT NOT NULL + REFERENCES "${schema}"."followers" (follower_id) + ON DELETE CASCADE + ) + `); + await sql.unsafe(` + CREATE INDEX "idx_follow_requests_follower" + ON "${schema}"."follow_requests" (follower_id) + `); + await sql.unsafe(` + CREATE TABLE "${schema}"."sent_follows" ( + id TEXT PRIMARY KEY, + follow_json JSONB NOT NULL + ) + `); + await sql.unsafe(` + CREATE TABLE "${schema}"."followees" ( + followee_id TEXT PRIMARY KEY, + follow_json JSONB NOT NULL + ) + `); + await sql.unsafe(` + CREATE TABLE "${schema}"."poll_votes" ( + message_id TEXT NOT NULL, + voter_id TEXT NOT NULL, + option TEXT NOT NULL, + PRIMARY KEY (message_id, voter_id, option) + ) + `); + await sql.unsafe(` + CREATE INDEX "idx_poll_votes_message_option" + ON "${schema}"."poll_votes" (message_id, option) + `); + } + + async function seedLegacySchema( + sql: postgres.Sql, + schema: string, + ): Promise<{ + messageId: string; + followerId: string; + followRequestId: string; + followeeId: string; + sentFollowId: string; + }> { + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + const message = new Create({ + id: new URL(`https://example.com/ap/create/${messageId}`), + actor: new URL("https://example.com/ap/actor/bot"), + object: new Note({ + id: new URL(`https://example.com/ap/note/${messageId}`), + content: "Hello, world!", + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }), + published: Temporal.Instant.from("2025-01-01T00:00:00Z"), + }); + await sql.unsafe( + `INSERT INTO "${schema}"."messages" (id, activity_json, published) + VALUES ($1, $2::jsonb, $3)`, + [ + messageId, + JSON.stringify(await message.toJsonLd({ format: "compact" })), + Temporal.Instant.from("2025-01-01T00:00:00Z").epochMilliseconds, + ], + ); + + const follower = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }); + const followRequestId = + "https://example.com/ap/follow/be2da56a-0ea3-4a6a-9dff-2a1837be67e0"; + await sql.unsafe( + `INSERT INTO "${schema}"."followers" (follower_id, actor_json) + VALUES ($1, $2::jsonb)`, + [ + follower.id!.href, + JSON.stringify(await follower.toJsonLd({ format: "compact" })), + ], + ); + await sql.unsafe( + `INSERT INTO "${schema}"."follow_requests" + (follow_request_id, follower_id) + VALUES ($1, $2)`, + [followRequestId, follower.id!.href], + ); + + const followeeId = "https://example.com/ap/actor/jane"; + const followeeFollow = new Follow({ + id: new URL( + "https://example.com/ap/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ), + actor: new URL("https://example.com/ap/actor/bot"), + object: new URL(followeeId), + }); + await sql.unsafe( + `INSERT INTO "${schema}"."followees" (followee_id, follow_json) + VALUES ($1, $2::jsonb)`, + [ + followeeId, + JSON.stringify( + await followeeFollow.toJsonLd({ format: "compact" }), + ), + ], + ); + + const sentFollowId = "e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c"; + const sentFollow = new Follow({ + id: new URL(`https://example.com/ap/follow/${sentFollowId}`), + actor: new URL("https://example.com/ap/actor/bot"), + object: new URL("https://example.com/ap/actor/joe"), + }); + await sql.unsafe( + `INSERT INTO "${schema}"."sent_follows" (id, follow_json) + VALUES ($1, $2::jsonb)`, + [ + sentFollowId, + JSON.stringify(await sentFollow.toJsonLd({ format: "compact" })), + ], + ); + + await sql.unsafe( + `INSERT INTO "${schema}"."poll_votes" (message_id, voter_id, option) + VALUES ($1, $2, $3)`, + [messageId, "https://example.com/ap/actor/alice", "option1"], + ); + + return { + messageId, + followerId: follower.id!.href, + followRequestId, + followeeId, + sentFollowId, + }; + } + + test("upgrades a legacy schema and adopts its data", async () => { + const sql = createSql(postgresUrl!); + const schema = createSchemaName(); + try { + await createLegacySchema(sql, schema); + const seed = await seedLegacySchema(sql, schema); + + const repo = new PostgresRepository({ + url: postgresUrl!, + schema, + maxConnections: 1, + }); + try { + assert.equal(await repo.countMessages("bot"), 0); + + await repo.migrate("bot"); + assert.equal(await repo.countMessages("bot"), 1); + assert.ok( + await repo.getMessage( + "bot", + seed + .messageId as `${string}-${string}-${string}-${string}-${string}`, + ) != null, + ); + assert.ok(await repo.hasFollower("bot", new URL(seed.followerId))); + assert.ok( + await repo.getFollowee("bot", new URL(seed.followeeId)) != null, + ); + assert.ok( + await repo.getSentFollow( + "bot", + seed + .sentFollowId as `${string}-${string}-${string}-${string}-${string}`, + ) != null, + ); + assert.equal( + await repo.countVoters( + "bot", + seed + .messageId as `${string}-${string}-${string}-${string}-${string}`, + ), + 1, + ); + assert.deepStrictEqual( + await Array.fromAsync( + repo.findFollowedBots(new URL(seed.followeeId)), + ), + ["bot"], + ); + + // removeFollower() exercises the upgraded follow_requests rows: + const removed = await repo.removeFollower( + "bot", + new URL(seed.followRequestId), + new URL(seed.followerId), + ); + assert.ok(removed != null); + + // migrate() is idempotent: + await repo.migrate("bot"); + assert.equal(await repo.countMessages("bot"), 1); + } finally { + await repo.close(); + } + + // Reopening the upgraded schema works without another upgrade: + const repo2 = new PostgresRepository({ + url: postgresUrl!, + schema, + maxConnections: 1, + }); + try { + assert.equal(await repo2.countMessages("bot"), 1); + } finally { + await repo2.close(); + } + } finally { + await sql.unsafe(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`); + await sql.end(); + } + }); + + test("does not reassign data stored under an empty identifier", async () => { + const harness = createHarness(); + try { + const repo = harness.repository; + // A fresh schema has no legacy marker, so data stored under the + // empty-string identifier must never be adopted by migrate(): + await repo.setKeyPairs("", keyPairs); + await repo.migrate("bot"); + assert.deepStrictEqual(await repo.getKeyPairs(""), keyPairs); + assert.equal(await repo.getKeyPairs("bot"), undefined); + } finally { + await harness.cleanup(); + } + }); + }); +} diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index 5e87382..8d7a45d 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -138,6 +138,16 @@ export async function initializePostgresRepositorySchema( [], prepare, ); + await upgradeLegacySchema(sql, validatedSchema, prepare); + await execute( + sql, + `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."botkit_metadata" ( + "key" TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + [], + prepare, + ); await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."key_pairs" ( @@ -190,6 +200,7 @@ export async function initializePostgresRepositorySchema( FOREIGN KEY (bot_id, follower_id) REFERENCES "${validatedSchema}"."followers" (bot_id, follower_id) ON DELETE CASCADE + DEFERRABLE INITIALLY IMMEDIATE )`, [], prepare, @@ -251,6 +262,136 @@ export async function initializePostgresRepositorySchema( ); } +const upgradableTables = [ + "key_pairs", + "messages", + "followers", + "follow_requests", + "sent_follows", + "followees", + "poll_votes", +] as const; + +/** + * Upgrades tables created by \@fedify/botkit-postgres 0.4, which had no + * `bot_id` column, into the bot-scoped schema. Existing rows get the + * empty-string bot ID; use {@link PostgresRepository.migrate} to assign them + * to a bot actor identifier. + * + * The whole upgrade is sent as a single multi-statement query without + * parameters, which PostgreSQL executes over the simple query protocol in + * one implicit transaction on one connection, so it is atomic even when + * `sql` is a connection pool. + */ +async function upgradeLegacySchema( + sql: Queryable, + schema: string, + prepare: boolean, +): Promise { + const rows = await execute<{ readonly table_name: string }>( + sql, + `SELECT t.table_name + FROM information_schema.tables t + WHERE t.table_schema = $1 + AND t.table_name = ANY($2) + AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns c + WHERE c.table_schema = t.table_schema + AND c.table_name = t.table_name + AND c.column_name = 'bot_id' + )`, + [schema, [...upgradableTables]], + prepare, + ); + if (rows.length < 1) return; + const tables = rows.map((row) => row.table_name); + logger.info( + "Upgrading legacy tables without a bot_id column: {tables}.", + { tables }, + ); + const statements: string[] = []; + if (tables.includes("follow_requests")) { + // The old foreign key referenced followers (follower_id) only; it has to + // go away before the followers primary key changes: + statements.push( + `ALTER TABLE "${schema}"."follow_requests" + DROP CONSTRAINT IF EXISTS "follow_requests_follower_id_fkey"`, + ); + } + for (const table of tables) { + statements.push( + `ALTER TABLE "${schema}"."${table}" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE "${schema}"."${table}" ALTER COLUMN bot_id DROP DEFAULT`, + `ALTER TABLE "${schema}"."${table}" + DROP CONSTRAINT IF EXISTS "${table}_pkey"`, + ); + } + if (tables.includes("key_pairs")) { + statements.push( + `ALTER TABLE "${schema}"."key_pairs" ADD PRIMARY KEY (bot_id, position)`, + ); + } + if (tables.includes("messages")) { + statements.push( + `ALTER TABLE "${schema}"."messages" ADD PRIMARY KEY (bot_id, id)`, + `DROP INDEX IF EXISTS "${schema}"."idx_messages_published"`, + ); + } + if (tables.includes("followers")) { + statements.push( + `ALTER TABLE "${schema}"."followers" + ADD PRIMARY KEY (bot_id, follower_id)`, + ); + } + if (tables.includes("follow_requests")) { + statements.push( + `ALTER TABLE "${schema}"."follow_requests" + ADD PRIMARY KEY (bot_id, follow_request_id)`, + `ALTER TABLE "${schema}"."follow_requests" + ADD FOREIGN KEY (bot_id, follower_id) + REFERENCES "${schema}"."followers" (bot_id, follower_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY IMMEDIATE`, + `DROP INDEX IF EXISTS "${schema}"."idx_follow_requests_follower"`, + ); + } + if (tables.includes("sent_follows")) { + statements.push( + `ALTER TABLE "${schema}"."sent_follows" ADD PRIMARY KEY (bot_id, id)`, + ); + } + if (tables.includes("followees")) { + statements.push( + `ALTER TABLE "${schema}"."followees" + ADD PRIMARY KEY (bot_id, followee_id)`, + ); + } + if (tables.includes("poll_votes")) { + statements.push( + `ALTER TABLE "${schema}"."poll_votes" + ADD PRIMARY KEY (bot_id, message_id, voter_id, option)`, + `DROP INDEX IF EXISTS "${schema}"."idx_poll_votes_message_option"`, + ); + } + // The marker lets migrate() distinguish rows carried over from a legacy + // schema (bot_id = '') from data legitimately stored under an + // empty-string identifier: + statements.push( + `CREATE TABLE IF NOT EXISTS "${schema}"."botkit_metadata" ( + "key" TEXT PRIMARY KEY, + value TEXT NOT NULL + )`, + `INSERT INTO "${schema}"."botkit_metadata" ("key", value) + VALUES ('legacy_data', '1') + ON CONFLICT ("key") DO NOTHING`, + ); + // Multi-statement queries cannot be prepared: + await execute(sql, statements.join(";\n"), [], false); + logger.info("Finished upgrading legacy tables."); +} + /** * A repository for storing bot data using PostgreSQL. * @since 0.4.0 @@ -820,6 +961,50 @@ export class PostgresRepository implements Repository, AsyncDisposable { return result; } + /** + * Migrates data stored by \@fedify/botkit-postgres 0.4, which was not + * scoped by bot actor identifiers, so that it belongs to the given + * identifier. Rows carried over from a legacy schema have the + * empty-string bot ID; this method assigns them to the identifier in + * a single transaction. It only acts when the schema was actually + * upgraded from a legacy layout, so data legitimately stored under an + * empty-string identifier is never touched, and calling it again is + * a no-op. + * @param identifier The identifier of the bot actor that adopts the legacy + * data. + * @since 0.5.0 + */ + async migrate(identifier: string): Promise { + await this.ensureReady(); + await this.sql.begin(async (sql) => { + const rows = await this.query<{ readonly value: string }>( + sql, + `SELECT value FROM ${this.table("botkit_metadata")} + WHERE "key" = 'legacy_data' + FOR UPDATE`, + ); + if (rows.length < 1) return; + // The followers and follow_requests rows move in tandem, which + // temporarily breaks the foreign key between them; defer the check to + // the commit: + await execute(sql, "SET CONSTRAINTS ALL DEFERRED", [], false); + for (const table of upgradableTables) { + await this.query( + sql, + `UPDATE "${this.schema}"."${table}" + SET bot_id = $1 + WHERE bot_id = ''`, + [identifier], + ); + } + await this.query( + sql, + `DELETE FROM ${this.table("botkit_metadata")} + WHERE "key" = 'legacy_data'`, + ); + }); + } + forIdentifier(identifier: string): ActorScopedRepository { return new ActorScopedRepository(this, identifier); } From 51bd6a94c9d2ae5efeefccb86178e2bf7a0e1bcc Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 19:20:14 +0900 Subject: [PATCH 05/36] Carry bot identifiers in local object URIs Local object URIs like /ap/follow/{id} did not encode which bot actor owns the object, so routing an object back to its owner required consulting storage. Once multiple bots share an instance, ownership becomes part of the protocol boundary, so the canonical templates now nest under the actor path: - /ap/actor/{identifier}/follow/{id} - /ap/actor/{identifier}/create/{id} - /ap/actor/{identifier}/article/{id} (likewise chat-message, note, and question) - /ap/actor/{identifier}/announce/{id} Emoji URIs stay instance-global since they carry no ownership semantics. Object dispatchers now verify that the identifier in the URI matches the dispatching bot, so one bot's URI path can never serve another bot's objects. Remote servers may have stored object URIs generated by BotKit 0.4 or earlier and can send them back in later activities such as Accept, Undo, Like, or replies, so a plain redirect would not be enough. The new parseLocalUri() helper falls back to rewriting legacy-format paths to the canonical format and re-parsing them, attributed to the single bot actor that predates the upgrade. All inbound parsing of local object URIs goes through this helper now, and each site also checks that the parsed identifier belongs to the handling bot before touching its repository. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/bot-impl.test.ts | 41 ++++--- packages/botkit/src/bot-impl.ts | 128 ++++++++++++++++----- packages/botkit/src/message-impl.ts | 55 +++++++-- packages/botkit/src/pages.tsx | 2 +- packages/botkit/src/session-impl.ts | 15 ++- packages/botkit/src/uri.test.ts | 165 +++++++++++++++++++++++++++ packages/botkit/src/uri.ts | 82 +++++++++++++ 7 files changed, 429 insertions(+), 59 deletions(-) create mode 100644 packages/botkit/src/uri.test.ts create mode 100644 packages/botkit/src/uri.ts diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 3391954..0488934 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -762,7 +762,10 @@ test("BotImpl.dispatchFollow()", async () => { undefined, ); assert.deepStrictEqual( - await bot.dispatchFollow(ctx, { id: crypto.randomUUID() }), + await bot.dispatchFollow(ctx, { + identifier: "bot", + id: crypto.randomUUID(), + }), null, ); @@ -779,6 +782,7 @@ test("BotImpl.dispatchFollow()", async () => { }), ); const follow = await bot.dispatchFollow(ctx, { + identifier: "bot", id: "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a", }); assert.ok(follow instanceof Follow); @@ -838,14 +842,14 @@ test("BotImpl.authorizeFollow()", async () => { assert.ok( await bot.authorizeFollow( ctx, - { id: "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a" }, + { identifier: "bot", id: "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a" }, ), ); setSignedKeyOwner(await new SessionImpl(bot, ctx).getActor()); assert.ok( await bot.authorizeFollow( ctx, - { id: "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a" }, + { identifier: "bot", id: "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a" }, ), ); setSignedKeyOwner( @@ -854,7 +858,7 @@ test("BotImpl.authorizeFollow()", async () => { assert.deepStrictEqual( await bot.authorizeFollow( ctx, - { id: "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a" }, + { identifier: "bot", id: "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a" }, ), false, ); @@ -864,7 +868,7 @@ test("BotImpl.authorizeFollow()", async () => { assert.deepStrictEqual( await bot.authorizeFollow( ctx, - { id: crypto.randomUUID() }, + { identifier: "bot", id: crypto.randomUUID() }, ), false, ); @@ -882,7 +886,7 @@ test("BotImpl.dispatchCreate()", async () => { undefined, ); assert.deepStrictEqual( - await bot.dispatchCreate(ctx, { id: "non-existent" }), + await bot.dispatchCreate(ctx, { identifier: "bot", id: "non-existent" }), null, ); @@ -906,6 +910,7 @@ test("BotImpl.dispatchCreate()", async () => { }), ); const create = await bot.dispatchCreate(ctx, { + identifier: "bot", id: "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", }); assert.ok(create instanceof Create); @@ -925,6 +930,7 @@ test("BotImpl.dispatchCreate()", async () => { ctx2.getSignedKeyOwner = () => Promise.resolve(actor); assert.deepStrictEqual( await bot.dispatchCreate(ctx2, { + identifier: "bot", id: "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", }), create, @@ -949,11 +955,13 @@ test("BotImpl.dispatchCreate()", async () => { ); assert.deepStrictEqual( await bot.dispatchCreate(ctx, { + identifier: "bot", id: "8386a4c7-06f8-409f-ad72-2bba43e83363", }), null, ); const create2 = await bot.dispatchCreate(ctx2, { + identifier: "bot", id: "8386a4c7-06f8-409f-ad72-2bba43e83363", }); assert.ok(create2 instanceof Create); @@ -979,6 +987,7 @@ test("BotImpl.dispatchCreate()", async () => { ); assert.deepStrictEqual( await bot.dispatchCreate(ctx, { + identifier: "bot", id: "ce8081ac-f238-484b-9a70-5d8a4b66d829", }), null, @@ -1128,7 +1137,7 @@ test("BotImpl.dispatchAnnounce()", async () => { undefined, ); assert.deepStrictEqual( - await bot.dispatchAnnounce(ctx, { id: "non-existent" }), + await bot.dispatchAnnounce(ctx, { identifier: "bot", id: "non-existent" }), null, ); @@ -1146,6 +1155,7 @@ test("BotImpl.dispatchAnnounce()", async () => { }), ); const announce = await bot.dispatchAnnounce(ctx, { + identifier: "bot", id: "ce8081ac-f238-484b-9a70-5d8a4b66d829", }); assert.ok(announce instanceof Announce); @@ -1177,6 +1187,7 @@ test("BotImpl.dispatchAnnounce()", async () => { ); assert.deepStrictEqual( await bot.dispatchAnnounce(ctx, { + identifier: "bot", id: "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", }), null, @@ -1196,6 +1207,7 @@ test("BotImpl.dispatchAnnounce()", async () => { ); assert.deepStrictEqual( await bot.dispatchAnnounce(ctx, { + identifier: "bot", id: "d4a7ef9b-682c-4de9-b23c-87747d6725cb", }), null, @@ -1209,6 +1221,7 @@ test("BotImpl.dispatchAnnounce()", async () => { }); ctx2.getSignedKeyOwner = () => Promise.resolve(actor); const announce2 = await bot.dispatchAnnounce(ctx2, { + identifier: "bot", id: "d4a7ef9b-682c-4de9-b23c-87747d6725cb", }); assert.ok(announce2 instanceof Announce); @@ -2053,7 +2066,7 @@ test("BotImpl.onAnnounced()", async () => { to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), object: new Note({ - id: new URL("https://example.com/ap/actor/bot/note/1"), + id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/ap/actor/bot"), to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), @@ -2086,7 +2099,7 @@ test("BotImpl.onLiked()", async () => { id: new URL("https://example.com/ap/actor/bot/like/1"), actor: new URL("https://example.com/ap/actor/bot"), object: new Note({ - id: new URL("https://example.com/ap/actor/bot/note/1"), + id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/ap/actor/bot"), to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), @@ -2118,7 +2131,7 @@ test("BotImpl.onUnliked()", async () => { id: new URL("https://example.com/ap/actor/bot/like/1"), actor: new URL("https://example.com/ap/actor/bot"), object: new Note({ - id: new URL("https://example.com/ap/actor/bot/note/1"), + id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/ap/actor/bot"), to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), @@ -2166,7 +2179,7 @@ test("BotImpl.onReacted()", async () => { actor: new URL("https://example.com/ap/actor/bot"), name: ":heart:", object: new Note({ - id: new URL("https://example.com/ap/actor/bot/note/1"), + id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/ap/actor/bot"), to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), @@ -2205,7 +2218,7 @@ test("BotImpl.onReacted()", async () => { actor: new URL("https://example.com/ap/actor/bot"), name: ":thumbsup:", object: new Note({ - id: new URL("https://example.com/ap/actor/bot/note/1"), + id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/ap/actor/bot"), to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), @@ -2254,7 +2267,7 @@ test("BotImpl.onUnreacted()", async () => { actor: new URL("https://example.com/ap/actor/bot"), name: ":heart:", object: new Note({ - id: new URL("https://example.com/ap/actor/bot/note/1"), + id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/ap/actor/bot"), to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), @@ -2299,7 +2312,7 @@ test("BotImpl.onUnreacted()", async () => { actor: new URL("https://example.com/ap/actor/bot"), name: ":thumbsup:", object: new Note({ - id: new URL("https://example.com/ap/actor/bot/note/1"), + id: new URL("https://example.com/notes/1"), attribution: new URL("https://example.com/ap/actor/bot"), to: PUBLIC_COLLECTION, cc: new URL("https://example.com/ap/actor/bot/followers"), diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 4359313..fc38cbb 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -100,6 +100,7 @@ import { KvRepository, type Uuid, } from "./repository.ts"; +import { parseLocalUri } from "./uri.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; import type { Text } from "./text.ts"; @@ -129,6 +130,14 @@ export class BotImpl implements Bot { readonly collectionWindow: number; readonly federation: Federation; + /** + * The identifier of the bot actor that owns local objects whose URIs are + * in the legacy (pre-0.5) format, which did not carry the identifier. + * Legacy URIs can only occur in deployments that hosted a single bot + * before the upgrade, so they are attributed to that bot. + */ + readonly legacyObjectUrisIdentifier?: string; + onFollow?: FollowEventHandler; onUnfollow?: UnfollowEventHandler; onAcceptFollow?: AcceptEventHandler; @@ -174,6 +183,7 @@ export class BotImpl implements Bot { }); this.behindProxy = options.behindProxy ?? false; this.collectionWindow = options.collectionWindow ?? 50; + this.legacyObjectUrisIdentifier = this.identifier; this.initialize(); } @@ -202,38 +212,50 @@ export class BotImpl implements Bot { this.federation .setObjectDispatcher( Follow, - "/ap/follow/{id}", + "/ap/actor/{identifier}/follow/{id}", this.dispatchFollow.bind(this), ) .authorize(this.authorizeFollow.bind(this)); this.federation.setObjectDispatcher( Create, - "/ap/create/{id}", + "/ap/actor/{identifier}/create/{id}", this.dispatchCreate.bind(this), ); this.federation.setObjectDispatcher( Article, - "/ap/article/{id}", - (ctx, values) => this.dispatchMessage(Article, ctx, values.id), + "/ap/actor/{identifier}/article/{id}", + (ctx, values) => + values.identifier === this.identifier + ? this.dispatchMessage(Article, ctx, values.id) + : null, ); this.federation.setObjectDispatcher( ChatMessage, - "/ap/chat-message/{id}", - (ctx, values) => this.dispatchMessage(ChatMessage, ctx, values.id), + "/ap/actor/{identifier}/chat-message/{id}", + (ctx, values) => + values.identifier === this.identifier + ? this.dispatchMessage(ChatMessage, ctx, values.id) + : null, ); this.federation.setObjectDispatcher( Note, - "/ap/note/{id}", - (ctx, values) => this.dispatchMessage(Note, ctx, values.id), + "/ap/actor/{identifier}/note/{id}", + (ctx, values) => + values.identifier === this.identifier + ? this.dispatchMessage(Note, ctx, values.id) + : null, ); this.federation.setObjectDispatcher( Question, - "/ap/question/{id}", - (ctx, values) => this.dispatchMessage(Question, ctx, values.id), + "/ap/actor/{identifier}/question/{id}", + (ctx, values) => + values.identifier === this.identifier + ? this.dispatchMessage(Question, ctx, values.id) + : null, ); this.federation.setObjectDispatcher( Announce, - "/ap/announce/{id}", + "/ap/actor/{identifier}/announce/{id}", this.dispatchAnnounce.bind(this), ); this.federation.setObjectDispatcher( @@ -489,8 +511,9 @@ export class BotImpl implements Bot { async dispatchFollow( _ctx: RequestContext, - values: { id: string }, + values: { identifier: string; id: string }, ): Promise { + if (values.identifier !== this.identifier) return null; const id = values.id as Uuid; const follow = await this.repository.getSentFollow(id); return follow ?? null; @@ -498,8 +521,9 @@ export class BotImpl implements Bot { async authorizeFollow( ctx: RequestContext, - values: { id: string }, + values: { identifier: string; id: string }, ): Promise { + if (values.identifier !== this.identifier) return false; const signedKeyOwner = await ctx.getSignedKeyOwner(); if (signedKeyOwner == null || signedKeyOwner.id == null) return false; const id = values.id as Uuid; @@ -511,8 +535,9 @@ export class BotImpl implements Bot { async dispatchCreate( ctx: RequestContext, - values: { id: string }, + values: { identifier: string; id: string }, ): Promise { + if (values.identifier !== this.identifier) return null; const activity = await this.repository.getMessage(values.id as Uuid); if (!(activity instanceof Create)) return null; const isVisible = await this.getPermissionChecker(ctx); @@ -539,8 +564,9 @@ export class BotImpl implements Bot { async dispatchAnnounce( ctx: RequestContext, - values: { id: string }, + values: { identifier: string; id: string }, ): Promise { + if (values.identifier !== this.identifier) return null; const activity = await this.repository.getMessage(values.id as Uuid); if (!(activity instanceof Announce)) return null; const isVisible = await this.getPermissionChecker(ctx); @@ -625,8 +651,17 @@ export class BotImpl implements Bot { ctx: InboxContext, accept: Accept, ): Promise { - const parsedObj = ctx.parseUri(accept.objectId); - if (parsedObj?.type !== "object" || parsedObj.class !== Follow) return; + const parsedObj = parseLocalUri( + ctx, + accept.objectId, + this.legacyObjectUrisIdentifier, + ); + if ( + parsedObj?.type !== "object" || parsedObj.class !== Follow || + parsedObj.values.identifier !== this.identifier + ) { + return; + } const follow = await this.repository.getSentFollow( parsedObj.values.id as Uuid, ); @@ -649,8 +684,17 @@ export class BotImpl implements Bot { ctx: InboxContext, reject: Reject, ): Promise { - const parsedObj = ctx.parseUri(reject.objectId); - if (parsedObj?.type !== "object" || parsedObj.class !== Follow) return; + const parsedObj = parseLocalUri( + ctx, + reject.objectId, + this.legacyObjectUrisIdentifier, + ); + if ( + parsedObj?.type !== "object" || parsedObj.class !== Follow || + parsedObj.values.identifier !== this.identifier + ) { + return; + } const id = parsedObj.values.id as Uuid; const follow = await this.repository.getSentFollow(id); if (follow == null) return; @@ -686,12 +730,17 @@ export class BotImpl implements Bot { if (messageCache != null) return messageCache; return messageCache = await createMessage(object, session, {}); }; - const replyTarget = ctx.parseUri(object.replyTargetId); + const replyTarget = parseLocalUri( + ctx, + object.replyTargetId, + this.legacyObjectUrisIdentifier, + ); if ( this.onVote != null && object instanceof Note && replyTarget?.type === "object" && // @ts-ignore: replyTarget.class satisfies (typeof messageClasses)[number] messageClasses.includes(replyTarget.class) && + replyTarget.values.identifier === this.identifier && object.name != null ) { if ( @@ -815,7 +864,8 @@ export class BotImpl implements Bot { this.onReply != null && replyTarget?.type === "object" && // @ts-ignore: replyTarget.class satisfies (typeof messageClasses)[number] - messageClasses.includes(replyTarget.class) + messageClasses.includes(replyTarget.class) && + replyTarget.values.identifier === this.identifier ) { const message = await getMessage(); if ( @@ -838,12 +888,17 @@ export class BotImpl implements Bot { } } if (quoteUrl == null) quoteUrl = object.quoteUrl; - const quoteTarget = ctx.parseUri(quoteUrl); + const quoteTarget = parseLocalUri( + ctx, + quoteUrl, + this.legacyObjectUrisIdentifier, + ); if ( this.onQuote != null && quoteTarget?.type === "object" && // @ts-ignore: quoteTarget.class satisfies (typeof messageClasses)[number] - messageClasses.includes(quoteTarget.class) + messageClasses.includes(quoteTarget.class) && + quoteTarget.values.identifier === this.identifier ) { const message = await getMessage(); if ( @@ -883,12 +938,17 @@ export class BotImpl implements Bot { this.onSharedMessage == null || announce.id == null || announce.actorId == null ) return; - const objectUri = ctx.parseUri(announce.objectId); + const objectUri = parseLocalUri( + ctx, + announce.objectId, + this.legacyObjectUrisIdentifier, + ); let object: Object | null = null; if ( objectUri?.type === "object" && // deno-lint-ignore no-explicit-any - messageClasses.includes(objectUri.class as any) + messageClasses.includes(objectUri.class as any) && + objectUri.values.identifier === this.identifier ) { const msg = await this.repository.getMessage(objectUri.values.id as Uuid); if (msg instanceof Create) object = await msg.getObject(ctx); @@ -919,12 +979,17 @@ export class BotImpl implements Bot { { session: Session; like: Like } | undefined > { if (like.id == null || like.actorId == null) return undefined; - const objectUri = ctx.parseUri(like.objectId); + const objectUri = parseLocalUri( + ctx, + like.objectId, + this.legacyObjectUrisIdentifier, + ); let object: Object | null = null; if ( objectUri?.type === "object" && // deno-lint-ignore no-explicit-any - messageClasses.includes(objectUri.class as any) + messageClasses.includes(objectUri.class as any) && + objectUri.values.identifier === this.identifier ) { const msg = await this.repository.getMessage(objectUri.values.id as Uuid); if (msg instanceof Create) object = await msg.getObject(ctx); @@ -995,12 +1060,17 @@ export class BotImpl implements Bot { } } if (emoji == null) return undefined; - const objectUri = ctx.parseUri(react.objectId); + const objectUri = parseLocalUri( + ctx, + react.objectId, + this.legacyObjectUrisIdentifier, + ); let object: Object | null = null; if ( objectUri?.type === "object" && // deno-lint-ignore no-explicit-any - messageClasses.includes(objectUri.class as any) + messageClasses.includes(objectUri.class as any) && + objectUri.values.identifier === this.identifier ) { const msg = await this.repository.getMessage(objectUri.values.id as Uuid); if (msg instanceof Create) object = await msg.getObject(ctx); diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 3eb45cc..5afb4a0 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -39,6 +39,7 @@ import { } from "@fedify/vocab"; import { decode } from "html-entities"; import { v7 as uuidv7 } from "uuid"; +import { parseLocalUri } from "./uri.ts"; import xss from "xss"; import type { DeferredCustomEmoji, Emoji } from "./emoji.ts"; import type { @@ -148,7 +149,10 @@ export class MessageImpl const id = uuidv7({ msecs: +published }) as Uuid; const visibility = options.visibility ?? this.visibility; const originalActor = this.actor.id == null ? [] : [this.actor.id]; - const uri = this.session.context.getObjectUri(Announce, { id }); + const uri = this.session.context.getObjectUri(Announce, { + identifier: this.session.bot.identifier, + id, + }); const announce = new Announce({ id: uri, actor: this.session.context.getActorUri(this.session.bot.identifier), @@ -373,10 +377,15 @@ export class AuthorizedMessageImpl extends MessageImpl implements AuthorizedMessage { async update(text: Text<"block", TContextData>): Promise { - const parsed = this.session.context.parseUri(this.id); + const parsed = parseLocalUri( + this.session.context, + this.id, + this.session.bot.legacyObjectUrisIdentifier, + ); if ( parsed?.type !== "object" || - !messageClasses.some((cls) => parsed.class === cls) + !messageClasses.some((cls) => parsed.class === cls) || + parsed.values.identifier !== this.session.bot.identifier ) { return; } @@ -464,7 +473,10 @@ export class AuthorizedMessageImpl update = new Update({ id: new URL( `#updated/${updated.toString()}`, - this.session.context.getObjectUri(Create, { id }), + this.session.context.getObjectUri(Create, { + identifier: this.session.bot.identifier, + id, + }), ), actors: newMessage.attributionIds, tos: to.map((url) => new URL(url)), @@ -512,10 +524,15 @@ export class AuthorizedMessageImpl } async delete(): Promise { - const parsed = this.session.context.parseUri(this.id); + const parsed = parseLocalUri( + this.session.context, + this.id, + this.session.bot.legacyObjectUrisIdentifier, + ); if ( parsed?.type !== "object" || - !messageClasses.some((cls) => parsed.class === cls) + !messageClasses.some((cls) => parsed.class === cls) || + parsed.values.identifier !== this.session.bot.identifier ) { return; } @@ -664,9 +681,16 @@ export async function createMessage( } if (replyTarget == null) { let rt: Link | Object | null; - const parsed = session.context.parseUri(raw.replyTargetId); - // @ts-ignore: The `class` property satisfies the `MessageClass` type. - if (parsed?.type === "object" && messageClasses.includes(parsed.class)) { + const parsed = parseLocalUri( + session.context, + raw.replyTargetId, + session.bot.legacyObjectUrisIdentifier, + ); + if ( + // @ts-ignore: The `class` property satisfies the `MessageClass` type. + parsed?.type === "object" && messageClasses.includes(parsed.class) && + parsed.values.identifier === session.bot.identifier + ) { // @ts-ignore: The `class` property satisfies the `MessageClass` type. // deno-lint-ignore no-explicit-any const cls: new (values: any) => T = parsed.class; @@ -694,9 +718,16 @@ export async function createMessage( } if (quoteUrl == null) quoteUrl = raw.quoteUrl; let qt: Object | null = null; - const parsed = session.context.parseUri(quoteUrl); - // @ts-ignore: The `class` property satisfies the `MessageClass` type. - if (parsed?.type === "object" && messageClasses.includes(parsed.class)) { + const parsed = parseLocalUri( + session.context, + quoteUrl, + session.bot.legacyObjectUrisIdentifier, + ); + if ( + // @ts-ignore: The `class` property satisfies the `MessageClass` type. + parsed?.type === "object" && messageClasses.includes(parsed.class) && + parsed.values.identifier === session.bot.identifier + ) { // @ts-ignore: The `class` property satisfies the `MessageClass` type. // deno-lint-ignore no-explicit-any const cls: new (values: any) => T = parsed.class; diff --git a/packages/botkit/src/pages.tsx b/packages/botkit/src/pages.tsx index 1031db4..538bd6a 100644 --- a/packages/botkit/src/pages.tsx +++ b/packages/botkit/src/pages.tsx @@ -304,7 +304,7 @@ app.get("/message/:id", async (c) => { if (message == null || !isMessageObject(message)) return c.notFound(); const activityLink = ctx.getObjectUri( getMessageClass(message), - { id }, + { identifier: bot.identifier, id }, ); const feedLink = new URL("/feed.xml", url); let title = message.name; diff --git a/packages/botkit/src/session-impl.ts b/packages/botkit/src/session-impl.ts index c5eb52e..e927b4c 100644 --- a/packages/botkit/src/session-impl.ts +++ b/packages/botkit/src/session-impl.ts @@ -123,7 +123,10 @@ export class SessionImpl implements Session { } const id = uuidv7() as Uuid; const follow = new Follow({ - id: this.context.getObjectUri(Follow, { id }), + id: this.context.getObjectUri(Follow, { + identifier: this.bot.identifier, + id, + }), actor: this.context.getActorUri(this.bot.identifier), object: actor.id, to: actor.id, @@ -309,7 +312,10 @@ export class SessionImpl implements Session { endTime = options.poll.endTime; } const msg = new cls({ - id: this.context.getObjectUri(cls, { id }), + id: this.context.getObjectUri(cls, { + identifier: this.bot.identifier, + id, + }), contents: options.language == null ? [contentHtml] : [new LanguageString(contentHtml, options.language), contentHtml], @@ -339,7 +345,10 @@ export class SessionImpl implements Session { url: new URL(`/message/${id}`, this.context.origin), }); const activity = new Create({ - id: this.context.getObjectUri(Create, { id }), + id: this.context.getObjectUri(Create, { + identifier: this.bot.identifier, + id, + }), actors: msg.attributionIds, tos: msg.toIds, ccs: msg.ccIds, diff --git a/packages/botkit/src/uri.test.ts b/packages/botkit/src/uri.test.ts new file mode 100644 index 0000000..04a71ab --- /dev/null +++ b/packages/botkit/src/uri.test.ts @@ -0,0 +1,165 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { MemoryKvStore } from "@fedify/fedify/federation"; +import { + Announce, + Article, + ChatMessage, + Create, + Follow, + Note, + Question, +} from "@fedify/vocab"; +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { BotImpl } from "./bot-impl.ts"; +import { parseLocalUri, rewriteLegacyObjectPath } from "./uri.ts"; + +describe("rewriteLegacyObjectPath()", () => { + test("rewrites legacy object paths", () => { + assert.deepStrictEqual( + rewriteLegacyObjectPath( + "/ap/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + "bot", + ), + "/ap/actor/bot/follow/03a395a2-353a-4894-afdb-2cab31a7b004", + ); + for ( + const type of [ + "follow", + "create", + "article", + "chat-message", + "note", + "question", + "announce", + ] + ) { + assert.deepStrictEqual( + rewriteLegacyObjectPath(`/ap/${type}/123`, "my-bot"), + `/ap/actor/my-bot/${type}/123`, + ); + } + }); + + test("percent-encodes the identifier", () => { + assert.deepStrictEqual( + rewriteLegacyObjectPath("/ap/note/123", "b/t"), + "/ap/actor/b%2Ft/note/123", + ); + }); + + test("returns null for non-legacy paths", () => { + assert.deepStrictEqual( + rewriteLegacyObjectPath("/ap/actor/bot", "bot"), + null, + ); + assert.deepStrictEqual( + rewriteLegacyObjectPath("/ap/actor/bot/note/123", "bot"), + null, + ); + assert.deepStrictEqual( + rewriteLegacyObjectPath("/ap/emoji/wave", "bot"), + null, + ); + assert.deepStrictEqual(rewriteLegacyObjectPath("/ap/inbox", "bot"), null); + assert.deepStrictEqual( + rewriteLegacyObjectPath("/message/123", "bot"), + null, + ); + assert.deepStrictEqual( + rewriteLegacyObjectPath("/ap/note/123/extra", "bot"), + null, + ); + }); +}); + +describe("parseLocalUri()", () => { + const bot = new BotImpl({ + kv: new MemoryKvStore(), + username: "bot", + }); + const ctx = bot.federation.createContext(new URL("https://example.com/")); + + test("behaves like Context.parseUri() for canonical URIs", () => { + const actorUri = new URL("https://example.com/ap/actor/bot"); + assert.deepStrictEqual( + parseLocalUri(ctx, actorUri, "bot"), + ctx.parseUri(actorUri), + ); + const noteUri = new URL( + "https://example.com/ap/actor/bot/note/01941f29-7c00-7fe8-ab0a-7b593990a3c0", + ); + const parsed = parseLocalUri(ctx, noteUri, "bot"); + assert.ok(parsed != null); + assert.deepStrictEqual(parsed.type, "object"); + assert.ok(parsed.type === "object"); + assert.deepStrictEqual(parsed.class, Note); + assert.deepStrictEqual(parsed.values, { + identifier: "bot", + id: "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + }); + }); + + test("recognizes legacy object URIs when a legacy identifier is given", () => { + const classes = { + follow: Follow, + create: Create, + article: Article, + "chat-message": ChatMessage, + note: Note, + question: Question, + announce: Announce, + } as const; + for (const [type, cls] of Object.entries(classes)) { + const legacyUri = new URL( + `https://example.com/ap/${type}/01941f29-7c00-7fe8-ab0a-7b593990a3c0`, + ); + const parsed = parseLocalUri(ctx, legacyUri, "bot"); + assert.ok(parsed != null, `legacy ${type} URI should parse`); + assert.deepStrictEqual(parsed.type, "object"); + assert.ok(parsed.type === "object"); + assert.deepStrictEqual(parsed.class, cls); + assert.deepStrictEqual(parsed.values, { + identifier: "bot", + id: "01941f29-7c00-7fe8-ab0a-7b593990a3c0", + }); + } + }); + + test("does not recognize legacy URIs without a legacy identifier", () => { + const legacyUri = new URL( + "https://example.com/ap/note/01941f29-7c00-7fe8-ab0a-7b593990a3c0", + ); + assert.deepStrictEqual(parseLocalUri(ctx, legacyUri), null); + }); + + test("returns null for null and foreign URIs", () => { + assert.deepStrictEqual(parseLocalUri(ctx, null, "bot"), null); + assert.deepStrictEqual( + parseLocalUri( + ctx, + new URL("https://other.example/ap/note/123"), + "bot", + ), + null, + ); + assert.deepStrictEqual( + parseLocalUri(ctx, new URL("https://example.com/unrelated"), "bot"), + null, + ); + }); +}); diff --git a/packages/botkit/src/uri.ts b/packages/botkit/src/uri.ts new file mode 100644 index 0000000..54c7f8b --- /dev/null +++ b/packages/botkit/src/uri.ts @@ -0,0 +1,82 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import type { Context } from "@fedify/fedify/federation"; + +/** + * The path pattern of local object URIs generated by BotKit 0.4 and earlier, + * which did not carry the owning bot actor identifier. + */ +const legacyObjectPathPattern = + /^\/ap\/(follow|create|article|chat-message|note|question|announce)\/([^/]+)$/; + +/** + * Rewrites a local object URI path in the legacy (pre-0.5) format, which did + * not carry the owning bot actor identifier, into the canonical format nested + * under the actor path. + * + * For example, `/ap/follow/123` becomes `/ap/actor/bot/follow/123` for the + * identifier `bot`. + * @param pathname The URL path to rewrite. + * @param identifier The identifier of the bot actor that owns the legacy + * objects. + * @returns The rewritten path, or `null` if the path is not in the legacy + * object URI format. + * @since 0.5.0 + */ +export function rewriteLegacyObjectPath( + pathname: string, + identifier: string, +): string | null { + const match = legacyObjectPathPattern.exec(pathname); + if (match == null) return null; + return `/ap/actor/${encodeURIComponent(identifier)}/${match[1]}/${match[2]}`; +} + +/** + * Parses a URI as a local ActivityPub resource, like + * {@link Context.parseUri}, but additionally recognizes local object URIs in + * the legacy (pre-0.5) format when a legacy bot actor identifier is + * configured. + * + * Remote servers may have stored object URIs generated by BotKit 0.4 or + * earlier and can send them back in later activities (e.g. `Accept`, + * `Undo`, `Like`, or replies), so failing to parse them would break + * long-lived interactions after an upgrade. Since legacy URIs carry no + * identifier, they can only be attributed to the single bot actor that + * predates the upgrade. + * @param ctx The Fedify context to parse the URI with. + * @param uri The URI to parse. + * @param legacyIdentifier The identifier of the bot actor that owns objects + * with legacy URIs, or `undefined` if the instance + * has no legacy objects. + * @returns The parse result, or `null` if the URI is not a local resource. + * @since 0.5.0 + */ +export function parseLocalUri( + ctx: Context, + uri: URL | null, + legacyIdentifier?: string, +): ReturnType["parseUri"]> { + if (uri == null) return null; + const parsed = ctx.parseUri(uri); + if (parsed != null) return parsed; + if (legacyIdentifier == null) return null; + const rewrittenPath = rewriteLegacyObjectPath(uri.pathname, legacyIdentifier); + if (rewrittenPath == null) return null; + const rewritten = new URL(uri.href); + rewritten.pathname = rewrittenPath; + return ctx.parseUri(rewritten); +} From 0da77e22366d15e2056e8502910cdc970cfa74ed Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 19:25:29 +0900 Subject: [PATCH 06/36] Redirect legacy object URIs to canonical ones Dereferenceable GET and HEAD requests to local object URIs in the legacy (pre-0.5) format are now permanently redirected to their canonical identifier-carrying URIs, so links stored by remote servers and clients keep working after an upgrade. Inbox deliveries and other non-dereferenceable requests are not affected; incoming activities referring to legacy URIs are already recognized by parseLocalUri(). Also adds regression tests covering an Accept activity carrying a canonical-format Follow URI and a Like activity referring to a stored message by its legacy URI. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/bot-impl.test.ts | 157 +++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 21 +++- 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 0488934..307490a 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3086,3 +3086,160 @@ test("BotImpl.onVote()", async (t) => { }); // cSpell: ignore thumbsup + +test("BotImpl.fetch() redirects legacy object URIs", async (t) => { + const bot = new BotImpl({ + kv: new MemoryKvStore(), + username: "bot", + }); + + await t.test("redirects GET requests", async () => { + const response = await bot.fetch( + new Request( + "https://example.com/ap/follow/2ca58e2a-a34a-43e6-81af-c4f21ffed0c5", + ), + undefined, + ); + assert.deepStrictEqual(response.status, 301); + assert.deepStrictEqual( + response.headers.get("Location"), + "https://example.com/ap/actor/bot/follow/2ca58e2a-a34a-43e6-81af-c4f21ffed0c5", + ); + }); + + await t.test("redirects HEAD requests", async () => { + const response = await bot.fetch( + new Request("https://example.com/ap/note/123", { method: "HEAD" }), + undefined, + ); + assert.deepStrictEqual(response.status, 301); + assert.deepStrictEqual( + response.headers.get("Location"), + "https://example.com/ap/actor/bot/note/123", + ); + }); + + await t.test("preserves the query string", async () => { + const response = await bot.fetch( + new Request("https://example.com/ap/note/123?foo=bar"), + undefined, + ); + assert.deepStrictEqual(response.status, 301); + assert.deepStrictEqual( + response.headers.get("Location"), + "https://example.com/ap/actor/bot/note/123?foo=bar", + ); + }); + + await t.test("does not redirect POST requests", async () => { + const response = await bot.fetch( + new Request("https://example.com/ap/note/123", { method: "POST" }), + undefined, + ); + assert.notDeepStrictEqual(response.status, 301); + }); + + await t.test("does not redirect canonical or unrelated paths", async () => { + const canonical = await bot.fetch( + new Request("https://example.com/ap/actor/bot/note/123"), + undefined, + ); + assert.notDeepStrictEqual(canonical.status, 301); + const inbox = await bot.fetch( + new Request("https://example.com/ap/inbox"), + undefined, + ); + assert.notDeepStrictEqual(inbox.status, 301); + }); +}); + +test("BotImpl.onFollowAccepted() with canonical follow URIs", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const accepted: Actor[] = []; + bot.onAcceptFollow = (_, actor) => void (accepted.push(actor)); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + await repository.addSentFollow( + "bot", + "9d952a10-77e6-46bd-a48a-208b47e5e2bb", + new Follow({ + id: new URL( + "https://example.com/ap/actor/bot/follow/9d952a10-77e6-46bd-a48a-208b47e5e2bb", + ), + actor: new URL("https://example.com/ap/actor/bot"), + object: new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }), + }), + ); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: new URL("https://example.com/ap/actor/john"), + object: new URL( + "https://example.com/ap/actor/bot/follow/9d952a10-77e6-46bd-a48a-208b47e5e2bb", + ), + }), + ); + assert.deepStrictEqual(accepted.length, 1); + assert.deepStrictEqual( + accepted[0].id, + new URL("https://example.com/ap/actor/john"), + ); + assert.ok( + await repository.getFollowee( + "bot", + new URL("https://example.com/ap/actor/john"), + ) != null, + ); +}); + +test("BotImpl.onLiked() with legacy message URIs", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const likes: Like[] = []; + bot.onLike = (_, like) => void (likes.push(like)); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + // The message is stored with a canonical URI, but a remote server that + // saw it before the upgrade may still refer to it by its legacy URI: + await repository.addMessage( + "bot", + messageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/bot/create/${messageId}`, + ), + actor: new URL("https://example.com/ap/actor/bot"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL(`https://example.com/ap/actor/bot/note/${messageId}`), + attribution: new URL("https://example.com/ap/actor/bot"), + to: PUBLIC_COLLECTION, + content: "Hello, world!", + }), + }), + ); + await bot.onLiked( + ctx, + new RawLike({ + id: new URL("https://remote.example/likes/1"), + actor: new URL("https://example.com/ap/actor/bot"), + object: new URL(`https://example.com/ap/note/${messageId}`), + }), + ); + assert.deepStrictEqual(likes.length, 1); + assert.deepStrictEqual( + likes[0].message.id, + new URL(`https://example.com/ap/actor/bot/note/${messageId}`), + ); +}); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index fc38cbb..38f65c8 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -100,7 +100,7 @@ import { KvRepository, type Uuid, } from "./repository.ts"; -import { parseLocalUri } from "./uri.ts"; +import { parseLocalUri, rewriteLegacyObjectPath } from "./uri.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; import type { Text } from "./text.ts"; @@ -1213,6 +1213,25 @@ export class BotImpl implements Bot { request = await getXForwardedRequest(request); } const url = new URL(request.url); + if ( + (request.method === "GET" || request.method === "HEAD") && + this.legacyObjectUrisIdentifier != null + ) { + // Dereferenceable requests to object URIs in the legacy (pre-0.5) + // format are permanently redirected to their canonical URIs: + const rewrittenPath = rewriteLegacyObjectPath( + url.pathname, + this.legacyObjectUrisIdentifier, + ); + if (rewrittenPath != null) { + const location = new URL(url.href); + location.pathname = rewrittenPath; + return new Response(null, { + status: 301, + headers: { Location: location.href }, + }); + } + } if ( url.pathname.startsWith("/.well-known/") || url.pathname.startsWith("/ap/") || From c909fd48bc2c27637fa90c2b645cd2bc0260d849 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 22:27:23 +0900 Subject: [PATCH 07/36] Expose a read-only bot view through Session.bot Session.bot used to expose the full Bot interface, so nothing stopped an event handler from accidentally reassigning another handler through it (e.g. session.bot.onMention = ...). Once multiple bots share an instance, a session leaking mutable access to its bot becomes even riskier, so the session now exposes a ReadonlyBot: a read-only view of the bot's identity and profile (identifier, username, name, class, icon, image, and follower policy). The event handler properties moved into a BotEventHandlers interface that Bot extends, which later allows dynamic bot groups to share the same handler-registration surface. Text lookups now pass the bot identifier to Context.getDocumentLoader() explicitly instead of passing the bot object, which also makes the signing identity deterministic. This is a breaking change for code that reached the full Bot through a session; such code should hold on to the Bot returned by createBot() instead. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/bot.test.ts | 20 +++- packages/botkit/src/bot.ts | 168 ++++++++++++++++++++----------- packages/botkit/src/mod.ts | 2 + packages/botkit/src/session.ts | 11 +- packages/botkit/src/text.test.ts | 9 +- packages/botkit/src/text.ts | 10 +- 6 files changed, 152 insertions(+), 68 deletions(-) diff --git a/packages/botkit/src/bot.test.ts b/packages/botkit/src/bot.test.ts index 724ba87..c09f892 100644 --- a/packages/botkit/src/bot.test.ts +++ b/packages/botkit/src/bot.test.ts @@ -18,7 +18,7 @@ import type { Actor } from "@fedify/vocab"; import assert from "node:assert"; import { test } from "node:test"; import type { BotImpl } from "./bot-impl.ts"; -import { createBot } from "./bot.ts"; +import { createBot, type ReadonlyBot } from "./bot.ts"; import type { FollowRequest } from "./follow.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; import type { Like } from "./reaction.ts"; @@ -119,3 +119,21 @@ test("createBot()", async () => { subject: "acct:bot@example.com", }); }); + +test("Session.bot is a ReadonlyBot", () => { + const bot = createBot({ + kv: new MemoryKvStore(), + username: "readonlybot", + }); + const session = bot.getSession("https://example.com"); + const view: ReadonlyBot = session.bot; + assert.deepStrictEqual(view.identifier, "bot"); + assert.deepStrictEqual(view.username, "readonlybot"); + assert.deepStrictEqual(view.followerPolicy, "accept"); + + // Event handlers must not be reachable through the session's bot view: + // @ts-expect-error: ReadonlyBot does not expose event handlers. + session.bot.onMention = undefined; + // @ts-expect-error: ReadonlyBot does not expose the repository. + session.bot.repository; +}); diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index beaf79b..e76eb1f 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -46,65 +46,12 @@ export { type Software } from "@fedify/fedify/nodeinfo"; export { Application, Image, Service } from "@fedify/vocab"; /** - * A bot that can interact with the ActivityPub network. + * The event handlers a bot can register. Assigning a handler to one of + * these properties makes the bot react to the corresponding ActivityPub + * activity. + * @since 0.5.0 */ -export interface Bot { - /** - * An internal Fedify federation instance. Normally you don't need to access - * this directly. - */ - readonly federation: Federation; - - /** - * The internal identifier for the bot actor. It is used for the actor URI. - */ - readonly identifier: string; - - /** - * Gets a new session to control the bot for a specific origin and context - * data. - * @param origin The origin of the session. Even if a URL with some path or - * query is passed, only the origin part will be used. - * @param contextData The context data to pass to the federation. - * @returns The session for the origin and context data. - */ - getSession( - origin: string | URL, - contextData: TContextData, - ): Session; - - /** - * Gets a new session to control bot for a specific Fedify context. - * @param context The Fedify context of the session. - * @returns The session for the Fedify context. - */ - getSession(context: Context): Session; - - /** - * The fetch API for handling HTTP requests. You can pass this to an HTTP - * server (e.g., `Deno.serve()`, `Bun.serve()`) to handle incoming requests. - * @param request The request to handle. - * @param contextData The context data to pass to the federation. - * @returns The response to the request. - */ - fetch(request: Request, contextData: TContextData): Promise; - - /** - * Defines custom emojis for the bot. The custom emojis are used for - * rendering the bot's profile and posts. The custom emojis are defined - * by their names, and the names are used as the keys of the emojis. - * @param emojis The custom emojis to define. The keys are the names of - * the emojis, and the values are the custom emoji definitions. - * @returns The defined emojis. The keys are the names of the emojis, and - * the values are the emoji objects, which are used for passing - * to the {@link customEmoji} function. - * @throws {TypeError} If any emoji name is invalid or duplicate. - * @since 0.2.0 - */ - addCustomEmojis( - emojis: Readonly>, - ): Readonly>>; - +export interface BotEventHandlers { /** * An event handler for a follow request to the bot. */ @@ -191,6 +138,111 @@ export interface Bot { onVote?: VoteEventHandler; } +/** + * A read-only view of a bot actor's identity and profile. It is exposed + * through {@link Session.bot} so that event handlers can tell which bot they + * are running as without being able to mutate the bot (e.g. reassign its + * event handlers). + * @since 0.5.0 + */ +export interface ReadonlyBot { + /** + * The internal identifier for the bot actor. It is used for the actor URI. + */ + readonly identifier: string; + + /** + * The username of the bot. It is a part of the fediverse handle. + */ + readonly username: string; + + /** + * The display name of the bot. + */ + readonly name?: string; + + /** + * The type of the bot actor. It is either `Service` or `Application`. + */ + readonly class: typeof Service | typeof Application; + + /** + * The avatar URL of the bot. + */ + readonly icon?: URL | Image; + + /** + * The header image URL of the bot. + */ + readonly image?: URL | Image; + + /** + * How the bot handles incoming follow requests. + */ + readonly followerPolicy: "accept" | "reject" | "manual"; +} + +/** + * A bot that can interact with the ActivityPub network. + */ +export interface Bot extends BotEventHandlers { + /** + * An internal Fedify federation instance. Normally you don't need to access + * this directly. + */ + readonly federation: Federation; + + /** + * The internal identifier for the bot actor. It is used for the actor URI. + */ + readonly identifier: string; + + /** + * Gets a new session to control the bot for a specific origin and context + * data. + * @param origin The origin of the session. Even if a URL with some path or + * query is passed, only the origin part will be used. + * @param contextData The context data to pass to the federation. + * @returns The session for the origin and context data. + */ + getSession( + origin: string | URL, + contextData: TContextData, + ): Session; + + /** + * Gets a new session to control bot for a specific Fedify context. + * @param context The Fedify context of the session. + * @returns The session for the Fedify context. + */ + getSession(context: Context): Session; + + /** + * The fetch API for handling HTTP requests. You can pass this to an HTTP + * server (e.g., `Deno.serve()`, `Bun.serve()`) to handle incoming requests. + * @param request The request to handle. + * @param contextData The context data to pass to the federation. + * @returns The response to the request. + */ + fetch(request: Request, contextData: TContextData): Promise; + + /** + * Defines custom emojis for the bot. The custom emojis are used for + * rendering the bot's profile and posts. The custom emojis are defined + * by their names, and the names are used as the keys of the emojis. + * @param emojis The custom emojis to define. The keys are the names of + * the emojis, and the values are the custom emoji definitions. + * @returns The defined emojis. The keys are the names of the emojis, and + * the values are the emoji objects, which are used for passing + * to the {@link customEmoji} function. + * @throws {TypeError} If any emoji name is invalid or duplicate. + * @since 0.2.0 + */ + addCustomEmojis( + emojis: Readonly>, + ): Readonly>>; +} + /** * A specialized {@link Bot} tpe that doesn't require context data. */ diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index 71130c5..b1f3de2 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -22,11 +22,13 @@ export { export { Application, type Bot, + type BotEventHandlers, type BotWithVoidContextData, createBot, type CreateBotOptions, Image, type PagesOptions, + type ReadonlyBot, Service, type Software, } from "./bot.ts"; diff --git a/packages/botkit/src/session.ts b/packages/botkit/src/session.ts index 1e63100..9244562 100644 --- a/packages/botkit/src/session.ts +++ b/packages/botkit/src/session.ts @@ -22,7 +22,7 @@ import type { Question, } from "@fedify/vocab"; import type { Context } from "@fedify/fedify/federation"; -import type { Bot } from "./bot.ts"; +import type { ReadonlyBot } from "./bot.ts"; import type { AuthorizedMessage, Message, @@ -37,9 +37,14 @@ import type { Text } from "./text.ts"; */ export interface Session { /** - * The bot to which the session belongs. + * A read-only view of the bot to which the session belongs. It exposes + * the bot's identity and profile (e.g. `identifier`, `username`, `name`) + * without allowing event handlers to be reassigned through it. + * + * Before BotKit 0.5.0, this property was typed as `Bot`; it is now + * a {@link ReadonlyBot} so that a session cannot mutate its bot. */ - readonly bot: Bot; + readonly bot: ReadonlyBot; /** * The Fedify context of the session. diff --git a/packages/botkit/src/text.test.ts b/packages/botkit/src/text.test.ts index f6db9a2..6403d6f 100644 --- a/packages/botkit/src/text.test.ts +++ b/packages/botkit/src/text.test.ts @@ -20,7 +20,7 @@ import { } from "@fedify/fedify/federation"; import { getDocumentLoader } from "@fedify/vocab-runtime"; import { importJwk } from "@fedify/fedify/sig"; -import { Emoji, Hashtag, Image, Mention, Person } from "@fedify/vocab"; +import { Emoji, Hashtag, Image, Mention, Person, Service } from "@fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; import { BotImpl } from "./bot-impl.ts"; @@ -122,7 +122,12 @@ const bot: BotWithVoidContextData = { ? federation.createContext(new URL(origin)) : origin; return { - bot, + bot: { + identifier: "bot", + username: "bot", + class: Service, + followerPolicy: "accept", + }, context: ctx, actorId: ctx.getActorUri(bot.identifier), actorHandle: `@bot@${ctx.host}` as const, diff --git a/packages/botkit/src/text.ts b/packages/botkit/src/text.ts index 6cf46e1..d6d3d74 100644 --- a/packages/botkit/src/text.ts +++ b/packages/botkit/src/text.ts @@ -410,7 +410,7 @@ export function mention( isActor(b) ? b : async (session) => { if (session.actorId.href === b.href) return await session.getActor(); const documentLoader = await session.context.getDocumentLoader( - session.bot, + { identifier: session.bot.identifier }, ); return await session.context.lookupObject(b, { documentLoader }); }, @@ -422,7 +422,7 @@ export function mention( async (session) => { if (session.actorHandle === a) return await session.getActor(); const documentLoader = await session.context.getDocumentLoader( - session.bot, + { identifier: session.bot.identifier }, ); return await session.context.lookupObject(a, { documentLoader }); }, @@ -446,7 +446,7 @@ export function mention( async (session) => { if (a.href === session.actorId.href) return await session.getActor(); const documentLoader = await session.context.getDocumentLoader( - session.bot, + { identifier: session.bot.identifier }, ); return await session.context.lookupObject(a, { documentLoader }); }, @@ -888,7 +888,9 @@ export class MarkdownText implements Text<"block", TContextData> { ): Promise> { if (this.#mentions == null) return {}; if (this.#actors != null) return this.#actors; - const documentLoader = await session.context.getDocumentLoader(session.bot); + const documentLoader = await session.context.getDocumentLoader({ + identifier: session.bot.identifier, + }); const objects = await Promise.all( this.#mentions.map((m) => m === session.actorHandle From 836228540b6a71f7fa9a624373b23d6ba6082932 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 22:41:49 +0900 Subject: [PATCH 08/36] Separate the Instance from the Bot BotKit conflated two concepts in one Bot object: the server infrastructure (KV store, message queue, repository, HTTP handling, and the Fedify federation) and an individual ActivityPub actor with its own identity and behavior. This is the fundamental reason one BotKit process could only host one bot. The new createInstance() function creates an Instance that owns the shared infrastructure and a single Fedify Federation, on which every hosted bot is registered. Federation callbacks are registered once by the instance and routed to the owning bot by the identifier carried in the URI. Instance.createBot(identifier, profile) creates a bot hosted on the instance; duplicate identifiers and usernames are rejected. Custom emojis, NodeInfo, the shared inbox key, and web page serving now belong to the instance. BotImpl keeps all of its per-bot behavior and its public surface, but no longer owns a Federation: constructing it without an instance creates a dedicated one, which preserves the single-bot behavior of BotKit 0.4 and earlier, including the recognition of legacy object URIs. Incoming activities are currently delivered to every hosted bot, which is identical to the previous behavior for a single bot; relevance-based routing for multiple bots follows separately. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/bot-impl.ts | 534 +++++++------------ packages/botkit/src/bot.ts | 112 +--- packages/botkit/src/instance-impl.test.ts | 144 ++++++ packages/botkit/src/instance-impl.ts | 592 ++++++++++++++++++++++ packages/botkit/src/instance.ts | 292 +++++++++++ packages/botkit/src/mod.ts | 7 + 6 files changed, 1233 insertions(+), 448 deletions(-) create mode 100644 packages/botkit/src/instance-impl.test.ts create mode 100644 packages/botkit/src/instance-impl.ts create mode 100644 packages/botkit/src/instance.ts diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 38f65c8..5efa56f 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -15,7 +15,6 @@ // along with this program. If not, see . import { type Context, - createFederation, type Federation, generateCryptoKeyPair, type InboxContext, @@ -26,7 +25,7 @@ import { type UnverifiedActivityReason, } from "@fedify/fedify"; import { - Accept, + type Accept, type Activity, type Actor, Announce, @@ -34,7 +33,6 @@ import { Article, ChatMessage, Create, - Delete, Emoji as APEmoji, EmojiReact, Endpoints, @@ -50,16 +48,11 @@ import { PUBLIC_COLLECTION, Question, type Recipient, - Reject, + type Reject, Service, - Undo, + type Undo, Update, } from "@fedify/vocab"; -import { getLogger } from "@logtape/logtape"; -import mimeDb from "mime-db"; -import fs from "node:fs/promises"; -import { getXForwardedRequest } from "x-forwarded-fetch"; -import metadata from "../deno.json" with { type: "json" }; import type { Bot, CreateBotOptions, PagesOptions } from "./bot.ts"; import { type CustomEmoji, @@ -84,6 +77,7 @@ import type { VoteEventHandler, } from "./events.ts"; import { FollowRequestImpl } from "./follow-impl.ts"; +import { InstanceImpl } from "./instance-impl.ts"; import { createMessage, getMessageVisibility, @@ -92,15 +86,10 @@ import { messageClasses, } from "./message-impl.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; -import { app } from "./pages.tsx"; import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; -import { - type ActorScopedRepository, - KvRepository, - type Uuid, -} from "./repository.ts"; -import { parseLocalUri, rewriteLegacyObjectPath } from "./uri.ts"; +import type { ActorScopedRepository, Uuid } from "./repository.ts"; +import { parseLocalUri } from "./uri.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; import type { Text } from "./text.ts"; @@ -108,6 +97,13 @@ import type { Text } from "./text.ts"; export interface BotImplOptions extends CreateBotOptions { collectionWindow?: number; + + /** + * The instance to host the bot on. If omitted, a dedicated instance is + * created from the given options, which preserves the single-bot behavior + * of BotKit 0.4 and earlier. + */ + instance?: InstanceImpl; } export class BotImpl implements Bot { @@ -122,13 +118,38 @@ export class BotImpl implements Bot { readonly properties: Record>; #properties: { pairs: PropertyValue[]; tags: (Link | Object)[] } | null; readonly followerPolicy: "accept" | "reject" | "manual"; - readonly customEmojis: Record; readonly repository: ActorScopedRepository; - readonly software?: Software; - readonly behindProxy: boolean; - readonly pages: Required; - readonly collectionWindow: number; - readonly federation: Federation; + + /** + * The instance hosting the bot. It owns the shared infrastructure: + * the Fedify federation, the key–value store, the message queue, + * the root repository, and HTTP handling. + */ + readonly instance: InstanceImpl; + + get customEmojis(): Record { + return this.instance.customEmojis; + } + + get software(): Software | undefined { + return this.instance.software; + } + + get behindProxy(): boolean { + return this.instance.behindProxy; + } + + get pages(): Required { + return this.instance.pages; + } + + get collectionWindow(): number { + return this.instance.collectionWindow; + } + + get federation(): Federation { + return this.instance.federation; + } /** * The identifier of the bot actor that owns local objects whose URIs are @@ -136,7 +157,9 @@ export class BotImpl implements Bot { * Legacy URIs can only occur in deployments that hosted a single bot * before the upgrade, so they are attributed to that bot. */ - readonly legacyObjectUrisIdentifier?: string; + get legacyObjectUrisIdentifier(): string | undefined { + return this.instance.legacyObjectUrisIdentifier; + } onFollow?: FollowEventHandler; onUnfollow?: UnfollowEventHandler; @@ -165,132 +188,20 @@ export class BotImpl implements Bot { this.properties = options.properties ?? {}; this.#properties = null; this.followerPolicy = options.followerPolicy ?? "accept"; - this.customEmojis = {}; - this.repository = (options.repository ?? new KvRepository(options.kv)) - .forIdentifier(this.identifier); - this.software = options.software; - this.pages = { - color: "green", - css: "", - ...(options.pages ?? {}), - }; - this.federation = createFederation({ + this.instance = options.instance ?? new InstanceImpl({ kv: options.kv, + repository: options.repository, queue: options.queue, - userAgent: { - software: `BotKit/${metadata.version}`, - }, + software: options.software, + behindProxy: options.behindProxy, + pages: options.pages, + collectionWindow: options.collectionWindow, + // A dedicated instance hosts the single bot that predates the + // multi-bot upgrade, so legacy object URIs belong to it: + legacyObjectUris: { identifier: this.identifier }, }); - this.behindProxy = options.behindProxy ?? false; - this.collectionWindow = options.collectionWindow ?? 50; - this.legacyObjectUrisIdentifier = this.identifier; - this.initialize(); - } - - initialize(): void { - this.federation - .setActorDispatcher( - "/ap/actor/{identifier}", - this.dispatchActor.bind(this), - ) - .mapHandle(this.mapHandle.bind(this)) - .setKeyPairsDispatcher(this.dispatchActorKeyPairs.bind(this)); - this.federation - .setFollowersDispatcher( - "/ap/actor/{identifier}/followers", - this.dispatchFollowers.bind(this), - ) - .setFirstCursor(this.getFollowersFirstCursor.bind(this)) - .setCounter(this.countFollowers.bind(this)); - this.federation - .setOutboxDispatcher( - "/ap/actor/{identifier}/outbox", - this.dispatchOutbox.bind(this), - ) - .setFirstCursor(this.getOutboxFirstCursor.bind(this)) - .setCounter(this.countOutbox.bind(this)); - this.federation - .setObjectDispatcher( - Follow, - "/ap/actor/{identifier}/follow/{id}", - this.dispatchFollow.bind(this), - ) - .authorize(this.authorizeFollow.bind(this)); - this.federation.setObjectDispatcher( - Create, - "/ap/actor/{identifier}/create/{id}", - this.dispatchCreate.bind(this), - ); - this.federation.setObjectDispatcher( - Article, - "/ap/actor/{identifier}/article/{id}", - (ctx, values) => - values.identifier === this.identifier - ? this.dispatchMessage(Article, ctx, values.id) - : null, - ); - this.federation.setObjectDispatcher( - ChatMessage, - "/ap/actor/{identifier}/chat-message/{id}", - (ctx, values) => - values.identifier === this.identifier - ? this.dispatchMessage(ChatMessage, ctx, values.id) - : null, - ); - this.federation.setObjectDispatcher( - Note, - "/ap/actor/{identifier}/note/{id}", - (ctx, values) => - values.identifier === this.identifier - ? this.dispatchMessage(Note, ctx, values.id) - : null, - ); - this.federation.setObjectDispatcher( - Question, - "/ap/actor/{identifier}/question/{id}", - (ctx, values) => - values.identifier === this.identifier - ? this.dispatchMessage(Question, ctx, values.id) - : null, - ); - this.federation.setObjectDispatcher( - Announce, - "/ap/actor/{identifier}/announce/{id}", - this.dispatchAnnounce.bind(this), - ); - this.federation.setObjectDispatcher( - APEmoji, - "/ap/emoji/{name}", - this.dispatchEmoji.bind(this), - ); - this.federation - .setInboxListeners("/ap/actor/{identifier}/inbox", "/ap/inbox") - .onUnverifiedActivity(this.onUnverifiedActivity.bind(this)) - .on(Follow, this.onFollowed.bind(this)) - .on(Undo, async (ctx, undo) => { - const object = await undo.getObject(ctx); - if (object instanceof Follow) await this.onUnfollowed(ctx, undo); - else if (object instanceof RawLike) await this.onUnliked(ctx, undo); - else { - const logger = getLogger(["botkit", "bot", "inbox"]); - logger.warn( - "The Undo object {undoId} is not about Follow or Like: {object}.", - { undoId: undo.id?.href, object }, - ); - } - }) - .on(Accept, this.onFollowAccepted.bind(this)) - .on(Reject, this.onFollowRejected.bind(this)) - .on(Create, this.onCreated.bind(this)) - .on(Announce, this.onAnnounced.bind(this)) - .on(RawLike, this.onLiked.bind(this)) - .setSharedKeyDispatcher(this.dispatchSharedKey.bind(this)); - if (this.software != null) { - this.federation.setNodeInfoDispatcher( - "/nodeinfo/2.1", - this.dispatchNodeInfo.bind(this), - ); - } + this.repository = this.instance.repository.forIdentifier(this.identifier); + this.instance.addBot(this); } async getActorSummary( @@ -577,28 +488,19 @@ export class BotImpl implements Bot { ctx: Context, values: { name: string }, ): APEmoji | null { - const customEmoji = this.customEmojis[values.name]; - if (customEmoji == null) return null; - return this.getEmoji(ctx, values.name, customEmoji); + return this.instance.dispatchEmoji(ctx, values); } - dispatchSharedKey(_ctx: Context): { identifier: string } { - return { identifier: this.identifier }; + dispatchSharedKey(ctx: Context): { identifier: string } { + return this.instance.dispatchSharedKey(ctx); } onUnverifiedActivity( - _ctx: RequestContext, + ctx: RequestContext, activity: Activity, reason: UnverifiedActivityReason, ): Response | void { - if ( - activity instanceof Delete && - reason.type === "keyFetchError" && - "status" in reason.result && - reason.result.status === 410 - ) { - return new Response(null, { status: 202 }); - } + return this.instance.onUnverifiedActivity(ctx, activity, reason); } async onFollowed( @@ -1121,23 +1023,8 @@ export class BotImpl implements Bot { await this.onUnreact(session, reaction); } - dispatchNodeInfo(_ctx: Context): NodeInfo { - return { - software: this.software!, - protocols: ["activitypub"], - services: { - outbound: ["atom1.0"], // TODO - }, - usage: { - users: { - total: 1, - activeMonth: 1, // FIXME - activeHalfyear: 1, // FIXME - }, - localPosts: 0, // FIXME - localComments: 0, - }, - }; + dispatchNodeInfo(ctx: Context): NodeInfo { + return this.instance.dispatchNodeInfo(ctx); } getSession( @@ -1157,125 +1044,20 @@ export class BotImpl implements Bot { return new SessionImpl(this, ctx); } - async addCollectionInverseProperty( + addCollectionInverseProperty( request: Request, contextData: TContextData, response: Response, ): Promise { - if (!response.ok) return response; - const ctx = this.federation.createContext(request, contextData); - const parsed = ctx.parseUri(new URL(request.url)); - if ( - parsed == null || - (parsed.type !== "outbox" && parsed.type !== "followers") || - parsed.identifier == null - ) { - return response; - } - const contentType = response.headers.get("Content-Type"); - if ( - contentType == null || - ( - !contentType.startsWith("application/activity+json") && - !contentType.startsWith("application/ld+json") - ) - ) { - return response; - } - const body = await response.json(); - if (typeof body !== "object" || body == null || Array.isArray(body)) { - return new Response(JSON.stringify(body), { - headers: response.headers, - status: response.status, - statusText: response.statusText, - }); - } - const property = parsed.type === "outbox" ? "outboxOf" : "followersOf"; - const actorUri = ctx.getActorUri(parsed.identifier).href; - if (body[property] === actorUri) { - return new Response(JSON.stringify(body), { - headers: response.headers, - status: response.status, - statusText: response.statusText, - }); - } - const headers = new Headers(response.headers); - headers.delete("Content-Length"); - return new Response(JSON.stringify({ ...body, [property]: actorUri }), { - headers, - status: response.status, - statusText: response.statusText, - }); + return this.instance.addCollectionInverseProperty( + request, + contextData, + response, + ); } - async fetch(request: Request, contextData: TContextData): Promise { - if (this.behindProxy) { - request = await getXForwardedRequest(request); - } - const url = new URL(request.url); - if ( - (request.method === "GET" || request.method === "HEAD") && - this.legacyObjectUrisIdentifier != null - ) { - // Dereferenceable requests to object URIs in the legacy (pre-0.5) - // format are permanently redirected to their canonical URIs: - const rewrittenPath = rewriteLegacyObjectPath( - url.pathname, - this.legacyObjectUrisIdentifier, - ); - if (rewrittenPath != null) { - const location = new URL(url.href); - location.pathname = rewrittenPath; - return new Response(null, { - status: 301, - headers: { Location: location.href }, - }); - } - } - if ( - url.pathname.startsWith("/.well-known/") || - url.pathname.startsWith("/ap/") || - url.pathname.startsWith("/nodeinfo/") - ) { - const response = await this.federation.fetch(request, { contextData }); - return await this.addCollectionInverseProperty( - request, - contextData, - response, - ); - } - const match = /^\/emojis\/([a-z0-9-_]+)(?:$|\.)/.exec(url.pathname); - if (match != null) { - const customEmoji = this.customEmojis[match[1]]; - if (customEmoji == null || !("file" in customEmoji)) { - return new Response("Not Found", { status: 404 }); - } - let file: fs.FileHandle; - try { - file = await fs.open(customEmoji.file, "r"); - } catch (error) { - if ( - typeof error === "object" && error != null && "code" in error && - error.code === "ENOENT" - ) { - return new Response("Not Found", { status: 404 }); - } - throw error; - } - const fileInfo = await file.stat(); - return new Response(file.readableWebStream(), { - headers: { - "Content-Type": customEmoji.type, - "Content-Length": fileInfo.size.toString(), - "Cache-Control": "public, max-age=31536000, immutable", - "Last-Modified": (fileInfo.mtime ?? new Date()).toUTCString(), - "ETag": `"${fileInfo.mtime?.getTime().toString(36)}${ - fileInfo.size.toString(36) - }"`, - }, - }); - } - return await app.fetch(request, { bot: this, contextData }); + fetch(request: Request, contextData: TContextData): Promise { + return this.instance.fetch(request, contextData); } getEmoji( @@ -1283,63 +1065,139 @@ export class BotImpl implements Bot { name: string, data: CustomEmoji, ): APEmoji { - let url: URL; - if ("url" in data) { - url = new URL(data.url); - } else { - // @ts-ignore: data.type satisfies keyof typeof mimeDb - const t = mimeDb[data.type]; - url = new URL( - `/emojis/${name}${ - t == null || t.extensions == null || t.extensions.length < 1 - ? "" - : `.${t.extensions[0]}` - }`, - ctx.origin, - ); - } - return new APEmoji({ - id: ctx.getObjectUri(APEmoji, { name }), - name: `:${name}:`, - icon: new Image({ - mediaType: data.type, - url, - }), - }); + return this.instance.getEmoji(ctx, name, data); } addCustomEmoji( name: TEmojiName, data: CustomEmoji, ): DeferredCustomEmoji { - if (!name.match(/^[a-z0-9-_]+$/i)) { - throw new TypeError( - `Invalid custom emoji name: ${name}. It must match /^[a-z0-9-_]+$/i.`, - ); - } else if (name in this.customEmojis) { - throw new TypeError(`Duplicate custom emoji name: ${name}`); - } else if (!data.type.startsWith("image/")) { - throw new TypeError(`Unsupported media type: ${data.type}`); - } - this.customEmojis[name] = data; - return (session: Session) => - this.getEmoji( - session.context, - name, - data, - ); + return this.instance.addCustomEmoji(name, data); } addCustomEmojis( emojis: Readonly>, ): Readonly>> { - const emojiMap = {} as Record< - TEmojiName, - DeferredCustomEmoji - >; - for (const name in emojis) { - emojiMap[name] = this.addCustomEmoji(name, emojis[name]); - } - return emojiMap; + return this.instance.addCustomEmojis(emojis); } } + +/** + * Wraps a {@link BotImpl} instance with a plain object implementing + * the {@link Bot} interface. Since `deno serve` does not recognize a class + * instance having fetch(), we wrap a BotImpl instance with a plain object. + * See also https://github.com/denoland/deno/issues/24062 + * @param bot The bot implementation to wrap. + * @returns The wrapped bot. + * @internal + */ +export function wrapBotImpl( + bot: BotImpl, +): Bot { + const wrapper = { + impl: bot, + get federation() { + return bot.federation; + }, + get identifier() { + return bot.identifier; + }, + getSession(a, b?) { + // @ts-ignore: BotImpl.getSession() implements Bot.getSession() + return bot.getSession(a, b); + }, + fetch(request, contextData) { + return bot.fetch(request, contextData); + }, + addCustomEmojis( + emojis: Readonly>, + ): Readonly>> { + return bot.addCustomEmojis(emojis); + }, + get onFollow() { + return bot.onFollow; + }, + set onFollow(value) { + bot.onFollow = value; + }, + get onUnfollow() { + return bot.onUnfollow; + }, + set onUnfollow(value) { + bot.onUnfollow = value; + }, + get onAcceptFollow() { + return bot.onAcceptFollow; + }, + set onAcceptFollow(value) { + bot.onAcceptFollow = value; + }, + get onRejectFollow() { + return bot.onRejectFollow; + }, + set onRejectFollow(value) { + bot.onRejectFollow = value; + }, + get onMention() { + return bot.onMention; + }, + set onMention(value) { + bot.onMention = value; + }, + get onReply() { + return bot.onReply; + }, + set onReply(value) { + bot.onReply = value; + }, + get onQuote() { + return bot.onQuote; + }, + set onQuote(value) { + bot.onQuote = value; + }, + get onMessage() { + return bot.onMessage; + }, + set onMessage(value) { + bot.onMessage = value; + }, + get onSharedMessage() { + return bot.onSharedMessage; + }, + set onSharedMessage(value) { + bot.onSharedMessage = value; + }, + get onLike() { + return bot.onLike; + }, + set onLike(value) { + bot.onLike = value; + }, + get onUnlike() { + return bot.onUnlike; + }, + set onUnlike(value) { + bot.onUnlike = value; + }, + get onReact() { + return bot.onReact; + }, + set onReact(value) { + bot.onReact = value; + }, + get onUnreact() { + return bot.onUnreact; + }, + set onUnreact(value) { + bot.onUnreact = value; + }, + get onVote() { + return bot.onVote; + }, + set onVote(value) { + bot.onVote = value; + }, + } satisfies Bot & { impl: BotImpl }; + return wrapper; +} diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index e76eb1f..fae5054 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -21,7 +21,7 @@ import type { } from "@fedify/fedify/federation"; import type { Software } from "@fedify/fedify/nodeinfo"; import type { Application, Image, Service } from "@fedify/vocab"; -import { BotImpl } from "./bot-impl.ts"; +import { BotImpl, wrapBotImpl } from "./bot-impl.ts"; import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts"; import type { AcceptEventHandler, @@ -437,114 +437,6 @@ export function createBot( options: CreateBotOptions, ): TContextData extends void ? BotWithVoidContextData : Bot { const bot = new BotImpl(options); - // Since `deno serve` does not recognize a class instance having fetch(), - // we wrap a BotImpl instance with a plain object. - // See also https://github.com/denoland/deno/issues/24062 - const wrapper = { - impl: bot, - get federation() { - return bot.federation; - }, - get identifier() { - return bot.identifier; - }, - getSession(a, b?) { - // @ts-ignore: BotImpl.getSession() implements Bot.getSession() - return bot.getSession(a, b); - }, - fetch(request, contextData) { - return bot.fetch(request, contextData); - }, - addCustomEmojis( - emojis: Readonly>, - ): Readonly>> { - return bot.addCustomEmojis(emojis); - }, - get onFollow() { - return bot.onFollow; - }, - set onFollow(value) { - bot.onFollow = value; - }, - get onUnfollow() { - return bot.onUnfollow; - }, - set onUnfollow(value) { - bot.onUnfollow = value; - }, - get onAcceptFollow() { - return bot.onAcceptFollow; - }, - set onAcceptFollow(value) { - bot.onAcceptFollow = value; - }, - get onRejectFollow() { - return bot.onRejectFollow; - }, - set onRejectFollow(value) { - bot.onRejectFollow = value; - }, - get onMention() { - return bot.onMention; - }, - set onMention(value) { - bot.onMention = value; - }, - get onReply() { - return bot.onReply; - }, - set onReply(value) { - bot.onReply = value; - }, - get onQuote() { - return bot.onQuote; - }, - set onQuote(value) { - bot.onQuote = value; - }, - get onMessage() { - return bot.onMessage; - }, - set onMessage(value) { - bot.onMessage = value; - }, - get onSharedMessage() { - return bot.onSharedMessage; - }, - set onSharedMessage(value) { - bot.onSharedMessage = value; - }, - get onLike() { - return bot.onLike; - }, - set onLike(value) { - bot.onLike = value; - }, - get onUnlike() { - return bot.onUnlike; - }, - set onUnlike(value) { - bot.onUnlike = value; - }, - get onReact() { - return bot.onReact; - }, - set onReact(value) { - bot.onReact = value; - }, - get onUnreact() { - return bot.onUnreact; - }, - set onUnreact(value) { - bot.onUnreact = value; - }, - get onVote() { - return bot.onVote; - }, - set onVote(value) { - bot.onVote = value; - }, - } satisfies Bot & { impl: BotImpl }; // @ts-ignore: the wrapper implements BotWithVoidContextData - return wrapper; + return wrapBotImpl(bot); } diff --git a/packages/botkit/src/instance-impl.test.ts b/packages/botkit/src/instance-impl.test.ts new file mode 100644 index 0000000..6937fc7 --- /dev/null +++ b/packages/botkit/src/instance-impl.test.ts @@ -0,0 +1,144 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { MemoryKvStore } from "@fedify/fedify/federation"; +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { createInstance } from "./instance.ts"; + +describe("createInstance()", () => { + test("serves a static bot's actor", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + const bot = instance.createBot("bot", { + username: "mybot", + name: "My Bot", + }); + assert.deepStrictEqual(bot.identifier, "bot"); + + const response = await instance.fetch( + new Request("https://example.com/ap/actor/bot", { + headers: { Accept: "application/activity+json" }, + }), + ); + assert.deepStrictEqual(response.status, 200); + const actor = await response.json(); + assert.deepStrictEqual(actor.preferredUsername, "mybot"); + assert.deepStrictEqual(actor.name, "My Bot"); + }); + + test("serves WebFinger for a static bot", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("bot", { username: "mybot" }); + const response = await instance.fetch( + new Request( + "https://example.com/.well-known/webfinger?resource=acct:mybot@example.com", + ), + ); + assert.deepStrictEqual(response.status, 200); + const jrd = await response.json(); + assert.deepStrictEqual(jrd.subject, "acct:mybot@example.com"); + }); + + test("returns 404 for unregistered identifiers", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("bot", { username: "mybot" }); + const response = await instance.fetch( + new Request("https://example.com/ap/actor/nonexistent", { + headers: { Accept: "application/activity+json" }, + }), + ); + assert.deepStrictEqual(response.status, 404); + }); + + test("rejects duplicate identifiers and usernames", () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("bot", { username: "mybot" }); + assert.throws( + () => instance.createBot("bot", { username: "other" }), + TypeError, + ); + assert.throws( + () => instance.createBot("other", { username: "mybot" }), + TypeError, + ); + }); + + test("registers event handlers through the returned bot", () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + const bot = instance.createBot("bot", { username: "mybot" }); + const handler = () => {}; + bot.onMention = handler; + assert.deepStrictEqual(bot.onMention, handler); + }); + + test("creates sessions for a static bot", () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + const bot = instance.createBot("bot", { username: "mybot" }); + const session = bot.getSession("https://example.com"); + assert.deepStrictEqual( + session.actorId.href, + "https://example.com/ap/actor/bot", + ); + assert.deepStrictEqual(session.actorHandle, "@mybot@example.com"); + assert.deepStrictEqual(session.bot.identifier, "bot"); + assert.deepStrictEqual(session.bot.username, "mybot"); + }); + + test("does not redirect legacy URIs without legacyObjectUris", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("bot", { username: "mybot" }); + const response = await instance.fetch( + new Request("https://example.com/ap/note/123"), + ); + assert.notDeepStrictEqual(response.status, 301); + }); + + test("redirects legacy URIs with legacyObjectUris", async () => { + const instance = createInstance({ + kv: new MemoryKvStore(), + legacyObjectUris: { identifier: "bot" }, + }); + instance.createBot("bot", { username: "mybot" }); + const response = await instance.fetch( + new Request("https://example.com/ap/note/123"), + ); + assert.deepStrictEqual(response.status, 301); + assert.deepStrictEqual( + response.headers.get("Location"), + "https://example.com/ap/actor/bot/note/123", + ); + }); + + test("defines custom emojis at the instance level", () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + const emojis = instance.addCustomEmojis({ + wave: { + type: "image/png", + url: "https://example.com/emojis/wave.png", + }, + }); + assert.ok("wave" in emojis); + assert.throws( + () => + instance.addCustomEmojis({ + wave: { + type: "image/png", + url: "https://example.com/emojis/wave2.png", + }, + }), + TypeError, + ); + }); +}); diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts new file mode 100644 index 0000000..3784062 --- /dev/null +++ b/packages/botkit/src/instance-impl.ts @@ -0,0 +1,592 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { + type Context, + createFederation, + type Federation, + type KvStore, + type MessageQueue, + type NodeInfo, + type Software, +} from "@fedify/fedify"; +import { + Accept, + type Activity, + Announce, + Article, + ChatMessage, + Create, + Delete, + Emoji as APEmoji, + Follow, + Image, + Like as RawLike, + Note, + Question, + Reject, + Undo, +} from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import mimeDb from "mime-db"; +import fs from "node:fs/promises"; +import { getXForwardedRequest } from "x-forwarded-fetch"; +import metadata from "../deno.json" with { type: "json" }; +import { BotImpl } from "./bot-impl.ts"; +import type { Bot, PagesOptions } from "./bot.ts"; +import { wrapBotImpl } from "./bot-impl.ts"; +import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts"; +import type { + BotProfile, + CreateInstanceOptions, + Instance, +} from "./instance.ts"; +import { app } from "./pages.tsx"; +import { KvRepository, type Repository } from "./repository.ts"; +import type { Session } from "./session.ts"; +import { rewriteLegacyObjectPath } from "./uri.ts"; + +/** + * Options for creating an {@link InstanceImpl}. + * @internal + */ +export interface InstanceImplOptions extends CreateInstanceOptions { + collectionWindow?: number; +} + +/** + * The internal implementation of an {@link Instance}. It owns the single + * Fedify {@link Federation} shared by every bot hosted on the instance, and + * routes federation callbacks to the right bot by its identifier. + * @internal + */ +export class InstanceImpl + implements Omit, "createBot"> { + readonly kv: KvStore; + readonly queue?: MessageQueue; + + /** + * The root repository shared by every bot hosted on the instance. + */ + readonly repository: Repository; + readonly software?: Software; + readonly behindProxy: boolean; + readonly pages: Required; + readonly collectionWindow: number; + readonly federation: Federation; + readonly customEmojis: Record = {}; + + /** + * The identifier of the bot actor that owns local objects whose URIs are + * in the legacy (pre-0.5) format, which did not carry the identifier. + */ + readonly legacyObjectUrisIdentifier?: string; + + readonly #bots: Map> = new Map(); + + constructor(options: InstanceImplOptions) { + this.kv = options.kv; + this.queue = options.queue; + this.repository = options.repository ?? new KvRepository(options.kv); + this.software = options.software; + this.behindProxy = options.behindProxy ?? false; + this.pages = { + color: "green", + css: "", + ...(options.pages ?? {}), + }; + this.collectionWindow = options.collectionWindow ?? 50; + this.legacyObjectUrisIdentifier = options.legacyObjectUris?.identifier; + this.federation = createFederation({ + kv: options.kv, + queue: options.queue, + userAgent: { + software: `BotKit/${metadata.version}`, + }, + }); + this.#initialize(); + } + + #initialize(): void { + this.federation + .setActorDispatcher( + "/ap/actor/{identifier}", + (ctx, identifier) => + this.getBot(identifier)?.dispatchActor(ctx, identifier) ?? null, + ) + .mapHandle((ctx, username) => this.mapHandle(ctx, username)) + .setKeyPairsDispatcher((ctx, identifier) => + this.getBot(identifier)?.dispatchActorKeyPairs(ctx, identifier) ?? [] + ); + this.federation + .setFollowersDispatcher( + "/ap/actor/{identifier}/followers", + (ctx, identifier, cursor) => + this.getBot(identifier) + ?.dispatchFollowers(ctx, identifier, cursor) ?? null, + ) + .setFirstCursor((ctx, identifier) => + this.getBot(identifier)?.getFollowersFirstCursor(ctx, identifier) ?? + null + ) + .setCounter((ctx, identifier) => + this.getBot(identifier)?.countFollowers(ctx, identifier) ?? null + ); + this.federation + .setOutboxDispatcher( + "/ap/actor/{identifier}/outbox", + (ctx, identifier, cursor) => + this.getBot(identifier)?.dispatchOutbox(ctx, identifier, cursor) ?? + null, + ) + .setFirstCursor((ctx, identifier) => + this.getBot(identifier)?.getOutboxFirstCursor(ctx, identifier) ?? null + ) + .setCounter((ctx, identifier) => + this.getBot(identifier)?.countOutbox(ctx, identifier) ?? null + ); + this.federation + .setObjectDispatcher( + Follow, + "/ap/actor/{identifier}/follow/{id}", + (ctx, values) => + this.getBot(values.identifier)?.dispatchFollow(ctx, values) ?? null, + ) + .authorize((ctx, values) => + this.getBot(values.identifier)?.authorizeFollow(ctx, values) ?? false + ); + this.federation.setObjectDispatcher( + Create, + "/ap/actor/{identifier}/create/{id}", + (ctx, values) => + this.getBot(values.identifier)?.dispatchCreate(ctx, values) ?? null, + ); + this.federation.setObjectDispatcher( + Article, + "/ap/actor/{identifier}/article/{id}", + (ctx, values) => + this.getBot(values.identifier) + ?.dispatchMessage(Article, ctx, values.id) ?? null, + ); + this.federation.setObjectDispatcher( + ChatMessage, + "/ap/actor/{identifier}/chat-message/{id}", + (ctx, values) => + this.getBot(values.identifier) + ?.dispatchMessage(ChatMessage, ctx, values.id) ?? null, + ); + this.federation.setObjectDispatcher( + Note, + "/ap/actor/{identifier}/note/{id}", + (ctx, values) => + this.getBot(values.identifier) + ?.dispatchMessage(Note, ctx, values.id) ?? null, + ); + this.federation.setObjectDispatcher( + Question, + "/ap/actor/{identifier}/question/{id}", + (ctx, values) => + this.getBot(values.identifier) + ?.dispatchMessage(Question, ctx, values.id) ?? null, + ); + this.federation.setObjectDispatcher( + Announce, + "/ap/actor/{identifier}/announce/{id}", + (ctx, values) => + this.getBot(values.identifier)?.dispatchAnnounce(ctx, values) ?? null, + ); + this.federation.setObjectDispatcher( + APEmoji, + "/ap/emoji/{name}", + (ctx, values) => this.dispatchEmoji(ctx, values), + ); + this.federation + .setInboxListeners("/ap/actor/{identifier}/inbox", "/ap/inbox") + .onUnverifiedActivity((ctx, activity, reason) => + this.onUnverifiedActivity(ctx, activity, reason) + ) + .on(Follow, async (ctx, follow) => { + for (const bot of this.#bots.values()) { + await bot.onFollowed(ctx, follow); + } + }) + .on(Undo, async (ctx, undo) => { + const object = await undo.getObject(ctx); + if (object instanceof Follow) { + for (const bot of this.#bots.values()) { + await bot.onUnfollowed(ctx, undo); + } + } else if (object instanceof RawLike) { + for (const bot of this.#bots.values()) { + await bot.onUnliked(ctx, undo); + } + } else { + const logger = getLogger(["botkit", "bot", "inbox"]); + logger.warn( + "The Undo object {undoId} is not about Follow or Like: {object}.", + { undoId: undo.id?.href, object }, + ); + } + }) + .on(Accept, async (ctx, accept) => { + for (const bot of this.#bots.values()) { + await bot.onFollowAccepted(ctx, accept); + } + }) + .on(Reject, async (ctx, reject) => { + for (const bot of this.#bots.values()) { + await bot.onFollowRejected(ctx, reject); + } + }) + .on(Create, async (ctx, create) => { + for (const bot of this.#bots.values()) { + await bot.onCreated(ctx, create); + } + }) + .on(Announce, async (ctx, announce) => { + for (const bot of this.#bots.values()) { + await bot.onAnnounced(ctx, announce); + } + }) + .on(RawLike, async (ctx, like) => { + for (const bot of this.#bots.values()) { + await bot.onLiked(ctx, like); + } + }) + .setSharedKeyDispatcher((ctx) => this.dispatchSharedKey(ctx)); + if (this.software != null) { + this.federation.setNodeInfoDispatcher( + "/nodeinfo/2.1", + (ctx) => this.dispatchNodeInfo(ctx), + ); + } + } + + /** + * Registers a bot on the instance. Invoked by the {@link BotImpl} + * constructor. + * @param bot The bot to register. + * @throws {TypeError} If a bot with the same identifier or username + * already exists on the instance. + */ + addBot(bot: BotImpl): void { + if (this.#bots.has(bot.identifier)) { + throw new TypeError( + `A bot with the identifier already exists: ${bot.identifier}`, + ); + } + for (const existing of this.#bots.values()) { + if (existing.username === bot.username) { + throw new TypeError( + `A bot with the username already exists: ${bot.username}`, + ); + } + } + this.#bots.set(bot.identifier, bot); + } + + /** + * Resolves a bot hosted on the instance by its identifier. + * @param identifier The identifier of the bot to resolve. + * @returns The resolved bot, or `undefined` if no bot has the identifier. + */ + getBot(identifier: string): BotImpl | undefined { + return this.#bots.get(identifier); + } + + /** + * Every bot hosted on the instance. + */ + get bots(): Iterable> { + return this.#bots.values(); + } + + #firstBot(): BotImpl | undefined { + return this.#bots.values().next().value; + } + + createBot( + identifier: string, + profile: BotProfile, + ): Bot { + const bot = new BotImpl({ + instance: this, + identifier, + kv: this.kv, + class: profile.class, + username: profile.username, + name: profile.name, + summary: profile.summary, + icon: profile.icon, + image: profile.image, + properties: profile.properties, + followerPolicy: profile.followerPolicy, + }); + return wrapBotImpl(bot); + } + + mapHandle( + _ctx: Context, + username: string, + ): string | null { + for (const bot of this.#bots.values()) { + if (bot.username === username) return bot.identifier; + } + return null; + } + + onUnverifiedActivity( + _ctx: Context, + activity: Activity, + reason: { type: string; result?: unknown }, + ): Response | void { + if ( + activity instanceof Delete && + reason.type === "keyFetchError" && + typeof reason.result === "object" && reason.result != null && + "status" in reason.result && + reason.result.status === 410 + ) { + return new Response(null, { status: 202 }); + } + } + + dispatchSharedKey(_ctx: Context): { identifier: string } { + const bot = this.#firstBot(); + if (bot == null) { + throw new TypeError( + "The instance has no bots; the shared inbox key cannot be " + + "dispatched.", + ); + } + return { identifier: bot.identifier }; + } + + dispatchNodeInfo(_ctx: Context): NodeInfo { + return { + software: this.software!, + protocols: ["activitypub"], + services: { + outbound: ["atom1.0"], // TODO + }, + usage: { + users: { + total: 1, + activeMonth: 1, // FIXME + activeHalfyear: 1, // FIXME + }, + localPosts: 0, // FIXME + localComments: 0, + }, + }; + } + + dispatchEmoji( + ctx: Context, + values: { name: string }, + ): APEmoji | null { + const customEmoji = this.customEmojis[values.name]; + if (customEmoji == null) return null; + return this.getEmoji(ctx, values.name, customEmoji); + } + + getEmoji( + ctx: Context, + name: string, + data: CustomEmoji, + ): APEmoji { + let url: URL; + if ("url" in data) { + url = new URL(data.url); + } else { + // @ts-ignore: data.type satisfies keyof typeof mimeDb + const t = mimeDb[data.type]; + url = new URL( + `/emojis/${name}${ + t == null || t.extensions == null || t.extensions.length < 1 + ? "" + : `.${t.extensions[0]}` + }`, + ctx.origin, + ); + } + return new APEmoji({ + id: ctx.getObjectUri(APEmoji, { name }), + name: `:${name}:`, + icon: new Image({ + mediaType: data.type, + url, + }), + }); + } + + addCustomEmoji( + name: TEmojiName, + data: CustomEmoji, + ): DeferredCustomEmoji { + if (!name.match(/^[a-z0-9-_]+$/i)) { + throw new TypeError( + `Invalid custom emoji name: ${name}. It must match /^[a-z0-9-_]+$/i.`, + ); + } else if (name in this.customEmojis) { + throw new TypeError(`Duplicate custom emoji name: ${name}`); + } else if (!data.type.startsWith("image/")) { + throw new TypeError(`Unsupported media type: ${data.type}`); + } + this.customEmojis[name] = data; + return (session: Session) => + this.getEmoji( + session.context, + name, + data, + ); + } + + addCustomEmojis( + emojis: Readonly>, + ): Readonly>> { + const emojiMap = {} as Record< + TEmojiName, + DeferredCustomEmoji + >; + for (const name in emojis) { + emojiMap[name] = this.addCustomEmoji(name, emojis[name]); + } + return emojiMap; + } + + async addCollectionInverseProperty( + request: Request, + contextData: TContextData, + response: Response, + ): Promise { + if (!response.ok) return response; + const ctx = this.federation.createContext(request, contextData); + const parsed = ctx.parseUri(new URL(request.url)); + if ( + parsed == null || + (parsed.type !== "outbox" && parsed.type !== "followers") || + parsed.identifier == null + ) { + return response; + } + const contentType = response.headers.get("Content-Type"); + if ( + contentType == null || + ( + !contentType.startsWith("application/activity+json") && + !contentType.startsWith("application/ld+json") + ) + ) { + return response; + } + const body = await response.json(); + if (typeof body !== "object" || body == null || Array.isArray(body)) { + return new Response(JSON.stringify(body), { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + } + const property = parsed.type === "outbox" ? "outboxOf" : "followersOf"; + const actorUri = ctx.getActorUri(parsed.identifier).href; + if (body[property] === actorUri) { + return new Response(JSON.stringify(body), { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + } + const headers = new Headers(response.headers); + headers.delete("Content-Length"); + return new Response(JSON.stringify({ ...body, [property]: actorUri }), { + headers, + status: response.status, + statusText: response.statusText, + }); + } + + async fetch(request: Request, contextData: TContextData): Promise { + if (this.behindProxy) { + request = await getXForwardedRequest(request); + } + const url = new URL(request.url); + if ( + (request.method === "GET" || request.method === "HEAD") && + this.legacyObjectUrisIdentifier != null + ) { + // Dereferenceable requests to object URIs in the legacy (pre-0.5) + // format are permanently redirected to their canonical URIs: + const rewrittenPath = rewriteLegacyObjectPath( + url.pathname, + this.legacyObjectUrisIdentifier, + ); + if (rewrittenPath != null) { + const location = new URL(url.href); + location.pathname = rewrittenPath; + return new Response(null, { + status: 301, + headers: { Location: location.href }, + }); + } + } + if ( + url.pathname.startsWith("/.well-known/") || + url.pathname.startsWith("/ap/") || + url.pathname.startsWith("/nodeinfo/") + ) { + const response = await this.federation.fetch(request, { contextData }); + return await this.addCollectionInverseProperty( + request, + contextData, + response, + ); + } + const match = /^\/emojis\/([a-z0-9-_]+)(?:$|\.)/.exec(url.pathname); + if (match != null) { + const customEmoji = this.customEmojis[match[1]]; + if (customEmoji == null || !("file" in customEmoji)) { + return new Response("Not Found", { status: 404 }); + } + let file: fs.FileHandle; + try { + file = await fs.open(customEmoji.file, "r"); + } catch (error) { + if ( + typeof error === "object" && error != null && "code" in error && + error.code === "ENOENT" + ) { + return new Response("Not Found", { status: 404 }); + } + throw error; + } + const fileInfo = await file.stat(); + return new Response(file.readableWebStream(), { + headers: { + "Content-Type": customEmoji.type, + "Content-Length": fileInfo.size.toString(), + "Cache-Control": "public, max-age=31536000, immutable", + "Last-Modified": (fileInfo.mtime ?? new Date()).toUTCString(), + "ETag": `"${fileInfo.mtime?.getTime().toString(36)}${ + fileInfo.size.toString(36) + }"`, + }, + }); + } + const bot = this.#firstBot(); + if (bot == null) return new Response("Not Found", { status: 404 }); + return await app.fetch(request, { bot, contextData }); + } +} diff --git a/packages/botkit/src/instance.ts b/packages/botkit/src/instance.ts new file mode 100644 index 0000000..4b53a17 --- /dev/null +++ b/packages/botkit/src/instance.ts @@ -0,0 +1,292 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import type { + Federation, + KvStore, + MessageQueue, +} from "@fedify/fedify/federation"; +import type { Software } from "@fedify/fedify/nodeinfo"; +import type { Application, Image, Service } from "@fedify/vocab"; +import type { Bot, PagesOptions } from "./bot.ts"; +import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts"; +import { InstanceImpl } from "./instance-impl.ts"; +import type { Repository } from "./repository.ts"; +import type { Text } from "./text.ts"; + +/** + * The profile of a bot actor hosted on an {@link Instance}. It configures + * everything about a bot except its identifier and the shared infrastructure, + * which belong to the instance. + * @since 0.5.0 + */ +export interface BotProfile { + /** + * The type of the bot actor. It should be either `Service` or + * `Application`. + * + * If omitted, `Service` will be used. + * @default `Service` + */ + readonly class?: typeof Service | typeof Application; + + /** + * The username of the bot. It will be a part of the fediverse handle. + * It can be changed after the bot is federated. + */ + readonly username: string; + + /** + * The display name of the bot. It can be changed after the bot is + * federated. + */ + readonly name?: string; + + /** + * The description of the bot. It can be changed after the bot is + * federated. + */ + readonly summary?: Text<"block", TContextData>; + + /** + * The avatar URL of the bot. It can be changed after the bot is federated. + */ + readonly icon?: URL | Image; + + /** + * The header image URL of the bot. It can be changed after the bot is + * federated. + */ + readonly image?: URL | Image; + + /** + * The custom properties of the bot. It can be changed after the bot is + * federated. + */ + readonly properties?: Record>; + + /** + * How to handle incoming follow requests. Note that this behavior can + * be overridden by manually invoking {@link FollowRequest.accept} or + * {@link FollowRequest.reject} in the {@link BotEventHandlers.onFollow} + * event handler. + * + * - `"accept"` (default): Automatically accept all incoming follow + * requests. + * - `"reject"`: Automatically reject all incoming follow requests. + * - `"manual"`: Require manual handling of incoming follow requests. + * @default `"accept"` + */ + readonly followerPolicy?: "accept" | "reject" | "manual"; +} + +/** + * A server instance that can host multiple bots. An instance owns the + * shared infrastructure—the key–value store, the message queue, the + * repository, and HTTP handling—while each bot hosted on it has its own + * actor identity and event handlers. + * @since 0.5.0 + */ +export interface Instance { + /** + * An internal Fedify federation instance. Normally you don't need to + * access this directly. + */ + readonly federation: Federation; + + /** + * Creates a bot with a fixed identifier and profile, hosted on this + * instance. + * + * @example + * ```typescript + * const greetBot = instance.createBot("greet", { + * username: "greetbot", + * name: "Greeting Bot", + * }); + * + * greetBot.onFollow = async (session, followRequest) => { + * await followRequest.accept(); + * }; + * ``` + * + * @param identifier The internal identifier for the bot. Since it is used + * for the actor URI, it *should not* be changed after + * the bot is federated. + * @param profile The profile of the bot. + * @returns The created bot. + * @throws {TypeError} If a bot with the same identifier or username + * already exists on the instance. + */ + createBot( + identifier: string, + profile: BotProfile, + ): Bot; + + /** + * The fetch API for handling HTTP requests. You can pass this to an HTTP + * server (e.g., `Deno.serve()`, `Bun.serve()`) to handle incoming + * requests. + * @param request The request to handle. + * @param contextData The context data to pass to the federation. + * @returns The response to the request. + */ + fetch(request: Request, contextData: TContextData): Promise; + + /** + * Defines custom emojis for the instance. The custom emojis are shared by + * all bots hosted on the instance and are used for rendering their + * profiles and posts. + * @param emojis The custom emojis to define. The keys are the names of + * the emojis, and the values are the custom emoji + * definitions. + * @returns The defined emojis. The keys are the names of the emojis, and + * the values are the emoji objects, which are used for passing + * to the {@link customEmoji} function. + * @throws {TypeError} If any emoji name is invalid or duplicate. + */ + addCustomEmojis( + emojis: Readonly>, + ): Readonly>>; +} + +/** + * A specialized {@link Instance} type that doesn't require context data. + * @since 0.5.0 + */ +export interface InstanceWithVoidContextData extends Instance { + /** + * The fetch API for handling HTTP requests. You can pass this to an HTTP + * server (e.g., `Deno.serve()`, `Bun.serve()`) to handle incoming + * requests. + * @param request The request to handle. + * @returns The response to the request. + */ + fetch(request: Request): Promise; +} + +/** + * Options for creating an {@link Instance}. + * @since 0.5.0 + */ +export interface CreateInstanceOptions { + /** + * The underlying key–value store to use for storing data. + */ + readonly kv: KvStore; + + /** + * The repository to use for storing bot data. A single repository stores + * the data of every bot hosted on the instance, scoped by their + * identifiers. If omitted, a {@link KvRepository} backed by `kv` will be + * used. + */ + readonly repository?: Repository; + + /** + * The underlying message queue to use for handling incoming and outgoing + * activities. If omitted, incoming activities are processed immediately, + * and outgoing activities are sent immediately. + */ + readonly queue?: MessageQueue; + + /** + * The software information of the instance. If omitted, the NodeInfo + * protocol will be unimplemented. + */ + readonly software?: Software; + + /** + * Whether to trust `X-Forwarded-*` headers. If your instance is behind + * an L7 reverse proxy, turn it on. + * + * Turned off by default. + * @default `false` + */ + readonly behindProxy?: boolean; + + /** + * The options for the web pages of the instance. If omitted, the default + * options will be used. + */ + readonly pages?: PagesOptions; + + /** + * Configures the recognition of local object URIs in the legacy (pre-0.5) + * format, which did not carry the owning bot actor identifier. Set this + * when the instance hosts a bot that was deployed with BotKit 0.4 or + * earlier, so that object URIs stored by remote servers keep working. + * + * Legacy URIs can only occur in deployments that hosted a single bot + * before the upgrade, so they are attributed to the configured bot. + */ + readonly legacyObjectUris?: { + /** + * The identifier of the bot actor that owns objects with legacy URIs. + */ + readonly identifier: string; + }; +} + +/** + * Creates an {@link Instance} that can host multiple bots sharing the same + * infrastructure. + * + * @example + * ```typescript + * import { createInstance } from "@fedify/botkit"; + * import { MemoryKvStore } from "@fedify/fedify/federation"; + * + * const instance = createInstance({ kv: new MemoryKvStore() }); + * const greetBot = instance.createBot("greet", { username: "greetbot" }); + * + * export default instance; + * ``` + * + * @param options The options for creating the instance. + * @returns The created instance. + * @since 0.5.0 + */ +export function createInstance( + options: CreateInstanceOptions, +): TContextData extends void ? InstanceWithVoidContextData + : Instance { + const instance = new InstanceImpl(options); + // Since `deno serve` does not recognize a class instance having fetch(), + // we wrap an InstanceImpl instance with a plain object. + // See also https://github.com/denoland/deno/issues/24062 + const wrapper = { + impl: instance, + get federation(): Federation { + return instance.federation; + }, + createBot( + identifier: string, + profile: BotProfile, + ): Bot { + return instance.createBot(identifier, profile); + }, + fetch(request: Request, contextData: TContextData): Promise { + return instance.fetch(request, contextData); + }, + addCustomEmojis( + emojis: Readonly>, + ): Readonly>> { + return instance.addCustomEmojis(emojis); + }, + } satisfies Instance & { impl: InstanceImpl }; + // @ts-ignore: the wrapper implements InstanceWithVoidContextData + return wrapper; +} diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index b1f3de2..fb056b2 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -32,6 +32,13 @@ export { Service, type Software, } from "./bot.ts"; +export { + type BotProfile, + createInstance, + type CreateInstanceOptions, + type Instance, + type InstanceWithVoidContextData, +} from "./instance.ts"; export { type CustomEmoji, type DeferredCustomEmoji, From 3a087d56ddd76b556b4c6b1dda59e63125727172 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 22:47:41 +0900 Subject: [PATCH 09/36] Adopt legacy repository data on createBot() startup Single-bot deployments upgrading from BotKit 0.4 or earlier carry repository data that predates bot-scoped storage. The createBot() compatibility path now wraps its repository in a gate that kicks off Repository.migrate() with the bot's identifier at construction time and makes every repository operation await its completion, so the legacy data is adopted before anything reads or writes the store. Since createBot() is synchronous, the gate is what guarantees the ordering without requiring a top-level await from the user. Repositories without a migrate() implementation are unaffected, and bots created through Instance.createBot() are not gated, since a multi-bot instance is necessarily a new deployment. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/bot-impl.ts | 226 +++++++++++++++++++++++++++++++- packages/botkit/src/bot.test.ts | 43 ++++++ 2 files changed, 267 insertions(+), 2 deletions(-) diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 5efa56f..17e577c 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -88,7 +88,14 @@ import { import type { Message, MessageClass, SharedMessage } from "./message.ts"; import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; -import type { ActorScopedRepository, Uuid } from "./repository.ts"; +import { + ActorScopedRepository, + KvRepository, + type Repository, + type RepositoryGetFollowersOptions, + type RepositoryGetMessagesOptions, + type Uuid, +} from "./repository.ts"; import { parseLocalUri } from "./uri.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; @@ -190,7 +197,13 @@ export class BotImpl implements Bot { this.followerPolicy = options.followerPolicy ?? "accept"; this.instance = options.instance ?? new InstanceImpl({ kv: options.kv, - repository: options.repository, + // The single-bot deployment may carry data from BotKit 0.4 or + // earlier; adopt it for this bot before the first repository + // operation: + repository: new MigrationGatedRepository( + options.repository ?? new KvRepository(options.kv), + this.identifier, + ), queue: options.queue, software: options.software, behindProxy: options.behindProxy, @@ -1201,3 +1214,212 @@ export function wrapBotImpl( } satisfies Bot & { impl: BotImpl }; return wrapper; } + +/** + * A repository decorator that adopts legacy (pre-0.5) data for a bot actor + * before the first repository operation. The migration is kicked off at + * construction time and every operation awaits its completion, so data + * stored by BotKit 0.4 or earlier is visible from the start. + * @internal + */ +export class MigrationGatedRepository implements Repository { + readonly #repository: Repository; + readonly #migration: Promise; + + constructor(repository: Repository, identifier: string) { + this.#repository = repository; + this.#migration = repository.migrate?.(identifier) ?? Promise.resolve(); + // The rejection is re-thrown by the first awaiting operation; this + // no-op handler only prevents an unhandled rejection warning: + this.#migration.catch(() => {}); + } + + async setKeyPairs( + identifier: string, + keyPairs: CryptoKeyPair[], + ): Promise { + await this.#migration; + return await this.#repository.setKeyPairs(identifier, keyPairs); + } + + async getKeyPairs(identifier: string): Promise { + await this.#migration; + return await this.#repository.getKeyPairs(identifier); + } + + async addMessage( + identifier: string, + id: Uuid, + activity: Create | Announce, + ): Promise { + await this.#migration; + return await this.#repository.addMessage(identifier, id, activity); + } + + async updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => Create | Announce | undefined | Promise, + ): Promise { + await this.#migration; + return await this.#repository.updateMessage(identifier, id, updater); + } + + async removeMessage( + identifier: string, + id: Uuid, + ): Promise { + await this.#migration; + return await this.#repository.removeMessage(identifier, id); + } + + async *getMessages( + identifier: string, + options?: RepositoryGetMessagesOptions, + ): AsyncIterable { + await this.#migration; + yield* this.#repository.getMessages(identifier, options); + } + + async getMessage( + identifier: string, + id: Uuid, + ): Promise { + await this.#migration; + return await this.#repository.getMessage(identifier, id); + } + + async countMessages(identifier: string): Promise { + await this.#migration; + return await this.#repository.countMessages(identifier); + } + + async addFollower( + identifier: string, + followId: URL, + follower: Actor, + ): Promise { + await this.#migration; + return await this.#repository.addFollower(identifier, followId, follower); + } + + async removeFollower( + identifier: string, + followId: URL, + followerId: URL, + ): Promise { + await this.#migration; + return await this.#repository.removeFollower( + identifier, + followId, + followerId, + ); + } + + async hasFollower(identifier: string, followerId: URL): Promise { + await this.#migration; + return await this.#repository.hasFollower(identifier, followerId); + } + + async *getFollowers( + identifier: string, + options?: RepositoryGetFollowersOptions, + ): AsyncIterable { + await this.#migration; + yield* this.#repository.getFollowers(identifier, options); + } + + async countFollowers(identifier: string): Promise { + await this.#migration; + return await this.#repository.countFollowers(identifier); + } + + async addSentFollow( + identifier: string, + id: Uuid, + follow: Follow, + ): Promise { + await this.#migration; + return await this.#repository.addSentFollow(identifier, id, follow); + } + + async removeSentFollow( + identifier: string, + id: Uuid, + ): Promise { + await this.#migration; + return await this.#repository.removeSentFollow(identifier, id); + } + + async getSentFollow( + identifier: string, + id: Uuid, + ): Promise { + await this.#migration; + return await this.#repository.getSentFollow(identifier, id); + } + + async addFollowee( + identifier: string, + followeeId: URL, + follow: Follow, + ): Promise { + await this.#migration; + return await this.#repository.addFollowee(identifier, followeeId, follow); + } + + async removeFollowee( + identifier: string, + followeeId: URL, + ): Promise { + await this.#migration; + return await this.#repository.removeFollowee(identifier, followeeId); + } + + async getFollowee( + identifier: string, + followeeId: URL, + ): Promise { + await this.#migration; + return await this.#repository.getFollowee(identifier, followeeId); + } + + async *findFollowedBots(followeeId: URL): AsyncIterable { + await this.#migration; + yield* this.#repository.findFollowedBots(followeeId); + } + + async vote( + identifier: string, + messageId: Uuid, + voterId: URL, + option: string, + ): Promise { + await this.#migration; + return await this.#repository.vote(identifier, messageId, voterId, option); + } + + async countVoters(identifier: string, messageId: Uuid): Promise { + await this.#migration; + return await this.#repository.countVoters(identifier, messageId); + } + + async countVotes( + identifier: string, + messageId: Uuid, + ): Promise>> { + await this.#migration; + return await this.#repository.countVotes(identifier, messageId); + } + + forIdentifier(identifier: string): ActorScopedRepository { + return new ActorScopedRepository(this, identifier); + } + + async migrate(identifier: string): Promise { + await this.#migration; + await this.#repository.migrate?.(identifier); + } +} diff --git a/packages/botkit/src/bot.test.ts b/packages/botkit/src/bot.test.ts index c09f892..76a811a 100644 --- a/packages/botkit/src/bot.test.ts +++ b/packages/botkit/src/bot.test.ts @@ -18,6 +18,7 @@ import type { Actor } from "@fedify/vocab"; import assert from "node:assert"; import { test } from "node:test"; import type { BotImpl } from "./bot-impl.ts"; +import { MemoryRepository } from "./repository.ts"; import { createBot, type ReadonlyBot } from "./bot.ts"; import type { FollowRequest } from "./follow.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; @@ -137,3 +138,45 @@ test("Session.bot is a ReadonlyBot", () => { // @ts-expect-error: ReadonlyBot does not expose the repository. session.bot.repository; }); + +test("createBot() adopts legacy repository data", async () => { + const kv = new MemoryKvStore(); + // Simulates the unscoped key layout of BotKit 0.4 and earlier: + const messageId = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + await kv.set(["_botkit", "messages"], [messageId]); + await kv.set(["_botkit", "messages", messageId], { + "@context": "https://www.w3.org/ns/activitystreams", + type: "Create", + id: `https://example.com/ap/create/${messageId}`, + actor: "https://example.com/ap/actor/bot", + object: { + type: "Note", + id: `https://example.com/ap/note/${messageId}`, + content: "Hello, world!", + }, + }); + const bot = createBot({ kv, username: "bot" }); + const { impl } = bot as unknown as { impl: BotImpl }; + // The legacy message is adopted before the first repository operation: + assert.deepStrictEqual(await impl.repository.countMessages(), 1); +}); + +test("createBot() runs Repository.migrate() once", async () => { + class MigratingMemoryRepository extends MemoryRepository { + migrated: string[] = []; + migrate(identifier: string): Promise { + this.migrated.push(identifier); + return Promise.resolve(); + } + } + const repository = new MigratingMemoryRepository(); + const bot = createBot({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const { impl } = bot as unknown as { impl: BotImpl }; + await impl.repository.countMessages(); + await impl.repository.countFollowers(); + assert.deepStrictEqual(repository.migrated, ["bot"]); +}); From 6226cf8a442a4258caf2f08767b77241e5a61c03 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 22:52:45 +0900 Subject: [PATCH 10/36] Host multiple static bots with an instance actor An instance can now host several bots, each with its own actor URI, WebFinger handle, collections, and isolated repository data; one bot's URI path never serves another bot's objects. Since a multi-bot instance has no single obvious actor whose key should sign shared-inbox related requests, instances created through createInstance() expose an instance actor under the reserved "_instance" identifier: a non-discoverable Application whose key pairs are generated lazily, similar to Mastodon's instance actor. Bots cannot take the reserved identifier. Instances created through the single-bot createBot() compatibility path keep the pre-0.5 behavior: the sole bot's key signs shared-inbox requests and no instance actor is exposed, so nothing new appears on existing deployments. NodeInfo usage statistics now report the number of hosted bots instead of a hardcoded single user. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/bot-impl.ts | 1 + packages/botkit/src/instance-impl.ts | 115 +++++++++++-- packages/botkit/src/instance-multi.test.ts | 187 +++++++++++++++++++++ packages/botkit/src/instance.ts | 1 + packages/botkit/src/mod.ts | 1 + 5 files changed, 290 insertions(+), 15 deletions(-) create mode 100644 packages/botkit/src/instance-multi.test.ts diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 17e577c..3c4e95e 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -212,6 +212,7 @@ export class BotImpl implements Bot { // A dedicated instance hosts the single bot that predates the // multi-bot upgrade, so legacy object URIs belong to it: legacyObjectUris: { identifier: this.identifier }, + compatMode: true, }); this.repository = this.instance.repository.forIdentifier(this.identifier); this.instance.addBot(this); diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index 3784062..2611245 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -17,6 +17,7 @@ import { type Context, createFederation, type Federation, + generateCryptoKeyPair, type KvStore, type MessageQueue, type NodeInfo, @@ -25,12 +26,15 @@ import { import { Accept, type Activity, + type Actor, Announce, + Application, Article, ChatMessage, Create, Delete, Emoji as APEmoji, + Endpoints, Follow, Image, Like as RawLike, @@ -58,12 +62,29 @@ import { KvRepository, type Repository } from "./repository.ts"; import type { Session } from "./session.ts"; import { rewriteLegacyObjectPath } from "./uri.ts"; +/** + * The reserved identifier of the instance actor: an internal + * `Application` actor that an {@link Instance} uses for signing + * shared-inbox related requests on behalf of the whole instance. + * Bots cannot take this identifier. + * @since 0.5.0 + */ +export const INSTANCE_ACTOR_IDENTIFIER = "_instance"; + /** * Options for creating an {@link InstanceImpl}. * @internal */ export interface InstanceImplOptions extends CreateInstanceOptions { collectionWindow?: number; + + /** + * Whether the instance was created through the single-bot + * `createBot()` compatibility path. A compatible instance keeps + * the pre-0.5 behavior: the sole bot's key signs shared-inbox + * requests and no instance actor is exposed. + */ + compatMode?: boolean; } /** @@ -94,6 +115,12 @@ export class InstanceImpl */ readonly legacyObjectUrisIdentifier?: string; + /** + * Whether the instance was created through the single-bot + * `createBot()` compatibility path. + */ + readonly compatMode: boolean; + readonly #bots: Map> = new Map(); constructor(options: InstanceImplOptions) { @@ -109,6 +136,7 @@ export class InstanceImpl }; this.collectionWindow = options.collectionWindow ?? 50; this.legacyObjectUrisIdentifier = options.legacyObjectUris?.identifier; + this.compatMode = options.compatMode ?? false; this.federation = createFederation({ kv: options.kv, queue: options.queue, @@ -123,13 +151,22 @@ export class InstanceImpl this.federation .setActorDispatcher( "/ap/actor/{identifier}", - (ctx, identifier) => - this.getBot(identifier)?.dispatchActor(ctx, identifier) ?? null, + (ctx, identifier) => { + if (!this.compatMode && identifier === INSTANCE_ACTOR_IDENTIFIER) { + return this.#dispatchInstanceActor(ctx); + } + return this.getBot(identifier)?.dispatchActor(ctx, identifier) ?? + null; + }, ) .mapHandle((ctx, username) => this.mapHandle(ctx, username)) - .setKeyPairsDispatcher((ctx, identifier) => - this.getBot(identifier)?.dispatchActorKeyPairs(ctx, identifier) ?? [] - ); + .setKeyPairsDispatcher((ctx, identifier) => { + if (!this.compatMode && identifier === INSTANCE_ACTOR_IDENTIFIER) { + return this.#dispatchInstanceActorKeyPairs(); + } + return this.getBot(identifier) + ?.dispatchActorKeyPairs(ctx, identifier) ?? []; + }); this.federation .setFollowersDispatcher( "/ap/actor/{identifier}/followers", @@ -282,6 +319,11 @@ export class InstanceImpl * already exists on the instance. */ addBot(bot: BotImpl): void { + if (!this.compatMode && bot.identifier === INSTANCE_ACTOR_IDENTIFIER) { + throw new TypeError( + `The identifier is reserved for the instance actor: ${bot.identifier}`, + ); + } if (this.#bots.has(bot.identifier)) { throw new TypeError( `A bot with the identifier already exists: ${bot.identifier}`, @@ -313,6 +355,13 @@ export class InstanceImpl return this.#bots.values(); } + /** + * The number of bots hosted on the instance. + */ + get botCount(): number { + return this.#bots.size; + } + #firstBot(): BotImpl | undefined { return this.#bots.values().next().value; } @@ -364,14 +413,50 @@ export class InstanceImpl } dispatchSharedKey(_ctx: Context): { identifier: string } { - const bot = this.#firstBot(); - if (bot == null) { - throw new TypeError( - "The instance has no bots; the shared inbox key cannot be " + - "dispatched.", - ); + if (this.compatMode) { + const bot = this.#firstBot(); + if (bot == null) { + throw new TypeError( + "The instance has no bots; the shared inbox key cannot be " + + "dispatched.", + ); + } + return { identifier: bot.identifier }; + } + return { identifier: INSTANCE_ACTOR_IDENTIFIER }; + } + + async #dispatchInstanceActor( + ctx: Context, + ): Promise { + const keyPairs = await ctx.getActorKeyPairs(INSTANCE_ACTOR_IDENTIFIER); + return new Application({ + id: ctx.getActorUri(INSTANCE_ACTOR_IDENTIFIER), + preferredUsername: INSTANCE_ACTOR_IDENTIFIER, + name: "Instance actor", + summary: "An internal actor the instance uses for signing requests " + + "on behalf of the whole instance.", + inbox: ctx.getInboxUri(INSTANCE_ACTOR_IDENTIFIER), + endpoints: new Endpoints({ + sharedInbox: ctx.getInboxUri(), + }), + publicKey: keyPairs[0].cryptographicKey, + assertionMethods: keyPairs.map((pair) => pair.multikey), + discoverable: false, + }); + } + + async #dispatchInstanceActorKeyPairs(): Promise { + let keyPairs = await this.repository.getKeyPairs( + INSTANCE_ACTOR_IDENTIFIER, + ); + if (keyPairs == null) { + const rsa = await generateCryptoKeyPair("RSASSA-PKCS1-v1_5"); + const ed25519 = await generateCryptoKeyPair("Ed25519"); + keyPairs = [rsa, ed25519]; + await this.repository.setKeyPairs(INSTANCE_ACTOR_IDENTIFIER, keyPairs); } - return { identifier: bot.identifier }; + return keyPairs; } dispatchNodeInfo(_ctx: Context): NodeInfo { @@ -383,9 +468,9 @@ export class InstanceImpl }, usage: { users: { - total: 1, - activeMonth: 1, // FIXME - activeHalfyear: 1, // FIXME + total: this.botCount, + activeMonth: this.botCount, // FIXME + activeHalfyear: this.botCount, // FIXME }, localPosts: 0, // FIXME localComments: 0, diff --git a/packages/botkit/src/instance-multi.test.ts b/packages/botkit/src/instance-multi.test.ts new file mode 100644 index 0000000..a57056a --- /dev/null +++ b/packages/botkit/src/instance-multi.test.ts @@ -0,0 +1,187 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { MemoryKvStore } from "@fedify/fedify/federation"; +import { Create, Note, PUBLIC_COLLECTION } from "@fedify/vocab"; +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { + createInstance, + INSTANCE_ACTOR_IDENTIFIER, + type InstanceWithVoidContextData, +} from "./instance.ts"; +import { MemoryRepository, type Uuid } from "./repository.ts"; + +function fetchJson( + instance: InstanceWithVoidContextData, + url: string, + // deno-lint-ignore no-explicit-any +): Promise { + return instance.fetch( + new Request(url, { headers: { Accept: "application/activity+json" } }), + ).then(async (response) => { + assert.deepStrictEqual(response.status, 200, `GET ${url}`); + return await response.json(); + }); +} + +describe("multiple static bots", () => { + test("serves distinct actors and collections", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("alpha", { username: "alphabot", name: "Alpha" }); + instance.createBot("beta", { username: "betabot", name: "Beta" }); + + const alpha = await fetchJson( + instance, + "https://example.com/ap/actor/alpha", + ); + const beta = await fetchJson(instance, "https://example.com/ap/actor/beta"); + assert.deepStrictEqual(alpha.preferredUsername, "alphabot"); + assert.deepStrictEqual(beta.preferredUsername, "betabot"); + assert.notDeepStrictEqual(alpha.id, beta.id); + assert.notDeepStrictEqual(alpha.followers, beta.followers); + assert.notDeepStrictEqual(alpha.outbox, beta.outbox); + }); + + test("resolves each username through WebFinger", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("alpha", { username: "alphabot" }); + instance.createBot("beta", { username: "betabot" }); + + for ( + const [username, identifier] of [ + ["alphabot", "alpha"], + ["betabot", "beta"], + ] + ) { + const response = await instance.fetch( + new Request( + `https://example.com/.well-known/webfinger?resource=acct:${username}@example.com`, + ), + ); + assert.deepStrictEqual(response.status, 200); + const jrd = await response.json(); + const self = jrd.links.find((link: { rel: string }) => + link.rel === "self" + ); + assert.deepStrictEqual( + self.href, + `https://example.com/ap/actor/${identifier}`, + ); + } + }); + + test("does not serve one bot's objects under another bot's path", async () => { + const repository = new MemoryRepository(); + const instance = createInstance({ + kv: new MemoryKvStore(), + repository, + }); + instance.createBot("alpha", { username: "alphabot" }); + instance.createBot("beta", { username: "betabot" }); + + const messageId: Uuid = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + await repository.addMessage( + "alpha", + messageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/alpha/create/${messageId}`, + ), + actor: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL(`https://example.com/ap/actor/alpha/note/${messageId}`), + attribution: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + content: "Hello from alpha!", + }), + }), + ); + + const own = await instance.fetch( + new Request( + `https://example.com/ap/actor/alpha/note/${messageId}`, + { headers: { Accept: "application/activity+json" } }, + ), + ); + assert.deepStrictEqual(own.status, 200); + + // The same UUID under beta's path must not leak alpha's message: + const cross = await instance.fetch( + new Request( + `https://example.com/ap/actor/beta/note/${messageId}`, + { headers: { Accept: "application/activity+json" } }, + ), + ); + assert.deepStrictEqual(cross.status, 404); + }); +}); + +describe("instance actor", () => { + test("is served under the reserved identifier", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("alpha", { username: "alphabot" }); + const actor = await fetchJson( + instance, + `https://example.com/ap/actor/${INSTANCE_ACTOR_IDENTIFIER}`, + ); + assert.deepStrictEqual(actor.type, "Application"); + assert.ok(actor.publicKey != null); + }); + + test("signs the shared inbox on multi-bot instances", () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + instance.createBot("alpha", { username: "alphabot" }); + const impl = (instance as unknown as { + impl: { + dispatchSharedKey(ctx: unknown): { identifier: string }; + federation: { + createContext(url: URL): unknown; + }; + }; + }).impl; + const ctx = impl.federation.createContext(new URL("https://example.com/")); + assert.deepStrictEqual(impl.dispatchSharedKey(ctx), { + identifier: INSTANCE_ACTOR_IDENTIFIER, + }); + }); + + test("cannot be taken by a bot", () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + assert.throws( + () => + instance.createBot(INSTANCE_ACTOR_IDENTIFIER, { username: "sneaky" }), + TypeError, + ); + }); +}); + +describe("NodeInfo on multi-bot instances", () => { + test("counts the hosted bots", async () => { + const instance = createInstance({ + kv: new MemoryKvStore(), + software: { name: "test-bot", version: "1.0.0" }, + }); + instance.createBot("alpha", { username: "alphabot" }); + instance.createBot("beta", { username: "betabot" }); + const response = await instance.fetch( + new Request("https://example.com/nodeinfo/2.1"), + ); + assert.deepStrictEqual(response.status, 200); + const nodeInfo = await response.json(); + assert.deepStrictEqual(nodeInfo.usage.users.total, 2); + }); +}); diff --git a/packages/botkit/src/instance.ts b/packages/botkit/src/instance.ts index 4b53a17..97e573f 100644 --- a/packages/botkit/src/instance.ts +++ b/packages/botkit/src/instance.ts @@ -23,6 +23,7 @@ import type { Application, Image, Service } from "@fedify/vocab"; import type { Bot, PagesOptions } from "./bot.ts"; import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts"; import { InstanceImpl } from "./instance-impl.ts"; +export { INSTANCE_ACTOR_IDENTIFIER } from "./instance-impl.ts"; import type { Repository } from "./repository.ts"; import type { Text } from "./text.ts"; diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index fb056b2..7904560 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -37,6 +37,7 @@ export { createInstance, type CreateInstanceOptions, type Instance, + INSTANCE_ACTOR_IDENTIFIER, type InstanceWithVoidContextData, } from "./instance.ts"; export { From 8c951e2e91cde7db84bfe4a07ee19915b190f0be Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 23:04:26 +0900 Subject: [PATCH 11/36] Route shared-inbox activities to relevant bots Incoming activities delivered to the shared inbox used to be handed to every bot hosted on the instance, which meant every bot's onMessage would fire for every incoming message once multiple bots share an instance. The instance now routes each activity to the bots it is actually relevant to: - Follow and Undo(Follow) route by the target actor URI. - Accept, Reject, Like, and Undo(Like) route by the owning bot identifier carried in the local object URI (legacy-format URIs are still recognized). Activities on objects the instance does not own cannot be attributed to any bot and are dropped with a debug log. - Create routes to the union of bots following the author, bots addressed in to/cc (on the activity or on the embedded object, including their followers collections), mentioned bots, and the owners of the reply and quote targets. - Announce routes to followers of the sharer, addressed bots, and the owner of the shared message. Timeline routing relies on the new Repository.findFollowedBots() reverse lookup, so bots resolved dynamically later are covered without enumerating them. Personal-inbox deliveries go to their recipient only. Instances created through the single-bot createBot() compatibility path keep delivering everything to their sole bot, which preserves the pre-0.5 behavior, including likes of objects with foreign URIs. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/instance-impl.ts | 285 +++++++++-- packages/botkit/src/instance-routing.test.ts | 487 +++++++++++++++++++ 2 files changed, 723 insertions(+), 49 deletions(-) create mode 100644 packages/botkit/src/instance-routing.test.ts diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index 2611245..0db3161 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -18,6 +18,7 @@ import { createFederation, type Federation, generateCryptoKeyPair, + type InboxContext, type KvStore, type MessageQueue, type NodeInfo, @@ -38,6 +39,8 @@ import { Follow, Image, Like as RawLike, + Link, + Mention, Note, Question, Reject, @@ -57,10 +60,11 @@ import type { CreateInstanceOptions, Instance, } from "./instance.ts"; +import { isMessageObject, isQuoteLink } from "./message-impl.ts"; import { app } from "./pages.tsx"; import { KvRepository, type Repository } from "./repository.ts"; import type { Session } from "./session.ts"; -import { rewriteLegacyObjectPath } from "./uri.ts"; +import { parseLocalUri, rewriteLegacyObjectPath } from "./uri.ts"; /** * The reserved identifier of the instance actor: an internal @@ -254,54 +258,13 @@ export class InstanceImpl .onUnverifiedActivity((ctx, activity, reason) => this.onUnverifiedActivity(ctx, activity, reason) ) - .on(Follow, async (ctx, follow) => { - for (const bot of this.#bots.values()) { - await bot.onFollowed(ctx, follow); - } - }) - .on(Undo, async (ctx, undo) => { - const object = await undo.getObject(ctx); - if (object instanceof Follow) { - for (const bot of this.#bots.values()) { - await bot.onUnfollowed(ctx, undo); - } - } else if (object instanceof RawLike) { - for (const bot of this.#bots.values()) { - await bot.onUnliked(ctx, undo); - } - } else { - const logger = getLogger(["botkit", "bot", "inbox"]); - logger.warn( - "The Undo object {undoId} is not about Follow or Like: {object}.", - { undoId: undo.id?.href, object }, - ); - } - }) - .on(Accept, async (ctx, accept) => { - for (const bot of this.#bots.values()) { - await bot.onFollowAccepted(ctx, accept); - } - }) - .on(Reject, async (ctx, reject) => { - for (const bot of this.#bots.values()) { - await bot.onFollowRejected(ctx, reject); - } - }) - .on(Create, async (ctx, create) => { - for (const bot of this.#bots.values()) { - await bot.onCreated(ctx, create); - } - }) - .on(Announce, async (ctx, announce) => { - for (const bot of this.#bots.values()) { - await bot.onAnnounced(ctx, announce); - } - }) - .on(RawLike, async (ctx, like) => { - for (const bot of this.#bots.values()) { - await bot.onLiked(ctx, like); - } - }) + .on(Follow, (ctx, follow) => this.onFollowed(ctx, follow)) + .on(Undo, (ctx, undo) => this.onUndone(ctx, undo)) + .on(Accept, (ctx, accept) => this.onFollowAccepted(ctx, accept)) + .on(Reject, (ctx, reject) => this.onFollowRejected(ctx, reject)) + .on(Create, (ctx, create) => this.onCreated(ctx, create)) + .on(Announce, (ctx, announce) => this.onAnnounced(ctx, announce)) + .on(RawLike, (ctx, like) => this.onLiked(ctx, like)) .setSharedKeyDispatcher((ctx) => this.dispatchSharedKey(ctx)); if (this.software != null) { this.federation.setNodeInfoDispatcher( @@ -412,6 +375,230 @@ export class InstanceImpl } } + /** + * Resolves which bots an incoming shared-inbox activity is relevant to. + * On a compatible (single-bot) instance every hosted bot is returned, + * preserving the pre-0.5 behavior. A personal-inbox delivery targets + * its recipient only. Otherwise the given resolver computes the + * relevant bot identifiers. + */ + async #resolveTargets( + ctx: InboxContext, + resolve: () => + | Promise> + | Iterable, + ): Promise[]> { + if (this.compatMode) return [...this.#bots.values()]; + if (ctx.recipient != null) { + const bot = this.getBot(ctx.recipient); + return bot == null ? [] : [bot]; + } + const identifiers = new Set(await resolve()); + const bots: BotImpl[] = []; + for (const identifier of identifiers) { + const bot = this.getBot(identifier); + if (bot != null) bots.push(bot); + } + return bots; + } + + /** + * Attributes a local object URI to its owning bot identifier, or logs and + * yields nothing when the URI is not a local object. Activities on + * objects the instance does not own cannot be attributed to any bot on + * a multi-bot instance, so they are dropped. + */ + #localObjectTarget( + ctx: InboxContext, + uri: URL | null, + ): Iterable { + const parsed = parseLocalUri(ctx, uri, this.legacyObjectUrisIdentifier); + if ( + parsed?.type === "object" && + typeof parsed.values.identifier === "string" + ) { + return [parsed.values.identifier]; + } + const logger = getLogger(["botkit", "instance", "inbox"]); + logger.debug( + "The object {uri} is not owned by any bot on this instance; " + + "the activity is not routed.", + { uri: uri?.href }, + ); + return []; + } + + async onFollowed( + ctx: InboxContext, + follow: Follow, + ): Promise { + const bots = await this.#resolveTargets(ctx, () => { + const parsed = ctx.parseUri(follow.objectId); + return parsed?.type === "actor" ? [parsed.identifier] : []; + }); + for (const bot of bots) await bot.onFollowed(ctx, follow); + } + + async onUndone( + ctx: InboxContext, + undo: Undo, + ): Promise { + const object = await undo.getObject(ctx); + if (object instanceof Follow) { + const bots = await this.#resolveTargets(ctx, () => { + const parsed = ctx.parseUri(object.objectId); + return parsed?.type === "actor" ? [parsed.identifier] : []; + }); + for (const bot of bots) await bot.onUnfollowed(ctx, undo); + } else if (object instanceof RawLike) { + const bots = await this.#resolveTargets( + ctx, + () => this.#localObjectTarget(ctx, object.objectId), + ); + for (const bot of bots) await bot.onUnliked(ctx, undo); + } else { + const logger = getLogger(["botkit", "bot", "inbox"]); + logger.warn( + "The Undo object {undoId} is not about Follow or Like: {object}.", + { undoId: undo.id?.href, object }, + ); + } + } + + async onFollowAccepted( + ctx: InboxContext, + accept: Accept, + ): Promise { + const bots = await this.#resolveTargets( + ctx, + () => this.#localObjectTarget(ctx, accept.objectId), + ); + for (const bot of bots) await bot.onFollowAccepted(ctx, accept); + } + + async onFollowRejected( + ctx: InboxContext, + reject: Reject, + ): Promise { + const bots = await this.#resolveTargets( + ctx, + () => this.#localObjectTarget(ctx, reject.objectId), + ); + for (const bot of bots) await bot.onFollowRejected(ctx, reject); + } + + async onLiked( + ctx: InboxContext, + like: RawLike, + ): Promise { + const bots = await this.#resolveTargets( + ctx, + () => this.#localObjectTarget(ctx, like.objectId), + ); + for (const bot of bots) await bot.onLiked(ctx, like); + } + + async onCreated( + ctx: InboxContext, + create: Create, + ): Promise { + const bots = await this.#resolveTargets(ctx, async () => { + const targets = new Set(); + // Bots following the author see the message on their timeline: + if (create.actorId != null) { + for await ( + const identifier of this.repository.findFollowedBots(create.actorId) + ) { + targets.add(identifier); + } + } + // Bots addressed directly, or whose followers collection is + // addressed (either on the activity or on the embedded object): + const addAddressee = (uri: URL) => { + const parsed = ctx.parseUri(uri); + if (parsed?.type === "actor" || parsed?.type === "followers") { + if (parsed.identifier != null) targets.add(parsed.identifier); + } + }; + for (const uri of [...create.toIds, ...create.ccIds]) { + addAddressee(uri); + } + const addLocalObject = (uri: URL | null) => { + const parsed = parseLocalUri( + ctx, + uri, + this.legacyObjectUrisIdentifier, + ); + if ( + parsed?.type === "object" && + typeof parsed.values.identifier === "string" + ) { + targets.add(parsed.values.identifier); + } + }; + const object = await create.getObject(ctx); + if (isMessageObject(object)) { + for (const uri of [...object.toIds, ...object.ccIds]) { + addAddressee(uri); + } + // Bots mentioned in or quoted by the message: + for await (const tag of object.getTags(ctx)) { + if (tag instanceof Mention && tag.href != null) { + const parsed = ctx.parseUri(tag.href); + if (parsed?.type === "actor") targets.add(parsed.identifier); + } else if (tag instanceof Link && isQuoteLink(tag)) { + addLocalObject(tag.href); + } + } + addLocalObject(object.quoteUrl); + // Bots whose message is replied to: + addLocalObject(object.replyTargetId); + } + return targets; + }); + for (const bot of bots) await bot.onCreated(ctx, create); + } + + async onAnnounced( + ctx: InboxContext, + announce: Announce, + ): Promise { + const bots = await this.#resolveTargets(ctx, async () => { + const targets = new Set(); + // Bots following the sharer see the share on their timeline: + if (announce.actorId != null) { + for await ( + const identifier of this.repository.findFollowedBots( + announce.actorId, + ) + ) { + targets.add(identifier); + } + } + // Bots addressed directly, or whose followers collection is + // addressed, or whose message is shared: + for (const uri of [...announce.toIds, ...announce.ccIds]) { + const parsed = ctx.parseUri(uri); + if (parsed?.type === "actor" || parsed?.type === "followers") { + if (parsed.identifier != null) targets.add(parsed.identifier); + } + } + const parsedObject = parseLocalUri( + ctx, + announce.objectId, + this.legacyObjectUrisIdentifier, + ); + if ( + parsedObject?.type === "object" && + typeof parsedObject.values.identifier === "string" + ) { + targets.add(parsedObject.values.identifier); + } + return targets; + }); + for (const bot of bots) await bot.onAnnounced(ctx, announce); + } + dispatchSharedKey(_ctx: Context): { identifier: string } { if (this.compatMode) { const bot = this.#firstBot(); diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts new file mode 100644 index 0000000..ec07f7f --- /dev/null +++ b/packages/botkit/src/instance-routing.test.ts @@ -0,0 +1,487 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { type InboxContext, MemoryKvStore } from "@fedify/fedify/federation"; +import { + Accept, + Create, + Follow, + Like as RawLike, + Mention, + Note, + Person, + PUBLIC_COLLECTION, + Undo, +} from "@fedify/vocab"; +import assert from "node:assert"; +import { describe, test } from "node:test"; +import type { Bot } from "./bot.ts"; +import { BotImpl } from "./bot-impl.ts"; +import { InstanceImpl } from "./instance-impl.ts"; +import { MemoryRepository, type Uuid } from "./repository.ts"; + +function createMockInboxContext( + instance: InstanceImpl, + origin: string | URL, + contextData: TContextData, + recipient?: string | null, +): InboxContext { + const ctx = instance.federation.createContext( + new URL(origin), + contextData, + ) as InboxContext; + Object.defineProperty(ctx, "recipient", { + value: recipient ?? null, + writable: true, + configurable: true, + }); + Object.defineProperty(ctx, "sendActivity", { + value: () => Promise.resolve(), + writable: true, + configurable: true, + }); + Object.defineProperty(ctx, "forwardActivity", { + value: () => Promise.resolve(), + writable: true, + configurable: true, + }); + return ctx; +} + +interface Harness { + readonly instance: InstanceImpl; + readonly repository: MemoryRepository; + readonly alpha: Bot; + readonly beta: Bot; + readonly ctx: InboxContext; +} + +function createHarness(): Harness { + const repository = new MemoryRepository(); + const instance = new InstanceImpl({ + kv: new MemoryKvStore(), + repository, + }); + const alpha = instance.createBot("alpha", { username: "alphabot" }); + const beta = instance.createBot("beta", { username: "betabot" }); + const ctx = createMockInboxContext( + instance, + "https://example.com/", + undefined, + ); + return { instance, repository, alpha, beta, ctx }; +} + +function remotePerson(handle: string): Person { + return new Person({ + id: new URL(`https://example.com/ap/actor/${handle}`), + preferredUsername: handle, + }); +} + +describe("shared inbox routing", () => { + test("routes Follow to the followed bot only", async () => { + const { instance, alpha, beta, ctx } = createHarness(); + const followed: string[] = []; + alpha.onFollow = (session) => void (followed.push(session.bot.identifier)); + beta.onFollow = (session) => void (followed.push(session.bot.identifier)); + await instance.onFollowed( + ctx, + new Follow({ + id: new URL("https://remote.example/follows/1"), + actor: remotePerson("john"), + object: new URL("https://example.com/ap/actor/alpha"), + }), + ); + assert.deepStrictEqual(followed, ["alpha"]); + }); + + test("routes Accept to the bot that sent the follow", async () => { + const { instance, repository, alpha, beta, ctx } = createHarness(); + const accepted: string[] = []; + alpha.onAcceptFollow = (session) => + void (accepted.push(session.bot.identifier)); + beta.onAcceptFollow = (session) => + void (accepted.push(session.bot.identifier)); + const followId: Uuid = "9d952a10-77e6-46bd-a48a-208b47e5e2bb"; + await repository.addSentFollow( + "alpha", + followId, + new Follow({ + id: new URL( + `https://example.com/ap/actor/alpha/follow/${followId}`, + ), + actor: new URL("https://example.com/ap/actor/alpha"), + object: remotePerson("john"), + }), + ); + await instance.onFollowAccepted( + ctx, + new Accept({ + actor: new URL("https://example.com/ap/actor/john"), + object: new URL( + `https://example.com/ap/actor/alpha/follow/${followId}`, + ), + }), + ); + assert.deepStrictEqual(accepted, ["alpha"]); + }); + + test("routes Like to the bot owning the message", async () => { + const { instance, repository, alpha, beta, ctx } = createHarness(); + const liked: string[] = []; + alpha.onLike = (session) => void (liked.push(session.bot.identifier)); + beta.onLike = (session) => void (liked.push(session.bot.identifier)); + const messageId: Uuid = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + await repository.addMessage( + "alpha", + messageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/alpha/create/${messageId}`, + ), + actor: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL( + `https://example.com/ap/actor/alpha/note/${messageId}`, + ), + attribution: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + content: "Hello!", + }), + }), + ); + await instance.onLiked( + ctx, + new RawLike({ + id: new URL("https://remote.example/likes/1"), + actor: remotePerson("john"), + object: new URL( + `https://example.com/ap/actor/alpha/note/${messageId}`, + ), + }), + ); + assert.deepStrictEqual(liked, ["alpha"]); + }); + + test("drops Like on objects the instance does not own", async () => { + const { instance, alpha, beta, ctx } = createHarness(); + const liked: string[] = []; + alpha.onLike = (session) => void (liked.push(session.bot.identifier)); + beta.onLike = (session) => void (liked.push(session.bot.identifier)); + await instance.onLiked( + ctx, + new RawLike({ + id: new URL("https://remote.example/likes/2"), + actor: remotePerson("john"), + object: new URL("https://remote.example/notes/1"), + }), + ); + assert.deepStrictEqual(liked, []); + }); + + test("routes Create replies to the replied bot", async () => { + const { instance, repository, alpha, beta, ctx } = createHarness(); + const events: string[] = []; + alpha.onReply = (session) => + void (events.push(`reply:${session.bot.identifier}`)); + beta.onReply = (session) => + void (events.push(`reply:${session.bot.identifier}`)); + beta.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + + const messageId: Uuid = "01941f29-7c00-7fe8-ab0a-7b593990a3c0"; + await repository.addMessage( + "alpha", + messageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/alpha/create/${messageId}`, + ), + actor: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL( + `https://example.com/ap/actor/alpha/note/${messageId}`, + ), + attribution: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + content: "Original post", + }), + }), + ); + + const author = remotePerson("john"); + await instance.onCreated( + ctx, + new Create({ + id: new URL("https://remote.example/creates/1"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://remote.example/notes/1"), + attribution: author, + to: PUBLIC_COLLECTION, + content: "A reply", + replyTarget: new URL( + `https://example.com/ap/actor/alpha/note/${messageId}`, + ), + }), + }), + ); + assert.deepStrictEqual(events, ["reply:alpha"]); + }); + + test("routes Create mentions to the mentioned bot", async () => { + const { instance, alpha, beta, ctx } = createHarness(); + const events: string[] = []; + alpha.onMention = (session) => + void (events.push(`mention:${session.bot.identifier}`)); + alpha.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + beta.onMention = (session) => + void (events.push(`mention:${session.bot.identifier}`)); + + const author = remotePerson("john"); + await instance.onCreated( + ctx, + new Create({ + id: new URL("https://remote.example/creates/2"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://remote.example/notes/2"), + attribution: author, + to: PUBLIC_COLLECTION, + content: "Hello @betabot!", + tags: [ + new Mention({ + href: new URL("https://example.com/ap/actor/beta"), + name: "@betabot@example.com", + }), + ], + }), + }), + ); + assert.deepStrictEqual(events, ["mention:beta"]); + }); + + test("routes Create to followers of the author", async () => { + const { instance, repository, alpha, beta, ctx } = createHarness(); + const events: string[] = []; + alpha.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + beta.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + + const author = remotePerson("john"); + await repository.addFollowee( + "alpha", + author.id!, + new Follow({ + id: new URL( + "https://example.com/ap/actor/alpha/follow/e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c", + ), + actor: new URL("https://example.com/ap/actor/alpha"), + object: author.id!, + }), + ); + await instance.onCreated( + ctx, + new Create({ + id: new URL("https://remote.example/creates/5"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://remote.example/notes/5"), + attribution: author, + to: PUBLIC_COLLECTION, + content: "Just a timeline post", + }), + }), + ); + assert.deepStrictEqual(events, ["message:alpha"]); + }); + + test("routes Undo(Follow) to the unfollowed bot", async () => { + const { instance, repository, alpha, beta, ctx } = createHarness(); + const unfollowed: string[] = []; + alpha.onUnfollow = (session) => + void (unfollowed.push(session.bot.identifier)); + beta.onUnfollow = (session) => + void (unfollowed.push(session.bot.identifier)); + const follower = remotePerson("john"); + const followId = new URL("https://remote.example/follows/1"); + await repository.addFollower("alpha", followId, follower); + await instance.onUndone( + ctx, + new Undo({ + actor: follower.id, + object: new Follow({ + id: followId, + actor: follower.id, + object: new URL("https://example.com/ap/actor/alpha"), + }), + }), + ); + assert.deepStrictEqual(unfollowed, ["alpha"]); + }); + + test("delivers personal inbox activities to the recipient only", async () => { + const repository = new MemoryRepository(); + const instance = new InstanceImpl({ + kv: new MemoryKvStore(), + repository, + }); + const alpha = instance.createBot("alpha", { username: "alphabot" }); + const beta = instance.createBot("beta", { username: "betabot" }); + const events: string[] = []; + alpha.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + beta.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + const ctx = createMockInboxContext( + instance, + "https://example.com/", + undefined, + "beta", + ); + const author = remotePerson("john"); + await instance.onCreated( + ctx, + new Create({ + id: new URL("https://remote.example/creates/3"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://remote.example/notes/3"), + attribution: author, + to: PUBLIC_COLLECTION, + content: "Delivered to beta's personal inbox", + }), + }), + ); + assert.deepStrictEqual(events, ["message:beta"]); + }); +}); + +describe("compatible single-bot instances", () => { + test("fire onMessage for any incoming Create", async () => { + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository: new MemoryRepository(), + username: "bot", + }); + const events: string[] = []; + bot.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + const ctx = createMockInboxContext( + bot.instance, + "https://example.com/", + undefined, + ); + const author = remotePerson("john"); + // The bot does not follow the author and is not addressed, but the + // pre-0.5 behavior of a single-bot deployment is preserved: + await bot.instance.onCreated( + ctx, + new Create({ + id: new URL("https://remote.example/creates/4"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://remote.example/notes/4"), + attribution: author, + to: PUBLIC_COLLECTION, + content: "Unrelated post", + }), + }), + ); + assert.deepStrictEqual(events, ["message:bot"]); + }); + + test("fire onLike for likes of objects with foreign URIs", async () => { + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository: new MemoryRepository(), + username: "bot", + }); + const events: string[] = []; + bot.onLike = (session) => void (events.push(session.bot.identifier)); + const ctx = createMockInboxContext( + bot.instance, + "https://example.com/", + undefined, + ); + await bot.instance.onLiked( + ctx, + new RawLike({ + id: new URL("https://remote.example/likes/3"), + actor: new Person({ + id: new URL("https://example.com/ap/actor/bot"), + preferredUsername: "bot", + }), + object: new Note({ + id: new URL("https://remote.example/notes/5"), + attribution: new URL("https://example.com/ap/actor/bot"), + to: PUBLIC_COLLECTION, + content: "A note with a foreign URI", + }), + }), + ); + assert.deepStrictEqual(events, ["bot"]); + }); +}); + +describe("addressed Create routing", () => { + test("routes by the embedded object's addressing", async () => { + const repository = new MemoryRepository(); + const instance = new InstanceImpl({ + kv: new MemoryKvStore(), + repository, + }); + const alpha = instance.createBot("alpha", { username: "alphabot" }); + const beta = instance.createBot("beta", { username: "betabot" }); + const events: string[] = []; + alpha.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + beta.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + const ctx = createMockInboxContext( + instance, + "https://example.com/", + undefined, + ); + const author = remotePerson("john"); + // The activity wrapper is public only; the audience is on the object: + await instance.onCreated( + ctx, + new Create({ + id: new URL("https://remote.example/creates/6"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: new URL("https://remote.example/notes/6"), + attribution: author, + to: new URL("https://example.com/ap/actor/beta"), + cc: PUBLIC_COLLECTION, + content: "Addressed to beta on the object", + }), + }), + ); + assert.deepStrictEqual(events, ["message:beta"]); + }); +}); From e836167f67dcaa963b2b1933a23d17e290f144b1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Sat, 4 Jul 2026 23:13:34 +0900 Subject: [PATCH 12/36] Serve per-bot web pages under handle paths The web interface used to serve the single bot at the site root, with /followers, /message/{id}, /feed.xml, /tags/{tag}, and the remote follow form all implicitly belonging to that bot. Multi-bot instances now serve a bot list at the root and each bot's pages under a path derived from its username: - /@{username}: the bot's profile - /@{username}/{messageId}: a message permalink - /@{username}/followers, /@{username}/tags/{tag}, /@{username}/feed.xml, and POST /@{username}/follow The page handlers were extracted into shared functions parameterized by a base path, so instances created through the single-bot createBot() compatibility path keep serving the exact same pages at the root. The actor's advertised web URL and the url property of published messages follow the instance's mode, and usernames are percent-encoded wherever they appear in paths. https://github.com/fedify-dev/botkit/issues/16 Assisted-by: Claude Code:claude-fable-5 Assisted-by: Codex:gpt-5.5 Claude-Session: https://claude.ai/code/session_0157FUYXeusCEmbWyYnwt3Cn --- packages/botkit/src/bot-impl.ts | 2 +- .../botkit/src/components/FollowButton.tsx | 5 +- packages/botkit/src/instance-impl.ts | 51 +++- packages/botkit/src/pages.test.ts | 227 +++++++++++++++++ packages/botkit/src/pages.tsx | 234 +++++++++++++++--- packages/botkit/src/session-impl.ts | 6 +- 6 files changed, 476 insertions(+), 49 deletions(-) create mode 100644 packages/botkit/src/pages.test.ts diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 3c4e95e..67ba1f3 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -298,7 +298,7 @@ export class BotImpl implements Bot { outbox: ctx.getOutboxUri(identifier), publicKey: keyPairs[0].cryptographicKey, assertionMethods: keyPairs.map((pair) => pair.multikey), - url: new URL("/", ctx.origin), + url: this.instance.getBotWebUrl(this, ctx.origin), }); } diff --git a/packages/botkit/src/components/FollowButton.tsx b/packages/botkit/src/components/FollowButton.tsx index a49ccc7..9d4bc13 100644 --- a/packages/botkit/src/components/FollowButton.tsx +++ b/packages/botkit/src/components/FollowButton.tsx @@ -3,9 +3,10 @@ import type { BotImpl } from "../bot-impl.ts"; export interface FollowButtonProps { readonly bot: BotImpl; + readonly action?: string; } -export function FollowButton({ bot }: FollowButtonProps) { +export function FollowButton({ bot, action }: FollowButtonProps) { return ( <>