diff --git a/AGENTS.md b/AGENTS.md index f41af15..2795e7b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,10 @@ Architecture - *src/mod.ts* - Main entry point, re-exports all public APIs - *src/bot.ts* - Core Bot interface and createBot function - *src/bot-impl.ts* - Internal Bot implementation + - *src/instance.ts* - Instance interface and createInstance function for + hosting multiple bots on a single server + - *src/instance-impl.ts* - Internal Instance implementation, which owns + the Fedify federation and routes incoming activities to the right bots - *src/session.ts* - Session management for bot operations - *src/message.ts* - Message types and ActivityPub objects (Note, Article, etc.) @@ -80,8 +84,13 @@ Architecture ### Key concepts - - *Bot*: The main bot instance created with `createBot()`, handles events - and provides session access + - *Instance*: A server hosting one or more bots, created with + `createInstance()`; owns the shared infrastructure (KV store, queue, + repository, HTTP handling) + - *Bot*: An individual ActivityPub actor created with `createBot()` (which + hosts a single bot on a dedicated instance) or `Instance.createBot()` + (static bots or dynamic bot groups); handles events and provides session + access - *Session*: Scoped bot operations for publishing content and managing state - *Message*: ActivityPub objects like Note, Article, Question with rich text support diff --git a/CHANGES.md b/CHANGES.md index ad218d7..2bcd40f 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,103 @@ Version 0.5.0 To be released. +### @fedify/botkit + + - Added support for hosting multiple bots on a single instance. + [[#16], [#24]] + + The new `createInstance()` function creates an *instance* that owns the + shared infrastructure (the key–value store, the message queue, the + repository, and HTTP handling), on which multiple bots can be hosted, + each with its own actor identity and event handlers. + + - Added `createInstance()` function. + - Added `Instance` interface. + - Added `InstanceWithVoidContextData` interface. + - Added `CreateInstanceOptions` interface. + - Added `Instance.createBot()` method, which creates a static bot from + an identifier and a `BotProfile`, or a dynamic `BotGroup` from + a `BotDispatcher` function that resolves bots on demand (e.g. one + bot per region, backed by a database). + - Added `BotProfile` interface. + - Added `BotDispatcher` type. + - Added `BotGroup` interface. + - Added `CreateBotGroupOptions` interface, whose `mapUsername` option + resolves WebFinger usernames to dynamic bot identifiers. + - Added `BotEventHandlers` interface, which `Bot` and `BotGroup` both + extend. + - Added `DEFAULT_INSTANCE_ACTOR_IDENTIFIER` constant. Multi-bot + instances expose an instance actor under a reserved identifier, + whose key signs shared-inbox related requests; it can be overridden + through the `CreateInstanceOptions.instanceActorIdentifier` option. + - Added `@fedify/botkit/instance` module. + + Activities delivered to the shared inbox are routed to the bots they are + relevant to: the followed or unfollowed bot, the owner of the liked or + replied-to message, mentioned bots, addressed bots, and bots following + the author. Multi-bot instances serve a bot list at the web root and + each bot's pages under `/@{username}`. + + The existing `createBot()` function keeps working for single-bot + deployments and preserves their behavior, including the web pages served + at the root. + + - The `Repository` interface now stores data for multiple bot actors: + every method takes the identifier of the owning bot actor as its first + parameter, and data belonging to different identifiers are isolated from + each other. This is a breaking change for custom `Repository` + implementations. [[#16], [#24]] + + - Added `identifier` parameter to all `Repository` methods. + - Added `Repository.findFollowedBots()` method, a reverse lookup + answering which bots follow a given actor. + - Added optional `Repository.migrate()` method for adopting data + stored by BotKit 0.4 or earlier. + - Added `Repository.forIdentifier()` method and `ActorScopedRepository` + class, a view of a repository bound to a single bot actor. + - `KvRepository` now stores data under bot-scoped keys. Its second + constructor parameter is now a `KvRepositoryOptions` object with + a single `prefix` option, replacing the removed + `KvStoreRepositoryPrefixes` interface. + - `createBot()` migrates data stored by BotKit 0.4 or earlier to the + bot-scoped layout on startup. + + - Local object URIs now carry the identifier of the owning bot actor, + e.g. `/ap/actor/{identifier}/note/{id}` instead of `/ap/note/{id}`. + URIs in the old format are still recognized in incoming activities and + are permanently redirected to their canonical URIs when dereferenced, + so links stored by remote servers keep working after an upgrade. + [[#16], [#24]] + + - The `Session.bot` property is now typed as `ReadonlyBot`, a read-only + view of the bot's identity and profile, instead of `Bot`. 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. [[#16], [#24]] + + - Added `ReadonlyBot` interface. + +[#16]: https://github.com/fedify-dev/botkit/issues/16 +[#24]: https://github.com/fedify-dev/botkit/pull/24 + +### @fedify/botkit-sqlite + + - All tables now have a `bot_id` column and composite primary keys, so + a single database stores the data of multiple bots. Opening a database + created by version 0.4 or earlier rebuilds the affected tables in place, + and `SqliteRepository.migrate()` assigns the carried-over rows to a bot + actor identifier; `createBot()` calls it automatically on startup. + [[#16], [#24]] + +### @fedify/botkit-postgres + + - All tables now have a `bot_id` column and composite primary keys, so + a single schema stores the data of multiple bots. Initializing a schema + created by version 0.4 upgrades it in place, and + `PostgresRepository.migrate()` assigns the carried-over rows to a bot + actor identifier; `createBot()` calls it automatically on startup. + [[#16], [#24]] + Version 0.4.3 ------------- diff --git a/deno.json b/deno.json index bf78cec..a59272c 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 --filter './packages/*' run build && pnpm run -r test", "test-all": { "dependencies": ["check", "test", "test:node"] }, diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 1b639bd..2c8242a 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -35,6 +35,7 @@ const concepts = { text: "Concepts", items: [ { text: "Bot", link: "/concepts/bot.md" }, + { text: "Instance", link: "/concepts/instance.md" }, { text: "Session", link: "/concepts/session.md" }, { text: "Events", link: "/concepts/events.md" }, { text: "Message", link: "/concepts/message.md" }, diff --git a/docs/concepts/bot.md b/docs/concepts/bot.md index 9ff4a57..26a939d 100644 --- a/docs/concepts/bot.md +++ b/docs/concepts/bot.md @@ -17,6 +17,11 @@ BotKit also exposes the bot actor's standard ActivityPub collections. The JSON-LD responses of the `outbox` and `followers` collections include the [FEP-5711] inverse properties `outboxOf` and `followersOf`. +> [!TIP] +> A bot created by `createBot()` occupies its whole server. Since BotKit +> 0.5.0, a single server can also host multiple bots; see the +> [*Instance* concept document](./instance.md). + [FEP-5711]: https://w3id.org/fep/5711 diff --git a/docs/concepts/instance.md b/docs/concepts/instance.md new file mode 100644 index 0000000..a08075d --- /dev/null +++ b/docs/concepts/instance.md @@ -0,0 +1,399 @@ +--- +description: >- + An Instance owns the shared infrastructure and can host multiple bots, + each with its own actor identity and event handlers. Learn how to create + an instance, host static and dynamic bots on it, and migrate an existing + single-bot deployment. +--- + +Instance +======== + +*This API is available since BotKit 0.5.0.* + +An `Instance` is a server that can host multiple bots. It owns the shared +infrastructure: the key–value store, the message queue, the repository, and +HTTP handling. Each bot hosted on it has its own actor identity, fediverse +handle, collections, and event handlers, while all of them share one +[Fedify federation] under the hood. + +If your server hosts a single bot, you don't need this API: `createBot()` +creates a dedicated instance for the bot internally, and everything described +in the [*Bot* concept document](./bot.md) keeps working as before. Reach for +`createInstance()` when you want several bots, or a whole family of bots +resolved from a database, to share one process and one domain. + +[Fedify federation]: https://fedify.dev/manual/federation + + +Creating an instance +-------------------- + +You can create an `Instance` by calling the `createInstance()` function: + +~~~~ typescript twoslash +import { createInstance } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const instance = createInstance({ + kv: new MemoryKvStore(), +}); +~~~~ + +The `CreateInstanceOptions` take the infrastructure-related options that +`createBot()` used to take: `~CreateInstanceOptions.kv`, +`~CreateInstanceOptions.repository`, `~CreateInstanceOptions.queue`, +`~CreateInstanceOptions.software`, `~CreateInstanceOptions.behindProxy`, and +`~CreateInstanceOptions.pages`. A single repository stores the data of every +bot hosted on the instance, scoped by their identifiers; see the +[*Repository* concept document](./repository.md) for details. + +Two options are specific to multi-bot instances: +`~CreateInstanceOptions.instanceActorIdentifier` overrides the reserved +identifier of [the instance actor](#the-instance-actor), and +`~CreateInstanceOptions.legacyObjectUris` keeps object URIs from an older +single-bot deployment working (see [*Migrating a single-bot +deployment*](#migrating-a-single-bot-deployment)). + +Like a `Bot`, an `Instance` has a `~Instance.fetch()` method to be connected +to the HTTP server: + +~~~~ typescript twoslash +import { createInstance } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const instance = createInstance({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +export default instance; // Deno +~~~~ + + +Static bots +----------- + +The `Instance.createBot()` method creates a bot with a fixed identifier and +profile, hosted on the instance: + +~~~~ typescript twoslash +import { createInstance, text } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const instance = createInstance({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +const greetBot = instance.createBot("greet", { + username: "greetbot", + name: "Greeting Bot", +}); + +greetBot.onFollow = async (session, followRequest) => { + await followRequest.accept(); + await session.publish(text`Welcome, ${followRequest.follower}!`); +}; +~~~~ + +The first argument is the bot's internal identifier, which is used for the +actor URI and *should not* be changed after the bot is federated. The second +argument is a `BotProfile`, which takes the profile-related options that +`createBot()` used to take: `~BotProfile.username`, `~BotProfile.name`, +`~BotProfile.summary`, `~BotProfile.icon`, `~BotProfile.image`, +`~BotProfile.properties`, `~BotProfile.class`, and +`~BotProfile.followerPolicy`. + +Identifiers and usernames must be unique across the instance; +`~Instance.createBot()` throws a `TypeError` on duplicates. + +Handler registration works exactly like on a `Bot` created by `createBot()`; +see the [*Events* concept document](./events.md). Incoming activities are +routed to the bots they are relevant to: a `Follow` reaches the followed bot, +a `Like` reaches the owner of the liked message, a mention reaches the +mentioned bot, and a message from a followed account reaches the bots that +follow its author. + + +Dynamic bots +------------ + +Passing a function instead of an identifier to `~Instance.createBot()` +creates a `BotGroup`: a family of bots resolved on demand by +a `BotDispatcher`. This suits scenarios like “one bot per region,” where +thousands of potential bots are backed by a database rather than declared up +front: + +~~~~ typescript twoslash +declare const db: { + getRegion(code: string): Promise<{ name: string } | null>; + getWeather(code: string): Promise; +}; +import { createInstance, text } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const instance = createInstance({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +const weatherBots = instance.createBot(async (ctx, identifier) => { + // Return null for identifiers this dispatcher doesn't handle: + if (!identifier.startsWith("weather_")) return null; + const region = await db.getRegion(identifier.slice("weather_".length)); + if (region == null) return null; + return { + username: identifier, + name: `${region.name} Weather Bot`, + }; +}); + +weatherBots.onMention = async (session, message) => { + // session.bot tells which bot is being mentioned: + const code = session.bot.identifier.slice("weather_".length); + const weather = await db.getWeather(code); + await message.reply(text`Current weather: ${weather}`); +}; +~~~~ + +The dispatcher is invoked whenever an identifier needs to be resolved, e.g. +for serving an actor or routing an incoming activity, so it should be fast. +Resolutions are memoized for the duration of a request, but not across +requests; look profiles up from a database rather than computing them +expensively. + +Event handlers registered on the group are shared by every bot it resolves, +and they are read at dispatch time, so the registration order of handlers +and the first resolution of a bot don't matter. + +Static bots take precedence over dynamic ones, and when multiple groups are +registered, their dispatchers are probed in the order the groups were +created: a dispatcher that returns `null` passes the identifier on to the +next group, and an identifier no dispatcher recognizes does not resolve at +all. + +### Usernames of dynamic bots + +By default, BotKit assumes that a dynamic bot's username equals its +identifier, which makes WebFinger lookups (`@weather_kr@your-domain`) work +without extra configuration. If your usernames differ from your +identifiers, provide a `~CreateBotGroupOptions.mapUsername` callback: + +~~~~ typescript twoslash +declare const db: { + getRegionByName(name: string): Promise<{ code: string } | null>; +}; +import { createInstance } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const instance = createInstance({ + kv: new MemoryKvStore(), +}); +declare function dispatcher(ctx: unknown, identifier: string): null; +// ---cut-before--- +const weatherBots = instance.createBot( + async (ctx, identifier) => { + // …resolve the profile from the identifier… + return dispatcher(ctx, identifier); + }, + { + async mapUsername(ctx, username) { + const region = await db.getRegionByName(username); + return region == null ? null : `weather_${region.code}`; + }, + }, +); +~~~~ + +When both are provided, it is your responsibility to keep the +identifier-to-profile and username-to-identifier mappings consistent. + +### Sessions for dynamic bots + +Since a group has no single identifier, `BotGroup.getSession()` takes the +identifier of the bot to control: + +~~~~ typescript twoslash +declare const dispatcher: import("@fedify/botkit").BotDispatcher; +import { createInstance, text } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const instance = createInstance({ + kv: new MemoryKvStore(), +}); +const weatherBots = instance.createBot(dispatcher); +// ---cut-before--- +const session = await weatherBots.getSession( + "https://mydomain", + "weather_kr", +); +await session.publish(text`It's sunny in Seoul today!`); +~~~~ + +It returns a `Promise` since the dispatcher has to resolve the identifier +first, and it rejects with a `TypeError` when the dispatcher doesn't +recognize the identifier. + + +Multiple bot groups +------------------- + +An instance can host several bot groups side by side, each serving +a different kind of bot. Since a dispatcher that returns `null` passes the +identifier on to the next group, giving each group its own identifier +namespace (e.g. a prefix) is all it takes to keep them apart: + +~~~~ typescript twoslash +declare const db: { + getRegion(code: string): Promise<{ name: string } | null>; + getWeather(code: string): Promise; + getTopic(slug: string): Promise<{ name: string } | null>; + getHeadlines(slug: string): Promise; +}; +import { createInstance, text } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const instance = createInstance({ + kv: new MemoryKvStore(), +}); +// ---cut-before--- +// A static bot for instance-wide announcements: +const announcements = instance.createBot("announcements", { + username: "announcements", + name: "Announcements", +}); + +// One bot per region, under the weather_ prefix: +const weatherBots = instance.createBot(async (ctx, identifier) => { + if (!identifier.startsWith("weather_")) return null; + const region = await db.getRegion(identifier.slice("weather_".length)); + if (region == null) return null; + return { username: identifier, name: `${region.name} Weather Bot` }; +}); + +// One bot per topic, under the news_ prefix: +const newsBots = instance.createBot(async (ctx, identifier) => { + if (!identifier.startsWith("news_")) return null; + const topic = await db.getTopic(identifier.slice("news_".length)); + if (topic == null) return null; + return { username: identifier, name: `${topic.name} News Bot` }; +}); + +weatherBots.onMention = async (session, message) => { + const code = session.bot.identifier.slice("weather_".length); + await message.reply(text`Current weather: ${await db.getWeather(code)}`); +}; + +newsBots.onMention = async (session, message) => { + const slug = session.bot.identifier.slice("news_".length); + await message.reply(text`Today's headlines: ${await db.getHeadlines(slug)}`); +}; +~~~~ + +Resolving the identifier `news_tech` on this instance walks through the +registrations in order: it is not a static bot, `weatherBots` returns `null` +because the prefix doesn't match, and `newsBots` resolves it. Each group +keeps its own event handlers, so a mention of `@news_tech@your-domain` +reaches `newsBots.onMention` only. + +Registering any number of groups is allowed and never an error. BotKit +cannot tell at registration time whether two arbitrary dispatchers overlap, +so overlaps are resolved by order instead: if two groups would resolve the +same identifier, the group created first wins and the later group's +dispatcher is not even invoked for it. WebFinger username resolution has +its own order: static bots' usernames win first, then the groups' +`~CreateBotGroupOptions.mapUsername` callbacks are tried in creation order, +and the username-as-identifier fallback for groups without the callback +comes last. In practice, disjoint namespaces like the prefixes above are +the way to keep group boundaries obvious. + + +The instance actor +------------------ + +A multi-bot instance has no single obvious actor whose key should sign +shared-inbox related requests, so it exposes an *instance actor*: an +internal, non-discoverable `Application` actor, similar to Mastodon's +instance actor. It lives under a reserved identifier, which defaults to +`DEFAULT_INSTANCE_ACTOR_IDENTIFIER` (`__botkit_instance__`). Bots cannot +take the reserved identifier, whether they are static or resolved by +a dispatcher. + +In the unlikely case that the default collides with an identifier you need, +override it through the `~CreateInstanceOptions.instanceActorIdentifier` +option: + +~~~~ typescript twoslash +import { createInstance } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; +// ---cut-before--- +const instance = createInstance({ + kv: new MemoryKvStore(), + instanceActorIdentifier: "fetcher", +}); +~~~~ + +Like bot identifiers, the instance actor identifier is used for the actor +URI, so it *should not* be changed after the instance is federated: remote +servers that have seen the instance actor would no longer be able to fetch +its key. + +Instances created through the single-bot `createBot()` function keep the +pre-0.5 behavior: the sole bot's key signs shared-inbox requests and no +instance actor is exposed. + + +Web pages +--------- + +A multi-bot instance serves a list of its static bots at the web 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`: the bot's followers + - `/@{username}/tags/{hashtag}`: the bot's messages with a hashtag + - `/@{username}/feed.xml`: the bot's Atom feed + +Dynamic bots get the same pages once their usernames resolve. Single-bot +deployments created through `createBot()` keep serving their pages at the +web root as before. + + +Custom emojis +------------- + +Custom emojis belong to the instance and are shared by every bot hosted on +it. The `Instance.addCustomEmojis()` method works like +[`Bot.addCustomEmojis()`](./text.md#custom-emojis), and emoji names must be +unique across the instance. + + +Migrating a single-bot deployment +--------------------------------- + +An existing deployment created with `createBot()` keeps working without any +changes: its data is migrated to the bot-scoped storage layout on startup, +object URIs in the old format are recognized and redirected, and its web +pages stay at the root. + +If you later want to move a single-bot deployment onto a multi-bot instance, +two things need to carry over. First, the repository data: the automatic +migration runs only in the `createBot()` compatibility path, so either run +the deployment once with `createBot()` on BotKit 0.5.0 before switching to +`createInstance()`, or call the repository's `~Repository.migrate()` method +with the bot's identifier yourself. Second, the object URIs: create the +instance with the `~CreateInstanceOptions.legacyObjectUris` option, so that +object URIs generated by BotKit 0.4 or earlier are still attributed to the +original bot: + +~~~~ typescript twoslash +import { createInstance } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; +// ---cut-before--- +const instance = createInstance({ + kv: new MemoryKvStore(), + legacyObjectUris: { identifier: "bot" }, +}); +~~~~ + +Note that the web page paths change in that case (from `/` to +`/@{username}`), while actor URIs and follower relationships are preserved. diff --git a/docs/concepts/repository.md b/docs/concepts/repository.md index 8c387df..1abf080 100644 --- a/docs/concepts/repository.md +++ b/docs/concepts/repository.md @@ -14,6 +14,27 @@ can be used to interact with the database, but you can also create your own repositories to interact with other data sources. +Bot-scoped storage +------------------ + +Since BotKit 0.5.0, a single repository stores the data of every bot hosted +on an [instance](./instance.md): every `Repository` method takes the +identifier of the owning bot actor as its first parameter, and data +belonging to different identifiers are isolated from each other. The +`Repository.forIdentifier()` method returns an `ActorScopedRepository`, +a view of the repository bound to one bot actor, which exposes the same +operations without the `identifier` parameter. + +Custom `Repository` implementations written for BotKit 0.4 or earlier need +to be updated to the new method signatures. Besides the added parameter, +two methods joined the interface: `~Repository.findFollowedBots()`, +a reverse lookup answering which bots follow a given actor (used for routing +incoming messages to the right bots), and the optional +`~Repository.migrate()`, which adopts data stored by BotKit 0.4 or earlier +for a bot actor identifier. The built-in repositories migrate legacy data +automatically when the bot is created through `createBot()`. + + `KvRepository` -------------- diff --git a/docs/concepts/session.md b/docs/concepts/session.md index a29e68f..7866b9a 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -59,6 +59,21 @@ const session = bot.getSession(`https://${SERVER_NAME}`); // [!code highlight] ::: +> [!NOTE] +> A dynamic [bot group](./instance.md#dynamic-bots) hosts many bots, so +> `BotGroup.getSession()` additionally takes the identifier of the bot to +> control and returns a `Promise`: +> +> ~~~~ typescript twoslash +> import type { BotGroup } from "@fedify/botkit"; +> const weatherBots = {} as unknown as BotGroup; +> // ---cut-before--- +> const session = await weatherBots.getSession( +> "https://mydomain", +> "weather_kr", +> ); +> ~~~~ + Getting a session from an event handler --------------------------------------- @@ -78,6 +93,32 @@ bot.onMention = async (session, message) => { To learn more about event handlers, see the [*Events* section](./events.md). +Determining which bot the session belongs to +-------------------------------------------- + +The `Session.bot` property is a `ReadonlyBot`: a read-only view of the bot's +identity and profile, including its `~ReadonlyBot.identifier`, +`~ReadonlyBot.username`, and `~ReadonlyBot.name`. It is particularly useful +in handlers registered on a [dynamic bot group](./instance.md#dynamic-bots), +where the same handler runs for many bots: + +~~~~ typescript twoslash +import type { BotGroup } from "@fedify/botkit"; +const weatherBots = {} as unknown as BotGroup; +// ---cut-before--- +weatherBots.onMention = async (session, message) => { + const identifier = session.bot.identifier; + // …look up the data this particular bot serves… +}; +~~~~ + +> [!NOTE] +> Before BotKit 0.5.0, this property was typed as `Bot`, so event handlers +> could be reassigned through it. It is now a `ReadonlyBot`, which exposes +> the identity and profile only. If you need the full `Bot`, hold on to +> the object returned by `createBot()` instead. + + Determining the actor URI of the bot ------------------------------------ diff --git a/docs/start.md b/docs/start.md index 01c87f7..f7f5030 100644 --- a/docs/start.md +++ b/docs/start.md @@ -109,6 +109,11 @@ same process for development purposes. > - [*Key–value store*] > - [*Message queue*] +> [!TIP] +> A bot created by `createBot()` occupies its whole server. If you want to +> host multiple bots on a single server, create them on an *instance* +> instead; see the [*Instance* chapter](./concepts/instance.md). + [`MemoryKvStore`]: https://fedify.dev/manual/kv#memorykvstore [`InProcessMessageQueue`]: https://fedify.dev/manual/mq#inprocessmessagequeue [*Key–value store*]: https://fedify.dev/manual/kv diff --git a/packages/botkit-postgres/package.json b/packages/botkit-postgres/package.json index a0f6cfe..fc0a32f 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..18c4cff 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", @@ -169,7 +170,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 +190,7 @@ if (postgresUrl == null) { sql, url: undefined, maxConnections: undefined, - }]).countMessages(), + }]).countMessages("bot"), ); await assert.rejects( async () => @@ -197,7 +198,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 +243,7 @@ if (postgresUrl == null) { }]) as PostgresRepository; await waitForMacrotask(); await assert.rejects( - () => repo.countMessages(), + () => repo.countMessages("bot"), error, ); await waitForMacrotask(); @@ -300,7 +301,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 +330,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 +343,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 +399,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 +421,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 +439,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 +501,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 +531,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 +610,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 +662,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 +685,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 +693,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 +735,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 +799,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 +824,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 +833,7 @@ if (postgresUrl == null) { ); assert.ok( await repo.updateMessage( + "bot", "0194244f-d800-7873-8993-ef71ccd47306", async (message) => message.clone({ @@ -814,18 +843,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 +875,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 +950,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 +1005,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 +1018,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`; @@ -977,3 +1030,381 @@ 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("survives concurrent initialization", async () => { + const sql = createSql(postgresUrl!); + const schema = createSchemaName(); + try { + await createLegacySchema(sql, schema); + await seedLegacySchema(sql, schema); + + // Two replicas starting at once against the same legacy schema must + // both come up; the upgrade is serialized by an advisory lock and + // guarded per table: + const sqlA = createSql(postgresUrl!); + const sqlB = createSql(postgresUrl!); + try { + await Promise.all([ + initializePostgresRepositorySchema(sqlA, schema), + initializePostgresRepositorySchema(sqlB, schema), + ]); + } finally { + await sqlA.end(); + await sqlB.end(); + } + + const repo = new PostgresRepository({ + url: postgresUrl!, + schema, + maxConnections: 1, + }); + try { + await repo.migrate("bot"); + assert.equal(await repo.countMessages("bot"), 1); + } finally { + await repo.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 7006ecc..f02f56b 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"; @@ -44,6 +45,7 @@ const logger = getLogger(["botkit", "postgres"]); const schemaNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/; const followRequestAdvisoryLockNamespace = 0x4254; const followerAdvisoryLockNamespace = 0x4246; +const schemaUpgradeAdvisoryLockNamespace = 0x424b; type Queryable = Pick; type QueryParameter = postgres.SerializableParameter; @@ -137,12 +139,24 @@ 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" ( - 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 +164,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 +176,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,10 +194,14 @@ 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 + DEFERRABLE INITIALLY IMMEDIATE )`, [], prepare, @@ -187,15 +209,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 +227,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,12 +257,194 @@ 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, ); } +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 }, + ); + // Multiple processes can start against the same legacy schema at once, so + // the whole upgrade runs inside one PL/pgSQL block: an advisory lock + // serializes it, and every table is re-checked under the lock, so the + // process that lost the race finds nothing left to do. The detection + // query above is merely a fast path for already-upgraded schemas. + const legacyTable = (table: string) => + `EXISTS (SELECT 1 + FROM information_schema.tables t + WHERE t.table_schema = '${schema}' AND t.table_name = '${table}') + AND NOT EXISTS (SELECT 1 + FROM information_schema.columns c + WHERE c.table_schema = '${schema}' AND c.table_name = '${table}' + AND c.column_name = 'bot_id')`; + const block = ` + DO $botkit_upgrade$ + DECLARE + upgraded boolean := false; + BEGIN + PERFORM pg_catalog.pg_advisory_xact_lock( + ${schemaUpgradeAdvisoryLockNamespace}, + pg_catalog.hashtext('${schema}') + ); + + IF ${legacyTable("follow_requests")} THEN + -- The old foreign key referenced followers (follower_id) only; it + -- has to go away before the followers primary key changes: + ALTER TABLE "${schema}"."follow_requests" + DROP CONSTRAINT IF EXISTS "follow_requests_follower_id_fkey"; + END IF; + + IF ${legacyTable("key_pairs")} THEN + ALTER TABLE "${schema}"."key_pairs" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."key_pairs" ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."key_pairs" + DROP CONSTRAINT IF EXISTS "key_pairs_pkey"; + ALTER TABLE "${schema}"."key_pairs" ADD PRIMARY KEY (bot_id, position); + upgraded := true; + END IF; + + IF ${legacyTable("messages")} THEN + ALTER TABLE "${schema}"."messages" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."messages" ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."messages" + DROP CONSTRAINT IF EXISTS "messages_pkey"; + ALTER TABLE "${schema}"."messages" ADD PRIMARY KEY (bot_id, id); + DROP INDEX IF EXISTS "${schema}"."idx_messages_published"; + upgraded := true; + END IF; + + IF ${legacyTable("followers")} THEN + ALTER TABLE "${schema}"."followers" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."followers" ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."followers" + DROP CONSTRAINT IF EXISTS "followers_pkey"; + ALTER TABLE "${schema}"."followers" + ADD PRIMARY KEY (bot_id, follower_id); + upgraded := true; + END IF; + + IF ${legacyTable("follow_requests")} THEN + ALTER TABLE "${schema}"."follow_requests" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."follow_requests" + ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."follow_requests" + DROP CONSTRAINT IF EXISTS "follow_requests_pkey"; + 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"; + upgraded := true; + END IF; + + IF ${legacyTable("sent_follows")} THEN + ALTER TABLE "${schema}"."sent_follows" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."sent_follows" + ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."sent_follows" + DROP CONSTRAINT IF EXISTS "sent_follows_pkey"; + ALTER TABLE "${schema}"."sent_follows" ADD PRIMARY KEY (bot_id, id); + upgraded := true; + END IF; + + IF ${legacyTable("followees")} THEN + ALTER TABLE "${schema}"."followees" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."followees" ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."followees" + DROP CONSTRAINT IF EXISTS "followees_pkey"; + ALTER TABLE "${schema}"."followees" + ADD PRIMARY KEY (bot_id, followee_id); + upgraded := true; + END IF; + + IF ${legacyTable("poll_votes")} THEN + ALTER TABLE "${schema}"."poll_votes" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."poll_votes" ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."poll_votes" + DROP CONSTRAINT IF EXISTS "poll_votes_pkey"; + 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"; + upgraded := true; + END IF; + + IF upgraded THEN + -- The marker lets migrate() distinguish rows carried over from + -- a legacy schema (bot_id = '') from data legitimately stored under + -- an empty-string identifier: + 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; + END IF; + END + $botkit_upgrade$ + `; + // DO blocks cannot be prepared: + await execute(sql, block, [], false); + logger.info("Finished upgrading legacy tables."); +} + /** * A repository for storing bot data using PostgreSQL. * @since 0.4.0 @@ -295,19 +511,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 +541,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 +550,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 +570,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 +591,7 @@ export class PostgresRepository implements Repository, AsyncDisposable { } async updateMessage( + identifier: string, id: Uuid, updater: ( existing: Create | Announce, @@ -370,9 +603,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 +618,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 +630,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 +681,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 +720,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 +834,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 +1002,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 +1014,61 @@ 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); + } + private table(name: string): string { return `"${this.schema}"."${name}"`; } private async lockFollowRequest( sql: Queryable, + identifier: string, followId: URL, ): Promise { await this.query( @@ -726,13 +1076,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 +1091,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/package.json b/packages/botkit-sqlite/package.json index b47fe67..cdb7f34 100644 --- a/packages/botkit-sqlite/package.json +++ b/packages/botkit-sqlite/package.json @@ -43,7 +43,7 @@ "README.md" ], "peerDependencies": { - "@fedify/botkit": "workspace:" + "@fedify/botkit": "workspace:^" }, "dependencies": { "@fedify/fedify": "catalog:", diff --git a/packages/botkit-sqlite/src/mod.test.ts b/packages/botkit-sqlite/src/mod.test.ts index c18b5e6..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)) { @@ -73,9 +74,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 +85,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 +111,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 +128,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 +152,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 +188,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 +231,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 +253,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(), ); @@ -263,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 8423c18..d73f3c8 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 { @@ -89,95 +90,356 @@ 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 ( 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 +449,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 +475,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 +490,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 +520,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 +530,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 +567,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 +612,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 +639,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 +661,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 +687,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 +746,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 +790,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 +852,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 +909,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 +964,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/deno.json b/packages/botkit/deno.json index b10d997..e392115 100644 --- a/packages/botkit/deno.json +++ b/packages/botkit/deno.json @@ -8,6 +8,7 @@ "./emoji": "./src/emoji.ts", "./events": "./src/events.ts", "./follow": "./src/follow.ts", + "./instance": "./src/instance.ts", "./message": "./src/message.ts", "./poll": "./src/poll.ts", "./reaction": "./src/reaction.ts", diff --git a/packages/botkit/package.json b/packages/botkit/package.json index f696bbf..876f5c6 100644 --- a/packages/botkit/package.json +++ b/packages/botkit/package.json @@ -50,6 +50,10 @@ "types": "./dist/follow.d.ts", "import": "./dist/follow.js" }, + "./instance": { + "types": "./dist/instance.d.ts", + "import": "./dist/instance.js" + }, "./message": { "types": "./dist/message.d.ts", "import": "./dist/message.js" diff --git a/packages/botkit/src/bot-group.test.ts b/packages/botkit/src/bot-group.test.ts new file mode 100644 index 0000000..f7af15b --- /dev/null +++ b/packages/botkit/src/bot-group.test.ts @@ -0,0 +1,365 @@ +// 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 { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; +import assert from "node:assert"; +import { describe, test } from "node:test"; +import { InstanceImpl } from "./instance-impl.ts"; +import type { BotProfile } from "./instance.ts"; +import { MemoryRepository } from "./repository.ts"; + +function createInstance(): { + instance: InstanceImpl; + repository: MemoryRepository; +} { + const repository = new MemoryRepository(); + const instance = new InstanceImpl({ + kv: new MemoryKvStore(), + repository, + }); + return { instance, repository }; +} + +function regionProfile(identifier: string): BotProfile | null { + if (!identifier.startsWith("region_")) return null; + const region = identifier.slice("region_".length); + return { + username: identifier, + name: `${region.toUpperCase()} Weather Bot`, + }; +} + +describe("dynamic bots", () => { + test("serve dispatcher-resolved actors", async () => { + const { instance } = createInstance(); + let calls = 0; + instance.createBot((_ctx, identifier) => { + calls++; + return regionProfile(identifier); + }); + const response = await instance.fetch( + new Request("https://example.com/ap/actor/region_kr", { + headers: { Accept: "application/activity+json" }, + }), + undefined, + ); + assert.deepStrictEqual(response.status, 200); + const actor = await response.json(); + assert.deepStrictEqual(actor.preferredUsername, "region_kr"); + assert.deepStrictEqual(actor.name, "KR Weather Bot"); + + const miss = await instance.fetch( + new Request("https://example.com/ap/actor/other", { + headers: { Accept: "application/activity+json" }, + }), + undefined, + ); + assert.deepStrictEqual(miss.status, 404); + + // Resolution is memoized per context, so a dispatcher runs at most + // once per identifier within one context: + calls = 0; + const ctx = instance.federation.createContext( + new URL("https://example.com/"), + undefined, + ); + await instance.resolveBot(ctx, "region_kr"); + await instance.resolveBot(ctx, "region_kr"); + await instance.resolveBot(ctx, "other"); + await instance.resolveBot(ctx, "other"); + assert.deepStrictEqual(calls, 2); + }); + + test("prefer static bots over dynamic dispatchers", async () => { + const { instance } = createInstance(); + instance.createBot("region_kr", { username: "static-kr" }); + instance.createBot((_ctx, identifier) => regionProfile(identifier)); + const response = await instance.fetch( + new Request("https://example.com/ap/actor/region_kr", { + headers: { Accept: "application/activity+json" }, + }), + undefined, + ); + assert.deepStrictEqual(response.status, 200); + const actor = await response.json(); + assert.deepStrictEqual(actor.preferredUsername, "static-kr"); + }); + + test("probe dispatchers in registration order", async () => { + const { instance } = createInstance(); + const probed: string[] = []; + instance.createBot((_ctx, identifier) => { + probed.push("first"); + return identifier === "dual" ? { username: "first-bot" } : null; + }); + instance.createBot((_ctx, identifier) => { + probed.push("second"); + return identifier === "dual" ? { username: "second-bot" } : null; + }); + const response = await instance.fetch( + new Request("https://example.com/ap/actor/dual", { + headers: { Accept: "application/activity+json" }, + }), + undefined, + ); + const actor = await response.json(); + assert.deepStrictEqual(actor.preferredUsername, "first-bot"); + assert.ok(!probed.includes("second")); + }); + + test("fall through to the next dispatcher on null", async () => { + const { instance } = createInstance(); + const probed: string[] = []; + instance.createBot((_ctx, identifier) => { + probed.push("first"); + return identifier === "somebody-else" ? { username: "first-bot" } : null; + }); + instance.createBot((_ctx, identifier) => { + probed.push("second"); + return regionProfile(identifier); + }); + const response = await instance.fetch( + new Request("https://example.com/ap/actor/region_kr", { + headers: { Accept: "application/activity+json" }, + }), + undefined, + ); + assert.deepStrictEqual(response.status, 200); + const actor = await response.json(); + assert.deepStrictEqual(actor.preferredUsername, "region_kr"); + // The first dispatcher was probed, returned null, and the resolution + // fell through to the second: + assert.ok(probed.includes("first")); + assert.ok(probed.includes("second")); + }); + + test("read event handlers live from the group", async () => { + const { instance } = createInstance(); + const group = instance.createBot( + (_ctx, identifier) => regionProfile(identifier), + ); + // Resolve the bot once before any handler is registered: + await instance.fetch( + new Request("https://example.com/ap/actor/region_kr", { + headers: { Accept: "application/activity+json" }, + }), + undefined, + ); + // A handler registered afterwards must still fire: + const followed: string[] = []; + group.onFollow = (session) => void (followed.push(session.bot.identifier)); + const ctx = instance.federation.createContext( + new URL("https://example.com/"), + undefined, + ) as InboxContext; + Object.defineProperty(ctx, "recipient", { + value: null, + configurable: true, + }); + Object.defineProperty(ctx, "sendActivity", { + value: () => Promise.resolve(), + configurable: true, + }); + await instance.onFollowed( + ctx, + new Follow({ + id: new URL("https://remote.example/follows/1"), + actor: new Person({ + id: new URL("https://remote.example/actors/john"), + preferredUsername: "john", + }), + object: new URL("https://example.com/ap/actor/region_kr"), + }), + ); + assert.deepStrictEqual(followed, ["region_kr"]); + }); + + test("resolve usernames through mapUsername", async () => { + const { instance } = createInstance(); + instance.createBot( + (_ctx, identifier) => regionProfile(identifier), + { + mapUsername: (_ctx, username) => + username.startsWith("region_") ? username : `region_${username}`, + }, + ); + const response = await instance.fetch( + new Request( + "https://example.com/.well-known/webfinger?resource=acct:kr@example.com", + ), + undefined, + ); + 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/region_kr", + ); + }); + + test("fall back to username-as-identifier lookups", async () => { + const { instance } = createInstance(); + instance.createBot((_ctx, identifier) => regionProfile(identifier)); + const response = await instance.fetch( + new Request( + "https://example.com/.well-known/webfinger?resource=acct:region_kr@example.com", + ), + undefined, + ); + assert.deepStrictEqual(response.status, 200); + }); + + test("require context data unless TContextData is void", () => { + const instance = new InstanceImpl<{ db: string }>({ + kv: new MemoryKvStore(), + repository: new MemoryRepository(), + }); + const group = instance.createBot((_ctx, identifier) => ({ + username: identifier, + })); + // @ts-expect-error: contextData is required when TContextData is not + // void. + group.getSession("https://example.com", "someone").catch(() => {}); + group.getSession("https://example.com", "someone", { db: "x" }) + .catch(() => {}); + }); + + test("create sessions for resolved identifiers", async () => { + const { instance } = createInstance(); + const group = instance.createBot( + (_ctx, identifier) => regionProfile(identifier), + ); + const session = await group.getSession( + "https://example.com", + "region_kr", + ); + assert.deepStrictEqual( + session.actorId.href, + "https://example.com/ap/actor/region_kr", + ); + assert.deepStrictEqual(session.bot.identifier, "region_kr"); + assert.deepStrictEqual(session.bot.username, "region_kr"); + + await assert.rejects( + () => group.getSession("https://example.com", "nonexistent"), + TypeError, + ); + }); +}); + +describe("dynamic bots in routing and web pages", () => { + test("receive timeline messages via followees", async () => { + const { instance, repository } = createInstance(); + const group = instance.createBot( + (_ctx, identifier) => regionProfile(identifier), + ); + const events: string[] = []; + group.onMessage = (session) => + void (events.push(`message:${session.bot.identifier}`)); + const author = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }); + await repository.addFollowee( + "region_kr", + author.id!, + new Follow({ + id: new URL( + "https://example.com/ap/actor/region_kr/follow/e35ff5d8-ede9-4f5e-9b83-4bfcd4c9a69c", + ), + actor: new URL("https://example.com/ap/actor/region_kr"), + object: author.id!, + }), + ); + const ctx = instance.federation.createContext( + new URL("https://example.com/"), + undefined, + ) as InboxContext; + Object.defineProperty(ctx, "recipient", { + value: null, + configurable: true, + }); + 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 timeline post", + }), + }), + ); + assert.deepStrictEqual(events, ["message:region_kr"]); + }); + + test("serve dynamic bots' web pages", async () => { + const { instance } = createInstance(); + instance.createBot((_ctx, identifier) => regionProfile(identifier)); + const response = await instance.fetch( + new Request("https://example.com/@region_kr"), + undefined, + ); + assert.deepStrictEqual(response.status, 200); + const html = await response.text(); + assert.ok(html.includes("@region_kr@example.com")); + + const miss = await instance.fetch( + new Request("https://example.com/@nonexistent"), + undefined, + ); + assert.deepStrictEqual(miss.status, 404); + }); +}); + +describe("mapUsername ownership", () => { + test("cannot map usernames to other bots' identifiers", async () => { + const { instance } = createInstance(); + instance.createBot("staticbot", { username: "staticbot" }); + instance.createBot( + (_ctx, identifier) => regionProfile(identifier), + { + // A hostile or buggy mapping pointing at a static bot's identifier: + mapUsername: () => "staticbot", + }, + ); + const response = await instance.fetch( + new Request( + "https://example.com/.well-known/webfinger?resource=acct:hijack@example.com", + ), + undefined, + ); + assert.deepStrictEqual(response.status, 404); + }); +}); + +describe("username-as-identifier fallback scope", () => { + test("does not expose static bots' internal identifiers", async () => { + const { instance } = createInstance(); + instance.createBot("internal", { username: "mybot" }); + const response = await instance.fetch( + new Request( + "https://example.com/.well-known/webfinger?resource=acct:internal@example.com", + ), + undefined, + ); + assert.deepStrictEqual(response.status, 404); + }); +}); diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 752abb3..307490a 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( @@ -749,11 +762,15 @@ test("BotImpl.dispatchFollow()", async () => { undefined, ); assert.deepStrictEqual( - await bot.dispatchFollow(ctx, { id: crypto.randomUUID() }), + await bot.dispatchFollow(ctx, { + identifier: "bot", + id: crypto.randomUUID(), + }), null, ); await repository.addSentFollow( + "bot", "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a", new Follow({ id: new URL( @@ -765,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); @@ -800,6 +818,7 @@ test("BotImpl.authorizeFollow()", async () => { undefined, ); await repository.addSentFollow( + "bot", "b51f6ca8-53e6-4f7d-ac1f-d039e8c6df5a", new Follow({ id: new URL( @@ -823,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( @@ -839,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, ); @@ -849,7 +868,7 @@ test("BotImpl.authorizeFollow()", async () => { assert.deepStrictEqual( await bot.authorizeFollow( ctx, - { id: crypto.randomUUID() }, + { identifier: "bot", id: crypto.randomUUID() }, ), false, ); @@ -867,11 +886,12 @@ test("BotImpl.dispatchCreate()", async () => { undefined, ); assert.deepStrictEqual( - await bot.dispatchCreate(ctx, { id: "non-existent" }), + await bot.dispatchCreate(ctx, { identifier: "bot", id: "non-existent" }), null, ); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -890,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); @@ -909,12 +930,14 @@ test("BotImpl.dispatchCreate()", async () => { ctx2.getSignedKeyOwner = () => Promise.resolve(actor); assert.deepStrictEqual( await bot.dispatchCreate(ctx2, { + identifier: "bot", id: "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", }), create, ); await repository.addMessage( + "bot", "8386a4c7-06f8-409f-ad72-2bba43e83363", new Create({ id: new URL( @@ -932,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); @@ -948,6 +973,7 @@ test("BotImpl.dispatchCreate()", async () => { ); await repository.addMessage( + "bot", "ce8081ac-f238-484b-9a70-5d8a4b66d829", new Announce({ id: new URL( @@ -961,6 +987,7 @@ test("BotImpl.dispatchCreate()", async () => { ); assert.deepStrictEqual( await bot.dispatchCreate(ctx, { + identifier: "bot", id: "ce8081ac-f238-484b-9a70-5d8a4b66d829", }), null, @@ -984,6 +1011,7 @@ test("BotImpl.dispatchMessage()", async () => { ); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -1039,6 +1067,7 @@ test("BotImpl.dispatchMessage()", async () => { ); await repository.addMessage( + "bot", "8386a4c7-06f8-409f-ad72-2bba43e83363", new Create({ id: new URL( @@ -1074,6 +1103,7 @@ test("BotImpl.dispatchMessage()", async () => { ); await repository.addMessage( + "bot", "ce8081ac-f238-484b-9a70-5d8a4b66d829", new Announce({ id: new URL( @@ -1107,11 +1137,12 @@ test("BotImpl.dispatchAnnounce()", async () => { undefined, ); assert.deepStrictEqual( - await bot.dispatchAnnounce(ctx, { id: "non-existent" }), + await bot.dispatchAnnounce(ctx, { identifier: "bot", id: "non-existent" }), null, ); await repository.addMessage( + "bot", "ce8081ac-f238-484b-9a70-5d8a4b66d829", new Announce({ id: new URL( @@ -1124,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); @@ -1135,6 +1167,7 @@ test("BotImpl.dispatchAnnounce()", async () => { ); await repository.addMessage( + "bot", "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", new Create({ id: new URL( @@ -1154,12 +1187,14 @@ test("BotImpl.dispatchAnnounce()", async () => { ); assert.deepStrictEqual( await bot.dispatchAnnounce(ctx, { + identifier: "bot", id: "78acb1ea-4ac6-46b7-bcd4-3a8965d8126e", }), null, ); await repository.addMessage( + "bot", "d4a7ef9b-682c-4de9-b23c-87747d6725cb", new Announce({ id: new URL( @@ -1172,6 +1207,7 @@ test("BotImpl.dispatchAnnounce()", async () => { ); assert.deepStrictEqual( await bot.dispatchAnnounce(ctx, { + identifier: "bot", id: "d4a7ef9b-682c-4de9-b23c-87747d6725cb", }), null, @@ -1185,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); @@ -1298,7 +1335,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 +1345,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 +1360,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 +1371,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 +1396,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 +1434,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 +1443,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 +1506,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 +1567,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 +1591,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 +1617,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 +1649,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 +1710,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 +1734,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 +1760,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 +1792,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 +1842,7 @@ test("BotImpl.onCreated()", async (t) => { }); await repository.addMessage( + "bot", "a6358f1b-c978-49d3-8065-37a1df6168de", new Create({ id: new URL( @@ -2016,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"), @@ -2049,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"), @@ -2081,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"), @@ -2129,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"), @@ -2168,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"), @@ -2217,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"), @@ -2262,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"), @@ -2394,6 +2444,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 +2453,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 +2818,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 +2876,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 +2925,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 +3019,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 = []; @@ -3034,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 1b16dcf..0914fda 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,17 +48,18 @@ 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 { + BotDispatcher, + BotGroup, + BotProfile, + CreateBotGroupOptions, +} from "./instance.ts"; import { type CustomEmoji, type DeferredCustomEmoji, @@ -84,6 +83,7 @@ import type { VoteEventHandler, } from "./events.ts"; import { FollowRequestImpl } from "./follow-impl.ts"; +import { InstanceImpl } from "./instance-impl.ts"; import { createMessage, getMessageVisibility, @@ -92,10 +92,17 @@ 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 { KvRepository, type Repository, type 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"; import type { Text } from "./text.ts"; @@ -103,8 +110,42 @@ 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; + + /** + * Whether the bot is a transient view of a dynamically resolved bot, + * which is not registered on the instance. + */ + transient?: boolean; } +/** + * The names of the event handler properties of {@link BotEventHandlers}. + * @internal + */ +export const botEventHandlerNames = [ + "onFollow", + "onUnfollow", + "onAcceptFollow", + "onRejectFollow", + "onMention", + "onReply", + "onQuote", + "onMessage", + "onSharedMessage", + "onLike", + "onUnlike", + "onReact", + "onUnreact", + "onVote", +] as const; + export class BotImpl implements Bot { readonly identifier: string; readonly class: typeof Service | typeof Application; @@ -117,13 +158,48 @@ 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: Repository; - readonly software?: Software; - readonly behindProxy: boolean; - readonly pages: Required; - readonly collectionWindow: number; - readonly federation: Federation; + readonly repository: ActorScopedRepository; + + /** + * 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 + * 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. + */ + get legacyObjectUrisIdentifier(): string | undefined { + return this.instance.legacyObjectUrisIdentifier; + } onFollow?: FollowEventHandler; onUnfollow?: UnfollowEventHandler; @@ -152,118 +228,27 @@ 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); - this.software = options.software; - this.pages = { - color: "green", - css: "", - ...(options.pages ?? {}), - }; - this.federation = createFederation({ + this.instance = options.instance ?? new InstanceImpl({ kv: options.kv, + // 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, - 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 }, + compatMode: true, }); - this.behindProxy = options.behindProxy ?? false; - this.collectionWindow = options.collectionWindow ?? 50; - 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/follow/{id}", - this.dispatchFollow.bind(this), - ) - .authorize(this.authorizeFollow.bind(this)); - this.federation.setObjectDispatcher( - Create, - "/ap/create/{id}", - this.dispatchCreate.bind(this), - ); - this.federation.setObjectDispatcher( - Article, - "/ap/article/{id}", - (ctx, values) => this.dispatchMessage(Article, ctx, values.id), - ); - this.federation.setObjectDispatcher( - ChatMessage, - "/ap/chat-message/{id}", - (ctx, values) => this.dispatchMessage(ChatMessage, ctx, values.id), - ); - this.federation.setObjectDispatcher( - Note, - "/ap/note/{id}", - (ctx, values) => this.dispatchMessage(Note, ctx, values.id), - ); - this.federation.setObjectDispatcher( - Question, - "/ap/question/{id}", - (ctx, values) => this.dispatchMessage(Question, ctx, values.id), - ); - this.federation.setObjectDispatcher( - Announce, - "/ap/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); + if (!options.transient) this.instance.addBot(this); } async getActorSummary( @@ -346,7 +331,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), }); } @@ -484,8 +469,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; @@ -493,8 +479,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; @@ -506,8 +493,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); @@ -534,8 +522,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); @@ -546,28 +535,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( @@ -620,8 +600,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, ); @@ -644,8 +633,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; @@ -681,12 +679,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 ( @@ -810,7 +813,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 ( @@ -833,12 +837,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 ( @@ -878,12 +887,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); @@ -914,13 +928,20 @@ 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) ) { + // A local object owned by another bot is not this bot's to report; + // the owner receives the activity through its own routing: + if (objectUri.values.identifier !== this.identifier) return undefined; const msg = await this.repository.getMessage(objectUri.values.id as Uuid); if (msg instanceof Create) object = await msg.getObject(ctx); } else { @@ -990,13 +1011,20 @@ 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) ) { + // A local object owned by another bot is not this bot's to report; + // the owner receives the activity through its own routing: + if (objectUri.values.identifier !== this.identifier) return undefined; const msg = await this.repository.getMessage(objectUri.values.id as Uuid); if (msg instanceof Create) object = await msg.getObject(ctx); } else { @@ -1046,23 +1074,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( @@ -1082,106 +1095,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 ( - 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( @@ -1189,63 +1116,448 @@ 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 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; +} + +/** + * 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); + } +} + +/** + * The internal implementation of a {@link BotGroup}: a registry of event + * handlers shared by every bot its dispatcher resolves. + * @internal + */ +export class BotGroupImpl implements BotGroup { + readonly instance: InstanceImpl; + readonly dispatcher: BotDispatcher; + readonly mapUsername?: ( + ctx: Context, + username: string, + ) => string | null | Promise; + + onFollow?: FollowEventHandler; + onUnfollow?: UnfollowEventHandler; + onAcceptFollow?: AcceptEventHandler; + onRejectFollow?: RejectEventHandler; + onMention?: MentionEventHandler; + onReply?: ReplyEventHandler; + onQuote?: QuoteEventHandler; + onMessage?: MessageEventHandler; + onSharedMessage?: SharedMessageEventHandler; + onLike?: LikeEventHandler; + onUnlike?: UnlikeEventHandler; + onReact?: ReactionEventHandler; + onUnreact?: UndoneReactionEventHandler; + onVote?: VoteEventHandler; + + constructor( + instance: InstanceImpl, + dispatcher: BotDispatcher, + options: CreateBotGroupOptions = {}, + ) { + this.instance = instance; + this.dispatcher = dispatcher; + this.mapUsername = options.mapUsername; + } + + async getSession( + origin: string | URL, + identifier: string, + contextData: TContextData, + ): Promise> { + const ctx = this.instance.federation.createContext( + new URL(origin), + contextData, + ); + const bot = await this.instance.resolveBot(ctx, identifier); + if (bot == null || !(bot instanceof GroupBotImpl) || bot.group !== this) { + throw new TypeError( + `The group's dispatcher does not resolve the identifier: ${identifier}`, + ); + } + return bot.getSession(ctx); + } +} + +/** + * A transient per-bot view of a dynamically resolved bot. It behaves like + * a regular {@link BotImpl}, except that its event handlers are read live + * from the owning {@link BotGroupImpl} at dispatch time, so handlers + * registered on the group after a bot was resolved still fire. Views are + * not registered on the instance and live only as long as the resolution + * cache of the request that produced them. + * @internal + */ +export class GroupBotImpl extends BotImpl { + readonly group: BotGroupImpl; + + constructor( + group: BotGroupImpl, + identifier: string, + profile: BotProfile, + ) { + super({ + instance: group.instance, + transient: true, + identifier, + kv: group.instance.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, + }); + this.group = group; + for (const name of botEventHandlerNames) { + // Class fields would shadow prototype accessors, so the live views + // into the group's handlers are defined per instance: + globalThis.Object.defineProperty(this, name, { + get: () => group[name], + configurable: true, + }); } - return emojiMap; } } diff --git a/packages/botkit/src/bot.test.ts b/packages/botkit/src/bot.test.ts index 724ba87..76a811a 100644 --- a/packages/botkit/src/bot.test.ts +++ b/packages/botkit/src/bot.test.ts @@ -18,7 +18,8 @@ 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 { 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"; import type { Like } from "./reaction.ts"; @@ -119,3 +120,63 @@ 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; +}); + +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"]); +}); diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index beaf79b..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, @@ -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. */ @@ -385,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/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 ( <>