From b9ea3945d7264d224df306bbcf37e19f8f1cef84 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Wed, 31 Dec 2025 11:56:10 +0900 Subject: [PATCH 1/7] Add type definitions for multi-bot instance support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add new types for supporting multiple bots per instance: - BotInfo: Interface for bot identity information - BotProfile: Profile configuration for creating bots - Instance: Server instance interface that can host multiple bots - BotDispatcher: Function type for dynamic bot resolution - CreateInstanceOptions: Options for creating an instance - createInstance(): Factory function (placeholder implementation) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 --- packages/botkit/src/bot.ts | 84 ++++++++++++ packages/botkit/src/instance.ts | 220 ++++++++++++++++++++++++++++++++ packages/botkit/src/mod.ts | 9 ++ 3 files changed, 313 insertions(+) create mode 100644 packages/botkit/src/instance.ts diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index e1b5be7..10af355 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -49,6 +49,90 @@ export { } from "@fedify/fedify/nodeinfo"; export { Application, Image, Service } from "@fedify/fedify/vocab"; +/** + * Information about a bot's identity. This is used for accessing the current + * bot's identity from a {@link Session}. + * @since 0.4.0 + */ +export interface BotInfo { + /** + * 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 | undefined; + + /** + * The type of the bot actor. It is either `Service` or `Application`. + */ + readonly class: typeof Service | typeof Application; +} + +/** + * Profile configuration for creating a bot. This is used by + * {@link Instance.createBot} for both static and dynamic bot creation. + * @since 0.4.0 + */ +export interface BotProfile { + /** + * The username of the bot. It will be 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 should be either `Service` or `Application`. + * + * If omitted, `Service` will be used. + * @default `Service` + */ + readonly class?: typeof Service | typeof Application; + + /** + * The description of the bot. + */ + readonly summary?: Text<"block", TContextData>; + + /** + * The avatar URL of the bot. + */ + readonly icon?: URL | Image; + + /** + * The header image URL of the bot. + */ + readonly image?: URL | Image; + + /** + * The custom properties of the bot. + */ + readonly properties?: Record>; + + /** + * How to handle incoming follow requests. Note that this behavior can be + * overridden by manually invoking {@link FollowRequest.accept} or + * {@link FollowRequest.reject} in the {@link Bot.onFollow} event handler. + * + * - `"accept"` (default): Automatically accept all incoming follow requests. + * - `"reject"`: Automatically reject all incoming follow requests. + * - `"manual"`: Require manual handling of incoming follow requests. + * @default `"accept"` + */ + readonly followerPolicy?: "accept" | "reject" | "manual"; +} + /** * A bot that can interact with the ActivityPub network. */ diff --git a/packages/botkit/src/instance.ts b/packages/botkit/src/instance.ts new file mode 100644 index 0000000..e03391c --- /dev/null +++ b/packages/botkit/src/instance.ts @@ -0,0 +1,220 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import type { + Context, + Federation, + KvStore, + MessageQueue, +} from "@fedify/fedify/federation"; +import type { Software } from "@fedify/fedify/nodeinfo"; +import type { Bot, BotProfile, PagesOptions } from "./bot.ts"; +import type { Repository } from "./repository.ts"; + +/** + * A function that dispatches a bot profile for a given identifier. + * Used for creating dynamic bots that are resolved on-demand. + * + * @param ctx The Fedify context. + * @param identifier The identifier to resolve. + * @returns The bot profile if the identifier matches, or `null` otherwise. + * @since 0.4.0 + */ +export type BotDispatcher = ( + ctx: Context, + identifier: string, +) => BotProfile | null | Promise | null>; + +/** + * A server instance that can host multiple bots. An instance manages + * shared infrastructure (KV store, message queue, HTTP handling) while + * allowing multiple bots to be registered and managed independently. + * + * @since 0.4.0 + */ +export interface Instance { + /** + * An internal Fedify federation instance. Normally you don't need to access + * this directly. + */ + readonly federation: Federation; + + /** + * Creates a static bot with a fixed identifier and profile. + * + * @example + * ```typescript + * const greetBot = instance.createBot("greet", { + * username: "greetbot", + * name: "Greeting Bot", + * }); + * + * greetBot.onFollow = async (session, { follower, followRequest }) => { + * await followRequest.accept(); + * await session.publish(text`Welcome, ${follower}!`); + * }; + * ``` + * + * @param identifier The internal identifier for the bot (used in actor URI). + * @param profile The profile configuration for the bot. + * @returns The created bot. + */ + createBot( + identifier: string, + profile: BotProfile, + ): Bot; + + /** + * Creates dynamic bots using a dispatcher function. The dispatcher is + * called when an identifier needs to be resolved, allowing for on-demand + * bot creation from a database or other data source. + * + * @example + * ```typescript + * const weatherBots = instance.createBot(async (ctx, identifier) => { + * // Return null for identifiers this dispatcher doesn't handle + * if (!identifier.startsWith("weather_")) return null; + * + * // Look up the region from the database + * const regionCode = identifier.slice("weather_".length); + * const region = await db.getRegion(regionCode); + * if (region == null) return null; + * + * // Return the bot profile + * return { + * username: identifier, + * name: `${region.name} Weather Bot`, + * }; + * }); + * + * weatherBots.onMention = async (session, { message }) => { + * const regionCode = session.botInfo.identifier.slice("weather_".length); + * const weather = await fetchWeather(regionCode); + * await session.publish(text`Current weather: ${weather}`); + * }; + * ``` + * + * @param dispatcher A function that returns a bot profile for a given + * identifier, or `null` if the identifier doesn't match. + * @returns A bot handle for registering event handlers. The handlers + * will be invoked for any bot resolved by this dispatcher. + */ + createBot(dispatcher: BotDispatcher): Bot; + + /** + * The fetch API for handling HTTP requests. You can pass this to an HTTP + * server (e.g., `Deno.serve()`, `Bun.serve()`) to handle incoming requests. + * + * @param request The request to handle. + * @param contextData The context data to pass to the federation. + * @returns The response to the request. + */ + fetch(request: Request, contextData: TContextData): Promise; +} + +/** + * A specialized {@link Instance} type that doesn't require context data. + * @since 0.4.0 + */ +export interface InstanceWithVoidContextData extends Instance { + /** + * The fetch API for handling HTTP requests. You can pass this to an HTTP + * server (e.g., `Deno.serve()`, `Bun.serve()`) to handle incoming requests. + * + * @param request The request to handle. + * @returns The response to the request. + */ + fetch(request: Request): Promise; +} + +/** + * Options for creating an instance. + * @since 0.4.0 + */ +export interface CreateInstanceOptions { + /** + * The underlying key-value store to use for storing data. + */ + readonly kv: KvStore; + + /** + * The underlying repository to use for storing data. If omitted, + * {@link KvRepository} will be used with bot-scoped prefixes. + */ + readonly repository?: Repository; + + /** + * The underlying message queue to use for handling incoming and outgoing + * activities. If omitted, incoming activities are processed immediately, + * and outgoing activities are sent immediately. + */ + readonly queue?: MessageQueue; + + /** + * The software information of the instance. If omitted, the NodeInfo + * protocol will be unimplemented. + */ + readonly software?: Software; + + /** + * Whether to trust `X-Forwarded-*` headers. If your instance is + * behind an L7 reverse proxy, turn it on. + * + * Turned off by default. + * @default `false` + */ + readonly behindProxy?: boolean; + + /** + * The options for the web pages of the bots. If omitted, the default + * options will be used. + */ + readonly pages?: PagesOptions; +} + +/** + * Creates an {@link Instance} that can host multiple bots. + * + * @example + * ```typescript + * import { createInstance, text } from "@fedify/botkit"; + * + * const instance = createInstance({ kv: new DenoKvStore(kv) }); + * + * const greetBot = instance.createBot("greet", { + * username: "greetbot", + * name: "Greeting Bot", + * }); + * + * greetBot.onFollow = async (session, { follower, followRequest }) => { + * await followRequest.accept(); + * await session.publish(text`Welcome, ${follower}!`); + * }; + * + * export default instance; + * ``` + * + * @param options The options for creating the instance. + * @returns The created instance. + * @since 0.4.0 + */ +export function createInstance( + options: CreateInstanceOptions, +): TContextData extends void ? InstanceWithVoidContextData + : Instance { + // TODO: Implement InstanceImpl and return it here + void options; + throw new Error("createInstance is not yet implemented"); +} diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index 6df3de3..44fe039 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -22,6 +22,8 @@ export { export { Application, type Bot, + type BotInfo, + type BotProfile, type BotWithVoidContextData, createBot, type CreateBotOptions, @@ -32,6 +34,13 @@ export { Service, type Software, } from "./bot.ts"; +export { + type BotDispatcher, + createInstance, + type CreateInstanceOptions, + type Instance, + type InstanceWithVoidContextData, +} from "./instance.ts"; export { type CustomEmoji, type DeferredCustomEmoji, From 456b238316fd15f374ff9aad13e8e35f87f54da2 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Wed, 31 Dec 2025 12:02:43 +0900 Subject: [PATCH 2/7] Add repository scoping for multi-bot support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add createScopedPrefixes() and createLegacyPrefixes() functions - Update SqliteRepository to support botId scoping - Add bot_id column to all SQLite tables with auto-migration - All queries now filter by bot_id for data isolation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 --- packages/botkit-sqlite/src/mod.ts | 294 ++++++++++++++++++++---------- packages/botkit/src/mod.ts | 3 + packages/botkit/src/repository.ts | 49 +++++ 3 files changed, 249 insertions(+), 97 deletions(-) diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index 0673019..d8d57ee 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -50,6 +50,19 @@ export interface SqliteRepositoryOptions { * @default true */ readonly wal?: boolean; + + /** + * The bot identifier to scope the repository to. + * When provided, all data will be scoped to this bot identifier, + * allowing multiple bots to share the same database. + * + * If not provided, the repository will use an empty string as the bot ID, + * which is suitable for single-bot instances. + * + * @default "" + * @since 0.4.0 + */ + readonly botId?: string; } /** @@ -58,15 +71,17 @@ export interface SqliteRepositoryOptions { */ export class SqliteRepository implements Repository, Disposable { private readonly db: DatabaseSync; + private readonly botId: string; /** * Creates a new SQLite repository. * @param options The options for creating the repository. */ constructor(options: SqliteRepositoryOptions = {}) { - const { path = ":memory:", wal = true } = options; + const { path = ":memory:", wal = true, botId = "" } = options; this.db = new DatabaseSync(path); + this.botId = botId; // Enable foreign key constraints this.db.exec("PRAGMA foreign_keys = ON;"); @@ -90,94 +105,162 @@ export class SqliteRepository implements Repository, Disposable { } private initializeTables(): void { - // Key pairs table + // Key pairs table (with bot_id for multi-bot support) this.db.exec(` CREATE TABLE IF NOT EXISTS key_pairs ( id INTEGER PRIMARY KEY, + bot_id TEXT NOT NULL DEFAULT '', private_key_jwk TEXT NOT NULL, public_key_jwk TEXT NOT NULL ) `); - // Messages table + // Add bot_id column if it doesn't exist (migration) + this.migrateAddBotIdColumn("key_pairs"); + + // Create index on bot_id for key_pairs + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_key_pairs_bot_id ON key_pairs(bot_id) + `); + + // Messages table (with bot_id for multi-bot support) this.db.exec(` CREATE TABLE IF NOT EXISTS messages ( - id TEXT PRIMARY KEY, + id TEXT NOT NULL, + bot_id TEXT NOT NULL DEFAULT '', activity_json TEXT NOT NULL, - published INTEGER + published INTEGER, + PRIMARY KEY (id, bot_id) ) `); + // Add bot_id column if it doesn't exist (migration) + this.migrateAddBotIdColumn("messages"); + // 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 + // Followers table (with bot_id for multi-bot support) this.db.exec(` CREATE TABLE IF NOT EXISTS followers ( - follower_id TEXT PRIMARY KEY, - actor_json TEXT NOT NULL + follower_id TEXT NOT NULL, + bot_id TEXT NOT NULL DEFAULT '', + actor_json TEXT NOT NULL, + PRIMARY KEY (follower_id, bot_id) ) `); - // Follow requests mapping table + // Add bot_id column if it doesn't exist (migration) + this.migrateAddBotIdColumn("followers"); + + // Follow requests mapping table (with bot_id for multi-bot support) this.db.exec(` CREATE TABLE IF NOT EXISTS follow_requests ( - follow_request_id TEXT PRIMARY KEY, + follow_request_id TEXT NOT NULL, + bot_id TEXT NOT NULL DEFAULT '', follower_id TEXT NOT NULL, - FOREIGN KEY (follower_id) REFERENCES followers(follower_id) + PRIMARY KEY (follow_request_id, bot_id) ) `); - // Sent follows table + // Add bot_id column if it doesn't exist (migration) + this.migrateAddBotIdColumn("follow_requests"); + + // Sent follows table (with bot_id for multi-bot support) this.db.exec(` CREATE TABLE IF NOT EXISTS sent_follows ( - id TEXT PRIMARY KEY, - follow_json TEXT NOT NULL + id TEXT NOT NULL, + bot_id TEXT NOT NULL DEFAULT '', + follow_json TEXT NOT NULL, + PRIMARY KEY (id, bot_id) ) `); - // Followees table + // Add bot_id column if it doesn't exist (migration) + this.migrateAddBotIdColumn("sent_follows"); + + // Followees table (with bot_id for multi-bot support) this.db.exec(` CREATE TABLE IF NOT EXISTS followees ( - followee_id TEXT PRIMARY KEY, - follow_json TEXT NOT NULL + followee_id TEXT NOT NULL, + bot_id TEXT NOT NULL DEFAULT '', + follow_json TEXT NOT NULL, + PRIMARY KEY (followee_id, bot_id) ) `); - // Poll votes table + // Add bot_id column if it doesn't exist (migration) + this.migrateAddBotIdColumn("followees"); + + // Poll votes table (with bot_id for multi-bot support) this.db.exec(` CREATE TABLE IF NOT EXISTS poll_votes ( message_id TEXT NOT NULL, + bot_id TEXT NOT NULL DEFAULT '', voter_id TEXT NOT NULL, option TEXT NOT NULL, - PRIMARY KEY (message_id, voter_id, option) + PRIMARY KEY (message_id, bot_id, voter_id, option) ) `); + // Add bot_id column if it doesn't exist (migration) + this.migrateAddBotIdColumn("poll_votes"); + // 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) `); } + /** + * Migrates a table to add bot_id column if it doesn't exist. + * This is for backward compatibility with existing databases. + */ + private migrateAddBotIdColumn(tableName: string): void { + try { + const stmt = this.db.prepare( + `SELECT COUNT(*) as count FROM pragma_table_info('${tableName}') WHERE name = 'bot_id'`, + ); + const row = stmt.get() as { count: number }; + if (row.count === 0) { + // bot_id column doesn't exist, add it + this.db.exec( + `ALTER TABLE ${tableName} ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''`, + ); + logger.info( + `Migrated table ${tableName} to add bot_id column for multi-bot support`, + ); + } + } catch { + // Table might have been just created with bot_id, ignore + } + } + async setKeyPairs(keyPairs: CryptoKeyPair[]): Promise { - const deleteStmt = this.db.prepare("DELETE FROM key_pairs"); + 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(this.botId); 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( + this.botId, + JSON.stringify(privateJwk), + JSON.stringify(publicJwk), + ); } this.db.exec("COMMIT"); @@ -189,9 +272,9 @@ export class SqliteRepository implements Repository, Disposable { async getKeyPairs(): Promise { const stmt = this.db.prepare(` - SELECT private_key_jwk, public_key_jwk FROM key_pairs + SELECT private_key_jwk, public_key_jwk FROM key_pairs WHERE bot_id = ? `); - const rows = stmt.all() as Array<{ + const rows = stmt.all(this.botId) as Array<{ private_key_jwk: string; public_key_jwk: string; }>; @@ -214,8 +297,8 @@ export class SqliteRepository implements Repository, Disposable { async addMessage(id: Uuid, activity: Create | Announce): Promise { const stmt = this.db.prepare(` - INSERT INTO messages (id, activity_json, published) - VALUES (?, ?, ?) + INSERT INTO messages (id, bot_id, activity_json, published) + VALUES (?, ?, ?, ?) `); const activityJson = JSON.stringify( @@ -223,7 +306,7 @@ export class SqliteRepository implements Repository, Disposable { ); const published = activity.published?.epochMilliseconds ?? null; - stmt.run(id, activityJson, published); + stmt.run(id, this.botId, activityJson, published); } async updateMessage( @@ -233,9 +316,11 @@ export class SqliteRepository implements Repository, Disposable { ) => Create | Announce | undefined | Promise, ): Promise { const selectStmt = this.db.prepare(` - SELECT activity_json FROM messages WHERE id = ? + SELECT activity_json FROM messages WHERE id = ? AND bot_id = ? `); - const row = selectStmt.get(id) as { activity_json: string } | undefined; + const row = selectStmt.get(id, this.botId) as + | { activity_json: string } + | undefined; if (!row) return false; @@ -250,9 +335,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 id = ? AND bot_id = ? `); const newActivityJson = JSON.stringify( @@ -260,22 +345,24 @@ export class SqliteRepository implements Repository, Disposable { ); const published = newActivity.published?.epochMilliseconds ?? null; - updateStmt.run(newActivityJson, published, id); + updateStmt.run(newActivityJson, published, id, this.botId); return true; } async removeMessage(id: Uuid): Promise { const selectStmt = this.db.prepare(` - SELECT activity_json FROM messages WHERE id = ? + SELECT activity_json FROM messages WHERE id = ? AND bot_id = ? `); - const row = selectStmt.get(id) as { activity_json: string } | undefined; + const row = selectStmt.get(id, this.botId) as + | { activity_json: string } + | undefined; if (!row) return undefined; const deleteStmt = this.db.prepare(` - DELETE FROM messages WHERE id = ? + DELETE FROM messages WHERE id = ? AND bot_id = ? `); - deleteStmt.run(id); + deleteStmt.run(id, this.botId); try { const activityData = JSON.parse(row.activity_json); @@ -296,8 +383,8 @@ export class SqliteRepository implements Repository, Disposable { ): 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)[] = [this.botId]; if (since != null) { sql += " AND published >= ?"; @@ -338,9 +425,11 @@ export class SqliteRepository implements Repository, Disposable { async getMessage(id: Uuid): Promise { const stmt = this.db.prepare(` - SELECT activity_json FROM messages WHERE id = ? + SELECT activity_json FROM messages WHERE id = ? AND bot_id = ? `); - const row = stmt.get(id) as { activity_json: string } | undefined; + const row = stmt.get(id, this.botId) as + | { activity_json: string } + | undefined; if (!row) return undefined; @@ -359,8 +448,10 @@ export class SqliteRepository implements Repository, Disposable { } countMessages(): Promise { - const stmt = this.db.prepare("SELECT COUNT(*) as count FROM messages"); - const row = stmt.get() as { count: number }; + const stmt = this.db.prepare( + "SELECT COUNT(*) as count FROM messages WHERE bot_id = ?", + ); + const row = stmt.get(this.botId) as { count: number }; return Promise.resolve(row.count); } @@ -374,19 +465,19 @@ 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 (follower_id, bot_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 (follow_request_id, bot_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(follower.id.href, this.botId, followerJson); + insertRequestStmt.run(followRequestId.href, this.botId, follower.id.href); this.db.exec("COMMIT"); } catch (error) { this.db.exec("ROLLBACK"); @@ -400,32 +491,34 @@ export class SqliteRepository implements Repository, Disposable { ): 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.follower_id = f.follower_id AND fr.bot_id = f.bot_id + WHERE fr.follow_request_id = ? AND fr.bot_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(followRequestId.href, this.botId, 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 follow_request_id = ? AND bot_id = ? `); const deleteFollowerStmt = this.db.prepare(` - DELETE FROM followers WHERE follower_id = ? + DELETE FROM followers WHERE follower_id = ? AND bot_id = ? `); this.db.exec("BEGIN TRANSACTION"); try { - deleteRequestStmt.run(followRequestId.href); - deleteFollowerStmt.run(actorId.href); + deleteRequestStmt.run(followRequestId.href, this.botId); + deleteFollowerStmt.run(actorId.href, this.botId); this.db.exec("COMMIT"); } catch (error) { this.db.exec("ROLLBACK"); @@ -448,9 +541,9 @@ export class SqliteRepository implements Repository, Disposable { hasFollower(followerId: URL): Promise { const stmt = this.db.prepare(` - SELECT 1 FROM followers WHERE follower_id = ? + SELECT 1 FROM followers WHERE follower_id = ? AND bot_id = ? `); - const row = stmt.get(followerId.href); + const row = stmt.get(followerId.href, this.botId); return Promise.resolve(row != null); } @@ -459,8 +552,9 @@ export class SqliteRepository implements Repository, Disposable { ): 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: (string | number)[] = [this.botId]; if (limit != null) { sql += " LIMIT ? OFFSET ?"; @@ -489,39 +583,43 @@ 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 }; + const stmt = this.db.prepare( + "SELECT COUNT(*) as count FROM followers WHERE bot_id = ?", + ); + const row = stmt.get(this.botId) as { count: number }; return Promise.resolve(row.count); } async addSentFollow(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 (id, bot_id, follow_json) + VALUES (?, ?, ?) `); const followJson = JSON.stringify( await follow.toJsonLd({ format: "compact" }), ); - stmt.run(id, followJson); + stmt.run(id, this.botId, followJson); } async removeSentFollow(id: Uuid): Promise { const follow = await this.getSentFollow(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 id = ? AND bot_id = ?", + ); + stmt.run(id, this.botId); return follow; } async getSentFollow(id: Uuid): Promise { const stmt = this.db.prepare(` - SELECT follow_json FROM sent_follows WHERE id = ? + SELECT follow_json FROM sent_follows WHERE id = ? AND bot_id = ? `); - const row = stmt.get(id) as { follow_json: string } | undefined; + const row = stmt.get(id, this.botId) as { follow_json: string } | undefined; if (!row) return undefined; @@ -536,32 +634,34 @@ export class SqliteRepository implements Repository, Disposable { async addFollowee(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 (followee_id, bot_id, follow_json) + VALUES (?, ?, ?) `); const followJson = JSON.stringify( await follow.toJsonLd({ format: "compact" }), ); - stmt.run(followeeId.href, followJson); + stmt.run(followeeId.href, this.botId, followJson); } async removeFollowee(followeeId: URL): Promise { const follow = await this.getFollowee(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 followee_id = ? AND bot_id = ?", + ); + stmt.run(followeeId.href, this.botId); return follow; } async getFollowee(followeeId: URL): Promise { const stmt = this.db.prepare(` - SELECT follow_json FROM followees WHERE followee_id = ? + SELECT follow_json FROM followees WHERE followee_id = ? AND bot_id = ? `); - const row = stmt.get(followeeId.href) as + const row = stmt.get(followeeId.href, this.botId) as | { follow_json: string } | undefined; @@ -581,32 +681,32 @@ export class SqliteRepository implements Repository, Disposable { vote(messageId: Uuid, voterId: URL, option: string): Promise { const stmt = this.db.prepare(` - INSERT OR IGNORE INTO poll_votes (message_id, voter_id, option) - VALUES (?, ?, ?) + INSERT OR IGNORE INTO poll_votes (message_id, bot_id, voter_id, option) + VALUES (?, ?, ?, ?) `); - stmt.run(messageId, voterId.href, option); + stmt.run(messageId, this.botId, voterId.href, option); return Promise.resolve(); } countVoters(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 message_id = ? AND bot_id = ? `); - const row = stmt.get(messageId) as { count: number }; + const row = stmt.get(messageId, this.botId) as { count: number }; return Promise.resolve(row.count); } countVotes(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 message_id = ? AND bot_id = ? GROUP BY option `); - const rows = stmt.all(messageId) as Array<{ + const rows = stmt.all(messageId, this.botId) as Array<{ option: string; count: number; }>; diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index 44fe039..59a24d7 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -85,7 +85,10 @@ export { export { Announce, Create, + createLegacyPrefixes, + createScopedPrefixes, KvRepository, + type KvStoreRepositoryPrefixes, MemoryCachedRepository, MemoryRepository, type Repository, diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index f12ee94..241b879 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -328,6 +328,55 @@ export interface KvStoreRepositoryPrefixes { readonly polls: KvKey; } +/** + * Creates repository prefixes scoped to a specific bot identifier. + * This is used for multi-bot instances where each bot needs isolated storage. + * + * @example + * ```typescript + * const prefixes = createScopedPrefixes("mybot"); + * // prefixes.keyPairs = ["_botkit", "bots", "mybot", "keyPairs"] + * // prefixes.messages = ["_botkit", "bots", "mybot", "messages"] + * // etc. + * ``` + * + * @param identifier The bot identifier to scope the prefixes to. + * @returns The scoped prefixes for the bot. + * @since 0.4.0 + */ +export function createScopedPrefixes( + identifier: string, +): KvStoreRepositoryPrefixes { + return { + keyPairs: ["_botkit", "bots", identifier, "keyPairs"], + messages: ["_botkit", "bots", identifier, "messages"], + followers: ["_botkit", "bots", identifier, "followers"], + followRequests: ["_botkit", "bots", identifier, "followRequests"], + followees: ["_botkit", "bots", identifier, "followees"], + follows: ["_botkit", "bots", identifier, "follows"], + polls: ["_botkit", "bots", identifier, "polls"], + }; +} + +/** + * Creates legacy (unscoped) repository prefixes. + * This is used for backward compatibility with single-bot instances. + * + * @returns The legacy prefixes without bot scoping. + * @since 0.4.0 + */ +export function createLegacyPrefixes(): KvStoreRepositoryPrefixes { + return { + keyPairs: ["_botkit", "keyPairs"], + messages: ["_botkit", "messages"], + followers: ["_botkit", "followers"], + followRequests: ["_botkit", "followRequests"], + followees: ["_botkit", "followees"], + follows: ["_botkit", "follows"], + polls: ["_botkit", "polls"], + }; +} + /** * A repository for storing bot data using a key-value store. */ From c5b865ecb74549d92d14721c99497653b68fc341 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Wed, 31 Dec 2025 12:12:25 +0900 Subject: [PATCH 3/7] Add botInfo property to Session interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add BotInfo import to session.ts - Add botInfo property to Session interface with docs - Implement botInfo getter in SessionImpl - Update text.test.ts mock to include botInfo 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude Opus 4.5 --- packages/botkit/src/session-impl.ts | 10 ++++++++++ packages/botkit/src/session.ts | 14 +++++++++++++- packages/botkit/src/text.test.ts | 15 ++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packages/botkit/src/session-impl.ts b/packages/botkit/src/session-impl.ts index 92d2330..17462ab 100644 --- a/packages/botkit/src/session-impl.ts +++ b/packages/botkit/src/session-impl.ts @@ -29,6 +29,7 @@ import { getLogger } from "@logtape/logtape"; import { encode } from "html-entities"; import { v7 as uuidv7 } from "uuid"; import type { BotImpl } from "./bot-impl.ts"; +import type { BotInfo } from "./bot.ts"; import { createMessage, isMessageObject } from "./message-impl.ts"; import { type AuthorizedMessage, @@ -84,6 +85,15 @@ export class SessionImpl implements Session { return `@${this.bot.username}@${this.context.host}` as const; } + get botInfo(): BotInfo { + return { + identifier: this.bot.identifier, + username: this.bot.username, + name: this.bot.name, + class: this.bot.class, + }; + } + async getActor(): Promise { return (await this.bot.dispatchActor(this.context, this.bot.identifier))!; } diff --git a/packages/botkit/src/session.ts b/packages/botkit/src/session.ts index 73fccba..72cf628 100644 --- a/packages/botkit/src/session.ts +++ b/packages/botkit/src/session.ts @@ -23,7 +23,7 @@ import type { Question, } from "@fedify/fedify"; import type { LanguageTag } from "@phensley/language-tag"; -import type { Bot } from "./bot.ts"; +import type { Bot, BotInfo } from "./bot.ts"; import type { AuthorizedMessage, Message, @@ -42,6 +42,18 @@ export interface Session { */ readonly bot: Bot; + /** + * Information about the current bot's identity. This provides a lightweight + * way to access the bot's identity (identifier, username, name, class) + * without needing to access the full bot object. + * + * This is particularly useful for dynamic bots where handlers need to + * determine which specific bot is being invoked. + * + * @since 0.4.0 + */ + readonly botInfo: BotInfo; + /** * The Fedify context of the session. */ diff --git a/packages/botkit/src/text.test.ts b/packages/botkit/src/text.test.ts index ceccf11..c626677 100644 --- a/packages/botkit/src/text.test.ts +++ b/packages/botkit/src/text.test.ts @@ -20,7 +20,14 @@ import { } from "@fedify/fedify/federation"; import { getDocumentLoader } from "@fedify/fedify/runtime"; import { importJwk } from "@fedify/fedify/sig"; -import { Emoji, Hashtag, Image, Mention, Person } from "@fedify/fedify/vocab"; +import { + Emoji, + Hashtag, + Image, + Mention, + Person, + Service, +} from "@fedify/fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; import { BotImpl } from "./bot-impl.ts"; @@ -120,6 +127,12 @@ const bot: BotWithVoidContextData = { : origin; return { bot, + botInfo: { + identifier: "bot", + username: "bot", + name: undefined, + class: Service, + }, context: ctx, actorId: ctx.getActorUri(bot.identifier), actorHandle: `@bot@${ctx.host}` as const, From cb02ecbf48f3f1ecdfaad99d1471074d2e3af141 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Wed, 31 Dec 2025 12:52:02 +0900 Subject: [PATCH 4/7] Add InstanceImpl class for multi-bot support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement InstanceImpl class that manages multiple bots sharing a single Federation instance. The class supports: - Static bot creation with fixed identifier and profile - Dynamic bot creation with dispatcher function for on-demand resolution - Shared dispatchers that route to the correct bot based on identifier - Inbox routing to delegate activities to the appropriate bot handlers - Repository scanning for object ownership lookup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/botkit/src/instance-impl.ts | 813 +++++++++++++++++++++++++++ 1 file changed, 813 insertions(+) create mode 100644 packages/botkit/src/instance-impl.ts diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts new file mode 100644 index 0000000..dcad914 --- /dev/null +++ b/packages/botkit/src/instance-impl.ts @@ -0,0 +1,813 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { + type Context, + createFederation, + type Federation, + type InboxContext, + type KvStore, + type MessageQueue, + type NodeInfo, + type PageItems, + type Recipient, + type RequestContext, + type Software, +} from "@fedify/fedify"; +import { + Accept, + type Activity, + type Actor, + Announce, + Article, + ChatMessage, + Create, + type CryptographicKey, + Emoji as APEmoji, + Follow, + Like as RawLike, + Note, + Question, + Reject, + Undo, +} from "@fedify/fedify/vocab"; +import { getLogger } from "@logtape/logtape"; +import fs from "node:fs/promises"; +import { getXForwardedRequest } from "x-forwarded-fetch"; +import metadata from "../deno.json" with { type: "json" }; +import type { Bot, BotProfile, PagesOptions } from "./bot.ts"; +import { BotImpl } from "./bot-impl.ts"; +import type { CustomEmoji } from "./emoji.ts"; +import type { MessageClass } from "./message.ts"; +import { app } from "./pages.tsx"; +import { createScopedPrefixes, KvRepository, type Uuid } from "./repository.ts"; +import type { + BotDispatcher, + CreateInstanceOptions, + Instance, +} from "./instance.ts"; + +const logger = getLogger(["botkit", "instance"]); + +interface DynamicDispatcherEntry { + dispatcher: BotDispatcher; + template: BotImpl; +} + +export interface InstanceImplOptions + extends CreateInstanceOptions { + collectionWindow?: number; +} + +export class InstanceImpl implements Instance { + readonly federation: Federation; + readonly kv: KvStore; + readonly queue?: MessageQueue; + readonly software?: Software; + readonly behindProxy: boolean; + readonly pages: Required; + readonly collectionWindow: number; + + readonly #staticBots: Map>; + readonly #dynamicDispatchers: DynamicDispatcherEntry[]; + readonly #dynamicBotCache: Map>; + + constructor(options: InstanceImplOptions) { + this.kv = options.kv; + this.queue = options.queue; + this.software = options.software; + this.behindProxy = options.behindProxy ?? false; + this.pages = { + color: "green", + css: "", + ...(options.pages ?? {}), + }; + this.collectionWindow = options.collectionWindow ?? 50; + + this.#staticBots = new Map(); + this.#dynamicDispatchers = []; + this.#dynamicBotCache = new Map(); + + this.federation = createFederation({ + kv: options.kv, + queue: options.queue, + userAgent: { + software: `BotKit/${metadata.version}`, + }, + }); + + 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( + 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") + .on(Follow, this.#onFollowed.bind(this)) + .on(Undo, this.#onUndo.bind(this)) + .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), + ); + } + } + + async #resolveBot( + ctx: Context, + identifier: string, + ): Promise | null> { + // Check static bots first + const staticBot = this.#staticBots.get(identifier); + if (staticBot != null) return staticBot; + + // Check dynamic bot cache + const cachedBot = this.#dynamicBotCache.get(identifier); + if (cachedBot != null) return cachedBot; + + // Try dynamic dispatchers + for (const { dispatcher, template } of this.#dynamicDispatchers) { + const profile = await dispatcher(ctx, identifier); + if (profile != null) { + // Create a new bot instance with the resolved profile + const repository = new KvRepository( + this.kv, + createScopedPrefixes(identifier), + ); + const bot = new BotImpl({ + identifier, + username: profile.username, + name: profile.name, + class: profile.class, + summary: profile.summary, + icon: profile.icon, + image: profile.image, + properties: profile.properties, + followerPolicy: profile.followerPolicy, + kv: this.kv, + repository, + queue: this.queue, + software: this.software, + behindProxy: this.behindProxy, + pages: this.pages, + collectionWindow: this.collectionWindow, + }); + // Copy event handlers from template + bot.onFollow = template.onFollow; + bot.onUnfollow = template.onUnfollow; + bot.onAcceptFollow = template.onAcceptFollow; + bot.onRejectFollow = template.onRejectFollow; + bot.onMention = template.onMention; + bot.onReply = template.onReply; + bot.onQuote = template.onQuote; + bot.onMessage = template.onMessage; + bot.onSharedMessage = template.onSharedMessage; + bot.onLike = template.onLike; + bot.onUnlike = template.onUnlike; + bot.onReact = template.onReact; + bot.onUnreact = template.onUnreact; + bot.onVote = template.onVote; + + // Cache the resolved bot + this.#dynamicBotCache.set(identifier, bot); + return bot; + } + } + + return null; + } + + #getAllBots(): BotImpl[] { + return [ + ...this.#staticBots.values(), + ...this.#dynamicBotCache.values(), + ]; + } + + // Actor dispatcher + async #dispatchActor( + ctx: Context, + identifier: string, + ): Promise { + const bot = await this.#resolveBot(ctx, identifier); + if (bot == null) return null; + return bot.dispatchActor(ctx, identifier); + } + + #mapHandle(_ctx: Context, username: string): string | null { + // Check static bots + for (const [identifier, bot] of this.#staticBots) { + if (bot.username === username) return identifier; + } + // Dynamic bots cannot be resolved by username without identifier + return null; + } + + async #dispatchActorKeyPairs( + ctx: Context, + identifier: string, + ): Promise { + const bot = await this.#resolveBot(ctx, identifier); + if (bot == null) return []; + return bot.dispatchActorKeyPairs(ctx, identifier); + } + + // Collection dispatchers + async #dispatchFollowers( + ctx: Context, + identifier: string, + cursor: string | null, + ): Promise | null> { + const bot = await this.#resolveBot(ctx, identifier); + if (bot == null) return null; + return bot.dispatchFollowers(ctx, identifier, cursor); + } + + #getFollowersFirstCursor( + ctx: Context, + identifier: string, + ): string | null { + const bot = this.#staticBots.get(identifier) ?? + this.#dynamicBotCache.get(identifier); + if (bot == null) return null; + return bot.getFollowersFirstCursor(ctx, identifier); + } + + async #countFollowers( + ctx: Context, + identifier: string, + ): Promise { + const bot = await this.#resolveBot(ctx, identifier); + if (bot == null) return null; + return bot.countFollowers(ctx, identifier); + } + + async #dispatchOutbox( + ctx: RequestContext, + identifier: string, + cursor: string | null, + ): Promise | null> { + const bot = await this.#resolveBot(ctx, identifier); + if (bot == null) return null; + return bot.dispatchOutbox(ctx, identifier, cursor); + } + + #getOutboxFirstCursor( + ctx: Context, + identifier: string, + ): string | null { + const bot = this.#staticBots.get(identifier) ?? + this.#dynamicBotCache.get(identifier); + if (bot == null) return null; + return bot.getOutboxFirstCursor(ctx, identifier); + } + + async #countOutbox( + ctx: Context, + identifier: string, + ): Promise { + const bot = await this.#resolveBot(ctx, identifier); + if (bot == null) return null; + return bot.countOutbox(ctx, identifier); + } + + // Object dispatchers - need to scan all bots' repositories + async #dispatchFollow( + ctx: RequestContext, + values: { id: string }, + ): Promise { + for (const bot of this.#getAllBots()) { + const follow = await bot.dispatchFollow(ctx, values); + if (follow != null) return follow; + } + return null; + } + + async #authorizeFollow( + ctx: RequestContext, + values: { id: string }, + signedKey: CryptographicKey | null, + signedKeyOwner: Actor | null, + ): Promise { + for (const bot of this.#getAllBots()) { + const authorized = await bot.authorizeFollow( + ctx, + values, + signedKey, + signedKeyOwner, + ); + if (authorized) return true; + } + return false; + } + + async #dispatchMessage( + // deno-lint-ignore no-explicit-any + cls: new (values: any) => T, + ctx: Context | RequestContext, + id: string, + ): Promise { + for (const bot of this.#getAllBots()) { + const message = await bot.dispatchMessage(cls, ctx, id); + if (message != null) return message; + } + return null; + } + + async #dispatchAnnounce( + ctx: RequestContext, + values: { id: string }, + ): Promise { + for (const bot of this.#getAllBots()) { + const announce = await bot.dispatchAnnounce(ctx, values); + if (announce != null) return announce; + } + return null; + } + + #dispatchEmoji( + ctx: Context, + values: { name: string }, + ): APEmoji | null { + for (const bot of this.#getAllBots()) { + const emoji = bot.dispatchEmoji(ctx, values); + if (emoji != null) return emoji; + } + return null; + } + + #dispatchSharedKey(_ctx: Context): { identifier: string } { + // Return the first static bot's identifier, or a default + const firstBot = this.#staticBots.values().next().value; + return { identifier: firstBot?.identifier ?? "bot" }; + } + + #dispatchNodeInfo(_ctx: Context): NodeInfo { + const botCount = this.#staticBots.size + this.#dynamicBotCache.size; + return { + software: this.software!, + protocols: ["activitypub"], + services: { + outbound: ["atom1.0"], + }, + usage: { + users: { + total: botCount, + activeMonth: botCount, + activeHalfyear: botCount, + }, + localPosts: 0, + localComments: 0, + }, + }; + } + + // Inbox handlers - route to correct bot + async #onFollowed( + ctx: InboxContext, + follow: Follow, + ): Promise { + const parsed = ctx.parseUri(follow.objectId); + if (parsed?.type !== "actor") return; + + const bot = await this.#resolveBot(ctx, parsed.identifier); + if (bot == null) return; + + await bot.onFollowed(ctx, follow); + } + + async #onUndo(ctx: InboxContext, undo: Undo): Promise { + const object = await undo.getObject(ctx); + if (object instanceof Follow) { + // Route to the bot that was being followed + const parsed = ctx.parseUri(object.objectId); + if (parsed?.type !== "actor") return; + + const bot = await this.#resolveBot(ctx, parsed.identifier); + if (bot == null) return; + + await bot.onUnfollowed(ctx, undo); + } else if (object instanceof RawLike) { + // Route to the bot whose message was liked + const objectUri = ctx.parseUri(object.objectId); + if (objectUri?.type !== "object") { + // External object - try all bots + for (const bot of this.#getAllBots()) { + await bot.onUnliked(ctx, undo); + } + return; + } + + // Find which bot owns the message + for (const bot of this.#getAllBots()) { + const msg = await bot.repository.getMessage( + objectUri.values.id as Uuid, + ); + if (msg != null) { + await bot.onUnliked(ctx, undo); + return; + } + } + } else { + logger.warn( + "The Undo object {undoId} is not about Follow or Like: {object}.", + { undoId: undo.id?.href, object }, + ); + } + } + + async #onFollowAccepted( + ctx: InboxContext, + accept: Accept, + ): Promise { + const parsedObj = ctx.parseUri(accept.objectId); + if (parsedObj?.type !== "object" || parsedObj.class !== Follow) return; + + // Find which bot sent this follow request + for (const bot of this.#getAllBots()) { + const follow = await bot.repository.getSentFollow( + parsedObj.values.id as Uuid, + ); + if (follow != null) { + await bot.onFollowAccepted(ctx, accept); + return; + } + } + } + + async #onFollowRejected( + ctx: InboxContext, + reject: Reject, + ): Promise { + const parsedObj = ctx.parseUri(reject.objectId); + if (parsedObj?.type !== "object" || parsedObj.class !== Follow) return; + + // Find which bot sent this follow request + for (const bot of this.#getAllBots()) { + const follow = await bot.repository.getSentFollow( + parsedObj.values.id as Uuid, + ); + if (follow != null) { + await bot.onFollowRejected(ctx, reject); + return; + } + } + } + + async #onCreated( + ctx: InboxContext, + create: Create, + ): Promise { + // Route to all bots - each bot's onCreated will check + // if the activity is relevant (mentions, replies, quotes, etc.) + for (const bot of this.#getAllBots()) { + await bot.onCreated(ctx, create); + } + } + + async #onAnnounced( + ctx: InboxContext, + announce: Announce, + ): Promise { + // Route to all bots that have onSharedMessage handler + for (const bot of this.#getAllBots()) { + if (bot.onSharedMessage != null) { + await bot.onAnnounced(ctx, announce); + } + } + } + + async #onLiked( + ctx: InboxContext, + like: RawLike, + ): Promise { + const objectUri = ctx.parseUri(like.objectId); + if (objectUri?.type !== "object") { + // External object - try all bots + for (const bot of this.#getAllBots()) { + await bot.onLiked(ctx, like); + } + return; + } + + // Find which bot owns the message + for (const bot of this.#getAllBots()) { + const msg = await bot.repository.getMessage(objectUri.values.id as Uuid); + if (msg != null) { + await bot.onLiked(ctx, like); + return; + } + } + } + + // Public API + createBot( + identifierOrDispatcher: string | BotDispatcher, + profile?: BotProfile, + ): Bot { + if (typeof identifierOrDispatcher === "string") { + // Static bot creation + const identifier = identifierOrDispatcher; + if (profile == null) { + throw new TypeError("Profile is required for static bot creation"); + } + if (this.#staticBots.has(identifier)) { + throw new TypeError( + `Bot with identifier "${identifier}" already exists`, + ); + } + + const repository = new KvRepository( + this.kv, + createScopedPrefixes(identifier), + ); + const bot = new BotImpl({ + identifier, + username: profile.username, + name: profile.name, + class: profile.class, + summary: profile.summary, + icon: profile.icon, + image: profile.image, + properties: profile.properties, + followerPolicy: profile.followerPolicy, + kv: this.kv, + repository, + queue: this.queue, + software: this.software, + behindProxy: this.behindProxy, + pages: this.pages, + collectionWindow: this.collectionWindow, + }); + + this.#staticBots.set(identifier, bot); + return this.#wrapBot(bot); + } else { + // Dynamic bot creation + const dispatcher = identifierOrDispatcher; + + // Create a template bot for storing event handlers + // This bot won't be used directly, just for handler storage + const template = new BotImpl({ + identifier: "__dynamic_template__", + username: "__dynamic_template__", + kv: this.kv, + repository: new KvRepository(this.kv), + queue: this.queue, + software: this.software, + behindProxy: this.behindProxy, + pages: this.pages, + collectionWindow: this.collectionWindow, + }); + + this.#dynamicDispatchers.push({ dispatcher, template }); + return this.#wrapBot(template); + } + } + + #wrapBot(bot: BotImpl): Bot { + // Wrap BotImpl in a plain object for Deno serve compatibility + return { + get federation() { + return bot.federation; + }, + get identifier() { + return bot.identifier; + }, + getSession(a: unknown, b?: unknown) { + // @ts-ignore: BotImpl.getSession() implements Bot.getSession() + return bot.getSession(a, b); + }, + fetch(request: Request, contextData: TContextData) { + return bot.fetch(request, contextData); + }, + addCustomEmojis( + emojis: 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; + }, + }; + } + + 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/") + ) { + return await this.federation.fetch(request, { contextData }); + } + + // Handle emoji routes for all bots + const match = /^\/emojis\/([a-z0-9-_]+)(?:$|\.)/.exec(url.pathname); + if (match != null) { + for (const bot of this.#getAllBots()) { + const customEmoji = bot.customEmojis[match[1]]; + if (customEmoji != null && "file" in customEmoji) { + 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" + ) { + continue; + } + 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 new Response("Not Found", { status: 404 }); + } + + // For web pages, use the first static bot + const firstBot = this.#staticBots.values().next().value; + if (firstBot != null) { + return await app.fetch(request, { bot: firstBot, contextData }); + } + + return new Response("Not Found", { status: 404 }); + } +} From e161047061ef4d2263afebe29b909f5b786a0555 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Wed, 31 Dec 2025 12:56:14 +0900 Subject: [PATCH 5/7] Update BotImpl to support instance mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add options to BotImpl for integration with Instance: - federation: Accept an existing Federation instead of creating new one - skipInitialize: Skip dispatcher/listener registration when managed by Instance Update InstanceImpl to use these options when creating bots, ensuring all bots share the same Federation instance. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/botkit/src/bot-impl.ts | 16 ++++++++++++++-- packages/botkit/src/instance-impl.ts | 6 ++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 4a6b75e..649077a 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -102,6 +102,16 @@ import type { Text } from "./text.ts"; export interface BotImplOptions extends CreateBotOptions { collectionWindow?: number; + /** + * An existing federation to use instead of creating a new one. + * When provided, `skipInitialize` should also be set to `true`. + */ + federation?: Federation; + /** + * Whether to skip the initialization of the federation. + * Set this to `true` when this bot is managed by an Instance. + */ + skipInitialize?: boolean; } export class BotImpl implements Bot { @@ -159,7 +169,7 @@ export class BotImpl implements Bot { css: "", ...(options.pages ?? {}), }; - this.federation = createFederation({ + this.federation = options.federation ?? createFederation({ kv: options.kv, queue: options.queue, userAgent: { @@ -168,7 +178,9 @@ export class BotImpl implements Bot { }); this.behindProxy = options.behindProxy ?? false; this.collectionWindow = options.collectionWindow ?? 50; - this.initialize(); + if (!options.skipInitialize) { + this.initialize(); + } } initialize(): void { diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index dcad914..2202d83 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -232,6 +232,8 @@ export class InstanceImpl implements Instance { behindProxy: this.behindProxy, pages: this.pages, collectionWindow: this.collectionWindow, + federation: this.federation, + skipInitialize: true, }); // Copy event handlers from template bot.onFollow = template.onFollow; @@ -619,6 +621,8 @@ export class InstanceImpl implements Instance { behindProxy: this.behindProxy, pages: this.pages, collectionWindow: this.collectionWindow, + federation: this.federation, + skipInitialize: true, }); this.#staticBots.set(identifier, bot); @@ -639,6 +643,8 @@ export class InstanceImpl implements Instance { behindProxy: this.behindProxy, pages: this.pages, collectionWindow: this.collectionWindow, + federation: this.federation, + skipInitialize: true, }); this.#dynamicDispatchers.push({ dispatcher, template }); From f1d399d2c9ed00fed21668d1a875ad223689e131 Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Wed, 31 Dec 2025 12:59:53 +0900 Subject: [PATCH 6/7] Implement createInstance function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connect createInstance() to InstanceImpl, enabling multi-bot instance creation. The function returns a wrapper object for Deno serve compatibility. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/botkit/src/instance.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/botkit/src/instance.ts b/packages/botkit/src/instance.ts index e03391c..641b6b9 100644 --- a/packages/botkit/src/instance.ts +++ b/packages/botkit/src/instance.ts @@ -21,6 +21,7 @@ import type { } from "@fedify/fedify/federation"; import type { Software } from "@fedify/fedify/nodeinfo"; import type { Bot, BotProfile, PagesOptions } from "./bot.ts"; +import { InstanceImpl } from "./instance-impl.ts"; import type { Repository } from "./repository.ts"; /** @@ -214,7 +215,24 @@ export function createInstance( options: CreateInstanceOptions, ): TContextData extends void ? InstanceWithVoidContextData : Instance { - // TODO: Implement InstanceImpl and return it here - void options; - throw new Error("createInstance is not yet implemented"); + const instance = new InstanceImpl(options); + // Since `deno serve` does not recognize a class instance having fetch(), + // we wrap an InstanceImpl instance with a plain object. + // See also https://github.com/denoland/deno/issues/24062 + const wrapper = { + get federation() { + return instance.federation; + }, + createBot( + identifierOrDispatcher: string | BotDispatcher, + profile?: BotProfile, + ): Bot { + return instance.createBot(identifierOrDispatcher, profile); + }, + fetch(request: Request, contextData: TContextData): Promise { + return instance.fetch(request, contextData); + }, + } satisfies Instance; + // @ts-ignore: the wrapper implements InstanceWithVoidContextData + return wrapper; } From fe62e6216d4a0e75a0fb9e179184c965ee35ba6e Mon Sep 17 00:00:00 2001 From: Lee Dogeon Date: Wed, 31 Dec 2025 13:03:10 +0900 Subject: [PATCH 7/7] Update changelog for multi-bot instance support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the new multi-bot features in CHANGES.md: - createInstance() function and Instance interface - Bot profile and dispatcher types - Session.botInfo property - Repository scoping utilities - SQLite bot_id scoping with auto-migration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGES.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index ee98199..a67e041 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,25 @@ To be released. ### @fedify/botkit + - Added support for hosting multiple bots on a single instance. [[#16]] + + - Added `createInstance()` function that creates a server instance capable + of hosting multiple bots sharing infrastructure (KV store, message queue, + HTTP handling). + - Added `Instance` interface for managing multiple bots. + - Added `Instance.createBot()` method for creating static bots with a fixed + identifier and profile. + - Added `Instance.createBot()` overload for creating dynamic bots using + a dispatcher function. + - Added `BotDispatcher` type for dynamic bot profile resolution. + - Added `BotProfile` interface for bot profile configuration. + - Added `BotInfo` interface for accessing bot identity from sessions. + - Added `Session.botInfo` property for accessing current bot identity. + - Added `CreateInstanceOptions` interface. + - Added `createScopedPrefixes()` function for per-bot repository scoping. + - The existing `createBot()` function continues to work for single-bot + use cases. + - Added a remote follow button to the web interface. [[#10], [#14] by Hyeonseo Kim] @@ -22,6 +41,16 @@ To be released. [#10]: https://github.com/fedify-dev/botkit/issues/10 [#14]: https://github.com/fedify-dev/botkit/pull/14 +[#16]: https://github.com/fedify-dev/botkit/issues/16 + +### @fedify/botkit-sqlite + + - Added bot-scoped data isolation support for multi-bot instances. [[#16]] + + - Added `botId` option to `SqliteRepositoryOptions` for per-bot data scoping. + - All database tables now include a `bot_id` column for data isolation. + - Existing single-bot databases are automatically migrated with default + empty bot ID for backward compatibility. Version 0.3.1