diff --git a/CHANGES.md b/CHANGES.md index 4da9e20..2c4d3c2 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,10 @@ To be released. ### @fedify/botkit + - Added an `aliases` option to `CreateBotOptions` and `BotProfile` so + existing accounts can move their followers to a BotKit bot. Actor URIs + listed in the option are published as `alsoKnownAs`, and are available + through `Bot.aliases` and `Session.bot.aliases`. [[#48], [#55]] - Added names, inline summaries, and custom URLs when publishing or updating messages. Updated messages can remove these fields by setting them to `null`, and the new `inline` template composes paragraphless rich text for @@ -33,8 +37,10 @@ To be released. [#41]: https://github.com/fedify-dev/botkit/issues/41 [#42]: https://github.com/fedify-dev/botkit/pull/42 [#43]: https://github.com/fedify-dev/botkit/pull/43 +[#48]: https://github.com/fedify-dev/botkit/issues/48 [#52]: https://github.com/fedify-dev/botkit/issues/52 [#53]: https://github.com/fedify-dev/botkit/pull/53 +[#55]: https://github.com/fedify-dev/botkit/pull/55 Version 0.5.6 diff --git a/changes.d/botkit/account-aliases.md b/changes.d/botkit/account-aliases.md new file mode 100644 index 0000000..e455caa --- /dev/null +++ b/changes.d/botkit/account-aliases.md @@ -0,0 +1,9 @@ +--- +links: + '#48': https://github.com/fedify-dev/botkit/issues/48 + '#55': https://github.com/fedify-dev/botkit/pull/55 +--- + - Added an `aliases` option to `CreateBotOptions` and `BotProfile` so + existing accounts can move their followers to a BotKit bot. Actor URIs + listed in the option are published as `alsoKnownAs`, and are available + through `Bot.aliases` and `Session.bot.aliases`. [[#48], [#55]] diff --git a/docs/concepts/bot.md b/docs/concepts/bot.md index a04cdd0..c15eb46 100644 --- a/docs/concepts/bot.md +++ b/docs/concepts/bot.md @@ -213,6 +213,21 @@ const bot = createBot({ It can be changed after the bot is federated. +### `~CreateBotOptions.aliases` + +*Available since BotKit 0.6.0.* + +The actor URIs of other accounts representing the same bot, published in its +ActivityPub `alsoKnownAs` property. This allows an existing account to move +its followers to the bot. Values must be `URL` objects containing actor +URIs, rather than handles or profile page URLs. BotKit publishes these URIs +without resolving them. + +The default is an empty array. You can read the configured list through +`Bot.aliases` or `Session.bot.aliases`. To change it on a static bot, update +the configuration and redeploy. See [*Moving an existing account to a +bot*](#moving-an-existing-account-to-a-bot) for the migration steps. + ### `~CreateBotOptions.followerPolicy` How to handle incoming follow requests. Possible values are: @@ -420,6 +435,90 @@ See the [design language document][DESIGN.md] for the full system. [*Colors* section]: https://picocss.com/docs/colors +Moving an existing account to a bot +----------------------------------- + +*Available since BotKit 0.6.0.* + +You can move followers from an existing account on Mastodon or another server +that supports [account migration with `Move`][FEP-7628] to a BotKit bot. +Only followers move: posts, accounts you follow, and other account data are +not transferred. + +First, find the old account's *actor URI*. It is the `id` in its ActivityPub +actor document, which may differ from its profile page URL. For example, a +Mastodon account `@mybot@old.example` normally has the actor URI +`https://old.example/users/mybot`, while its profile page is +`https://old.example/@mybot`. Do not put the handle or the profile page URL +in `aliases`: migration checks compare actor URIs exactly. + +To find the URI without guessing the server's URL scheme, query the old +account's [WebFinger] endpoint: + +~~~~ bash +curl 'https://old.example/.well-known/webfinger?resource=acct:mybot@old.example' +~~~~ + +In the response's `links`, find the `rel: "self"` link whose `type` is +`application/activity+json` or `application/ld+json` with the ActivityStreams +profile. Fetch its `href` with an ActivityPub `Accept` header, and use the +returned actor document's `id`: + +~~~~ bash +curl -H 'Accept: application/activity+json' \ + 'https://old.example/users/mybot' +~~~~ + +Configure and deploy the new bot with that URI in `aliases`: + +~~~~ typescript twoslash +import { createBot } from "@fedify/botkit"; +import { MemoryKvStore } from "@fedify/fedify"; + +const bot = createBot({ + username: "mybot", + kv: new MemoryKvStore(), + aliases: [new URL("https://old.example/users/mybot")], +}); + +export default bot; +~~~~ + +This example uses an in-memory store; use +[persistent storage](./repository.md) for a production bot. + +Once the bot is publicly reachable, fetch its actor document and confirm that +`alsoKnownAs` contains the old actor URI. You can find the new actor URI +through the bot's WebFinger endpoint using the same procedure above. +Mastodon refuses to start the move until the target advertises this alias. + +Then sign in to the *old* Mastodon account. Under *Settings → Account → +Moving to a different account*, choose the migration option, enter the new +bot's handle (for example, `@mybot@new.example`), and confirm the move. +Keep the old server online while it sends the migration notifications. +The bot receives ordinary follow requests from servers that support migration; +followers may arrive gradually. + +The default `followerPolicy: "accept"` accepts these requests automatically. +With `"manual"`, accept them in the [`onFollow` handler](./events.md#follow). +A `"reject"` policy rejects them unless your handler accepts them first. +Check the bot's availability and follow policy before starting: Mastodon +imposes a migration cooldown, so you cannot recover by immediately repeating +the move. + +Aliases can be added to a bot that is already federated by updating its +configuration and redeploying. On a multi-bot +[instance](./instance.md), set `BotProfile.aliases` on each static bot or in +the profile returned by a dynamic group's dispatcher. Later requests for a +dynamic bot use the aliases returned by its dispatcher. See +[Mastodon's migration guide] for details about the old account's settings +and restrictions. + +[FEP-7628]: https://w3id.org/fep/7628 +[WebFinger]: https://docs.joinmastodon.org/spec/webfinger/ +[Mastodon's migration guide]: https://docs.joinmastodon.org/user/moving/ + + Running the bot --------------- diff --git a/docs/concepts/instance.md b/docs/concepts/instance.md index 35eb3d7..79c1d6a 100644 --- a/docs/concepts/instance.md +++ b/docs/concepts/instance.md @@ -151,7 +151,7 @@ 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.properties`, `~BotProfile.aliases`, `~BotProfile.class`, and `~BotProfile.followerPolicy`. Identifiers and usernames must be unique across the instance; @@ -164,6 +164,12 @@ 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. +To let an existing account move its followers to a bot, set +`BotProfile.aliases` to an array containing the old actor URI as a `URL`. +The same field is available in +profiles returned by a dynamic dispatcher. See [*Moving an existing account +to a bot*](./bot.md#moving-an-existing-account-to-a-bot) for the full procedure. + Dynamic bots ------------ diff --git a/docs/concepts/session.md b/docs/concepts/session.md index 7866b9a..7ab5e69 100644 --- a/docs/concepts/session.md +++ b/docs/concepts/session.md @@ -176,8 +176,9 @@ Republishing the bot profile ---------------------------- If you change the bot's profile metadata, such as the display name, bio, -avatar, or header image, remote servers may keep showing the old cached -profile until they refresh it themselves. You can explicitly notify your +avatar, header image, or account aliases, remote servers may keep showing +the old cached profile until they refresh it themselves. You can explicitly +notify your followers by calling the `~Session.republishProfile()` method: ~~~~ typescript twoslash diff --git a/packages/botkit/src/account-aliases.test.ts b/packages/botkit/src/account-aliases.test.ts new file mode 100644 index 0000000..f681b8a --- /dev/null +++ b/packages/botkit/src/account-aliases.test.ts @@ -0,0 +1,151 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { MemoryKvStore } from "@fedify/fedify/federation"; +import { Application, Service } from "@fedify/vocab"; +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createBot } from "./bot.ts"; +import { createInstance } from "./instance.ts"; + +const aliases: readonly URL[] = Object.freeze([ + new URL("https://old.example/users/mybot"), + new URL("https://other.example/actors/mybot"), +]); + +for (const actorClass of [Service, Application]) { + test(`createBot() publishes aliases for ${actorClass.name}`, async () => { + const bot = createBot({ + kv: new MemoryKvStore(), + username: "mybot", + class: actorClass, + aliases, + }); + assert.deepStrictEqual(bot.aliases, aliases); + const session = bot.getSession("https://example.com"); + assert.deepStrictEqual(session.bot.aliases, aliases); + const actor = await session.getActor(); + assert.deepStrictEqual(actor.aliasIds, aliases); + const response = await bot.fetch( + new Request( + "https://example.com/ap/actor/bot", + { headers: { Accept: "application/activity+json" } }, + ), + ); + assert.strictEqual(response.status, 200); + const json = await response.json(); + assert.deepStrictEqual(json.alsoKnownAs, aliases.map((uri) => uri.href)); + }); +} + +for (const configured of [undefined, []] as const) { + test(`createBot() defaults aliases to [] (${configured == null ? "omitted" : "explicit"})`, async () => { + const bot = createBot({ + kv: new MemoryKvStore(), + username: "mybot", + aliases: configured, + }); + assert.deepStrictEqual(bot.aliases, []); + assert.deepStrictEqual( + bot.getSession("https://example.com").bot.aliases, + [], + ); + const response = await bot.fetch( + new Request( + "https://example.com/ap/actor/bot", + { headers: { Accept: "application/activity+json" } }, + ), + ); + assert.strictEqual(response.status, 200); + const json = await response.json(); + assert.ok(!("alsoKnownAs" in json)); + }); +} + +test("static instance bots keep aliases per bot", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + const first = instance.createBot("first", { username: "first", aliases }); + const second = instance.createBot("second", { username: "second" }); + const empty = instance.createBot("empty", { username: "empty", aliases: [] }); + assert.deepStrictEqual(first.aliases, aliases); + assert.deepStrictEqual(second.aliases, []); + assert.deepStrictEqual(empty.aliases, []); + for (const bot of [first, second, empty]) { + const session = bot.getSession("https://example.com"); + assert.deepStrictEqual(session.bot.aliases, bot.aliases); + assert.deepStrictEqual((await session.getActor()).aliasIds, bot.aliases); + const response = await instance.fetch( + new Request( + `https://example.com/ap/actor/${bot.identifier}`, + { headers: { Accept: "application/activity+json" } }, + ), + ); + assert.strictEqual(response.status, 200); + const json = await response.json(); + if (bot === first) { + assert.deepStrictEqual(json.alsoKnownAs, aliases.map((uri) => uri.href)); + } else { + assert.ok(!("alsoKnownAs" in json)); + } + } +}); + +test("dynamic group bots publish and refresh per-bot aliases", async () => { + const instance = createInstance({ kv: new MemoryKvStore() }); + let currentAliases = aliases; + const group = instance.createBot((_ctx, identifier) => { + if (identifier === "first") { + return { username: identifier, aliases: currentAliases }; + } + if (identifier === "second") return { username: identifier }; + if (identifier === "empty") return { username: identifier, aliases: [] }; + return null; + }); + for (const identifier of ["first", "second", "empty"]) { + const session = await group.getSession("https://example.com", identifier); + const expected = identifier === "first" ? aliases : []; + assert.deepStrictEqual(session.bot.aliases, expected); + assert.deepStrictEqual((await session.getActor()).aliasIds, expected); + const response = await instance.fetch( + new Request( + `https://example.com/ap/actor/${identifier}`, + { headers: { Accept: "application/activity+json" } }, + ), + ); + assert.strictEqual(response.status, 200); + const json = await response.json(); + if (identifier === "first") { + assert.deepStrictEqual(json.alsoKnownAs, aliases.map((uri) => uri.href)); + } else { + assert.ok(!("alsoKnownAs" in json)); + } + } + currentAliases = [new URL("https://new.example/users/mybot")]; + const response = await instance.fetch( + new Request( + "https://example.com/ap/actor/first", + { headers: { Accept: "application/activity+json" } }, + ), + ); + assert.strictEqual(response.status, 200); + // Fedify compacts a single alias to a string. + assert.deepStrictEqual( + (await response.json()).alsoKnownAs, + currentAliases[0].href, + ); + const session = await group.getSession("https://example.com", "first"); + assert.deepStrictEqual(session.bot.aliases, currentAliases); + assert.deepStrictEqual((await session.getActor()).aliasIds, currentAliases); +}); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 4238dec..468a335 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -193,6 +193,7 @@ export class BotImpl implements Bot { readonly properties: Record>; #properties: { pairs: PropertyValue[]; tags: (Link | Object)[] } | null; readonly followerPolicy: "accept" | "reject" | "manual"; + readonly aliases: readonly URL[]; readonly quotePolicy: QuotePolicyOption; readonly repository: ActorScopedRepository; @@ -266,6 +267,7 @@ export class BotImpl implements Bot { this.icon = options.icon; this.image = options.image; this.properties = options.properties ?? {}; + this.aliases = options.aliases ?? []; this.#properties = null; this.followerPolicy = options.followerPolicy ?? "accept"; this.quotePolicy = options.quotePolicy ?? "public"; @@ -344,6 +346,8 @@ export class BotImpl implements Bot { return new this.class({ id: ctx.getActorUri(identifier), preferredUsername: this.username, + // Fedify may mutate its array during lazy alias resolution. + aliases: [...this.aliases], name: this.name, summary: summary == null ? null : summary.text, attachments: pairs, @@ -2194,6 +2198,9 @@ export function wrapBotImpl( ): Bot { const wrapper = { impl: bot, + get aliases() { + return bot.aliases; + }, get federation() { return bot.federation; }, @@ -2748,6 +2755,7 @@ export class GroupBotImpl extends BotImpl { icon: profile.icon, image: profile.image, properties: profile.properties, + aliases: profile.aliases, followerPolicy: profile.followerPolicy, quotePolicy: profile.quotePolicy, }); diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index d11e18f..c922913 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -177,6 +177,13 @@ export interface BotEventHandlers { * @since 0.5.0 */ export interface ReadonlyBot { + /** + * The URIs of other actors that represent the same bot, published as + * the actor's `alsoKnownAs`. Defaults to an empty array. + * @since 0.6.0 + */ + readonly aliases: readonly URL[]; + /** * The internal identifier for the bot actor. It is used for the actor URI. */ @@ -223,6 +230,13 @@ export interface ReadonlyBot { * A bot that can interact with the ActivityPub network. */ export interface Bot extends BotEventHandlers { + /** + * The URIs of other actors that represent the same bot, published as + * the actor's `alsoKnownAs`. Defaults to an empty array. + * @since 0.6.0 + */ + readonly aliases: readonly URL[]; + /** * An internal Fedify federation instance. Normally you don't need to access * this directly. @@ -375,6 +389,16 @@ export interface CreateBotOptions { */ readonly properties?: Record>; + /** + * The URIs of other actors that represent the same bot, published as + * the actor's `alsoKnownAs`. An account elsewhere can move its followers + * to this bot only if its actor URI is listed here. Use actor URIs, not + * handles or profile page URLs. It can be changed after the bot is federated. + * @default `[]` + * @since 0.6.0 + */ + readonly aliases?: readonly URL[]; + /** * How to handle incoming follow requests. Note that this behavior can be * overridden by manually invoking {@link FollowRequest.accept} or diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index 89beef7..2b6e596 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -504,6 +504,7 @@ export class InstanceImpl icon: profile.icon, image: profile.image, properties: profile.properties, + aliases: profile.aliases, followerPolicy: profile.followerPolicy, quotePolicy: profile.quotePolicy, }); diff --git a/packages/botkit/src/instance.ts b/packages/botkit/src/instance.ts index e462da1..70b1ef7 100644 --- a/packages/botkit/src/instance.ts +++ b/packages/botkit/src/instance.ts @@ -82,6 +82,16 @@ export interface BotProfile { */ readonly properties?: Record>; + /** + * The URIs of other actors that represent the same bot, published as + * the actor's `alsoKnownAs`. An account elsewhere can move its followers + * to this bot only if its actor URI is listed here. Use actor URIs, not + * handles or profile page URLs. It can be changed after the bot is federated. + * @default `[]` + * @since 0.6.0 + */ + readonly aliases?: readonly URL[]; + /** * How to handle incoming follow requests. Note that this behavior can * be overridden by manually invoking {@link FollowRequest.accept} or diff --git a/packages/botkit/src/text.test.ts b/packages/botkit/src/text.test.ts index 6ced91f..3b72732 100644 --- a/packages/botkit/src/text.test.ts +++ b/packages/botkit/src/text.test.ts @@ -120,6 +120,7 @@ federation.setActorDispatcher("/ap/actor/{identifier}", (ctx, identifier) => { const bot: BotWithVoidContextData = { federation, identifier: "bot", + aliases: [], getSession(origin: string | URL | Context, _contextData?: void) { const ctx = typeof origin === "string" || origin instanceof URL ? federation.createContext(new URL(origin)) @@ -127,6 +128,7 @@ const bot: BotWithVoidContextData = { return { bot: { identifier: "bot", + aliases: [], username: "bot", class: Service, followerPolicy: "accept",