Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions changes.d/botkit/account-aliases.md
Original file line number Diff line number Diff line change
@@ -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]]
99 changes: 99 additions & 0 deletions docs/concepts/bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,21 @@ const bot = createBot<void>({

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:
Expand Down Expand Up @@ -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<void>({
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
---------------

Expand Down
8 changes: 7 additions & 1 deletion docs/concepts/instance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
------------
Expand Down
5 changes: 3 additions & 2 deletions docs/concepts/session.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 151 additions & 0 deletions packages/botkit/src/account-aliases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// BotKit by Fedify: A framework for creating ActivityPub bots
// Copyright (C) 2025–2026 Hong Minhee <https://hongminhee.org/>
//
// 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 <https://www.gnu.org/licenses/>.
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<void>({
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<void>({
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<void>({ 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<void>({ 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);
});
8 changes: 8 additions & 0 deletions packages/botkit/src/bot-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
readonly properties: Record<string, Text<"block" | "inline", TContextData>>;
#properties: { pairs: PropertyValue[]; tags: (Link | Object)[] } | null;
readonly followerPolicy: "accept" | "reject" | "manual";
readonly aliases: readonly URL[];
readonly quotePolicy: QuotePolicyOption;
readonly repository: ActorScopedRepository;

Expand Down Expand Up @@ -266,6 +267,7 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
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";
Expand Down Expand Up @@ -344,6 +346,8 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
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,
Expand Down Expand Up @@ -2194,6 +2198,9 @@ export function wrapBotImpl<TContextData>(
): Bot<TContextData> {
const wrapper = {
impl: bot,
get aliases() {
return bot.aliases;
},
get federation() {
return bot.federation;
},
Expand Down Expand Up @@ -2748,6 +2755,7 @@ export class GroupBotImpl<TContextData> extends BotImpl<TContextData> {
icon: profile.icon,
image: profile.image,
properties: profile.properties,
aliases: profile.aliases,
followerPolicy: profile.followerPolicy,
quotePolicy: profile.quotePolicy,
});
Expand Down
Loading
Loading