From bcc0031c9ba4f847cbaf1acc588bc41e6154ce95 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Fri, 2 Oct 2026 20:26:30 +0900 Subject: [PATCH] Allow accounts to move followers to bots Publish configured actor aliases as alsoKnownAs so existing accounts can move their followers to standalone, static, and dynamic bots. Expose the aliases through Bot and session views and document how to prepare the target before starting a migration. Codex assisted the implementation and documentation. Claude Code reviewed and refined the design and implementation plan. Fixes https://github.com/fedify-dev/botkit/issues/48 Assisted-by: Codex:gpt-6.1-sol Assisted-by: Claude Code:claude-fable-5-1 --- CHANGES.md | 6 + changes.d/botkit/account-aliases.md | 9 ++ docs/concepts/bot.md | 99 +++++++++++++ docs/concepts/instance.md | 8 +- docs/concepts/session.md | 5 +- packages/botkit/src/account-aliases.test.ts | 151 ++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 8 ++ packages/botkit/src/bot.ts | 24 ++++ packages/botkit/src/instance-impl.ts | 1 + packages/botkit/src/instance.ts | 10 ++ packages/botkit/src/text.test.ts | 2 + 11 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 changes.d/botkit/account-aliases.md create mode 100644 packages/botkit/src/account-aliases.test.ts 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",