From 43728f30149f58bd981933fc59bbe45d8e74a49a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 06:30:10 +0900 Subject: [PATCH 01/25] Implement inbound quote authorization Add FEP-044f quote policies to published messages and handle incoming QuoteRequest activities. Accepted quote requests now issue and store QuoteAuthorization stamps, and bot applications can inspect or moderate requests through the new onQuoteRequest handler. Persist quote authorization stamps across the memory, SQLite, and PostgreSQL repositories. Keep duplicate requests idempotent by preserving the first stamp for a quote post, and allow later revocation by quote URL even when the remote quote can no longer be fetched. Document the quote policy and revocation APIs, and record the change for the PR expected to carry this work. https://github.com/fedify-dev/botkit/issues/27 Fixes https://github.com/fedify-dev/botkit/issues/28 https://github.com/fedify-dev/botkit/pull/31 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 37 +++ deno.lock | 1 + docs/concepts/bot.md | 45 ++++ docs/concepts/events.md | 48 ++++ docs/concepts/message.md | 67 +++++ packages/botkit-postgres/src/mod.test.ts | 51 +++- packages/botkit-postgres/src/mod.ts | 150 +++++++++++ packages/botkit-sqlite/src/mod.test.ts | 49 +++- packages/botkit-sqlite/src/mod.ts | 114 ++++++++ packages/botkit/deno.json | 1 + packages/botkit/package.json | 4 + packages/botkit/src/bot-group.test.ts | 64 ++++- packages/botkit/src/bot-impl.test.ts | 314 ++++++++++++++++++++++- packages/botkit/src/bot-impl.ts | 203 ++++++++++++++- packages/botkit/src/bot.ts | 24 ++ packages/botkit/src/events.ts | 13 + packages/botkit/src/instance-impl.ts | 22 ++ packages/botkit/src/instance.ts | 11 + packages/botkit/src/message-impl.test.ts | 21 ++ packages/botkit/src/message-impl.ts | 106 +++++++- packages/botkit/src/message.ts | 28 +- packages/botkit/src/mod.ts | 7 + packages/botkit/src/quote-impl.ts | 123 +++++++++ packages/botkit/src/quote.test.ts | 77 ++++++ packages/botkit/src/quote.ts | 159 ++++++++++++ packages/botkit/src/repository.test.ts | 124 ++++++++- packages/botkit/src/repository.ts | 305 ++++++++++++++++++++++ packages/botkit/src/session-impl.test.ts | 24 ++ packages/botkit/src/session-impl.ts | 6 + packages/botkit/src/session.ts | 9 + packages/botkit/src/text.test.ts | 1 + 31 files changed, 2197 insertions(+), 11 deletions(-) create mode 100644 packages/botkit/src/quote-impl.ts create mode 100644 packages/botkit/src/quote.test.ts create mode 100644 packages/botkit/src/quote.ts diff --git a/CHANGES.md b/CHANGES.md index 8009484..a7f6662 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -47,6 +47,28 @@ To be released. deployments and preserves their behavior, including the web pages served at the root. + - Added inbound support for consent-respecting quote posts using + [FEP-044f]. [[#27], [#28], [#31]] + + BotKit now serializes quote policies on outgoing messages, handles + incoming `QuoteRequest` activities, automatically accepts or rejects them + according to each message's policy, and stores `QuoteAuthorization` stamps + for accepted quotes. Applications can set a default + `CreateBotOptions.quotePolicy`, override it per message with + `Session.publish()` or `AuthorizedMessage.update()`, and moderate pending + requests with the new `Bot.onQuoteRequest` event handler. + + - Added `QuotePolicy`, `QuotePolicyOption`, `QuoteRequest`, and + `QuoteRequestEventHandler` types. + - Added `Bot.onQuoteRequest` event handler. + - Added `ReadonlyBot.quotePolicy`, `CreateBotOptions.quotePolicy`, and + `BotProfile.quotePolicy` properties. + - Added `SessionPublishOptions.quotePolicy` and + `AuthorizedMessageUpdateOptions.quotePolicy` options. + - Added `AuthorizedMessage.unauthorizeQuote()` method for revoking an + existing quote authorization stamp by the quoted message or its URI. + - Added `@fedify/botkit/quote` module. + - The `Repository` interface now stores data for multiple bot actors: every method takes the identifier of the owning bot actor as its first parameter, and data belonging to different identifiers are isolated from @@ -56,6 +78,11 @@ To be released. - Added `identifier` parameter to all `Repository` methods. - Added `Repository.findFollowedBots()` method, a reverse lookup answering which bots follow a given actor. + - Added quote authorization storage methods: + `Repository.addQuoteAuthorization()`, + `Repository.getQuoteAuthorization()`, + `Repository.findQuoteAuthorization()`, and + `Repository.removeQuoteAuthorization()`. - Added optional `Repository.migrate()` method for adopting data stored by BotKit 0.4 or earlier. - Added `Repository.forIdentifier()` method and `ActorScopedRepository` @@ -85,11 +112,18 @@ To be released. - Upgraded Fedify to 2.3.1, Hono to 4.12.27, LogTape to 2.2.3, and Markdown It to 14.3.0. +[FEP-044f]: https://w3id.org/fep/044f [#16]: https://github.com/fedify-dev/botkit/issues/16 [#24]: https://github.com/fedify-dev/botkit/pull/24 +[#27]: https://github.com/fedify-dev/botkit/issues/27 +[#28]: https://github.com/fedify-dev/botkit/issues/28 +[#31]: https://github.com/fedify-dev/botkit/pull/31 ### @fedify/botkit-sqlite + - Added a `quote_authorizations` table for [FEP-044f] quote authorization + stamps. [[#27], [#28], [#31]] + - All tables now have a `bot_id` column and composite primary keys, so a single database stores the data of multiple bots. Opening a database created by version 0.4 or earlier rebuilds the affected tables in place, @@ -101,6 +135,9 @@ To be released. ### @fedify/botkit-postgres + - Added a `quote_authorizations` table for [FEP-044f] quote authorization + stamps. [[#27], [#28], [#31]] + - All tables now have a `bot_id` column and composite primary keys, so a single schema stores the data of multiple bots. Initializing a schema created by version 0.4 upgrades it in place, and diff --git a/deno.lock b/deno.lock index 11b04dc..59444f5 100644 --- a/deno.lock +++ b/deno.lock @@ -8,6 +8,7 @@ "jsr:@fedify/markdown-it-mention@0.3": "0.3.0", "jsr:@fedify/uri-template@^2.3.1": "2.3.1", "jsr:@fedify/vocab-runtime@^2.3.1": "2.3.1", + "jsr:@fedify/vocab@2.3.1": "2.3.1", "jsr:@fedify/vocab@^2.3.1": "2.3.1", "jsr:@fedify/webfinger@^2.3.1": "2.3.1", "jsr:@hongminhee/x-forwarded-fetch@0.2": "0.2.0", diff --git a/docs/concepts/bot.md b/docs/concepts/bot.md index 26a939d..1fbc79a 100644 --- a/docs/concepts/bot.md +++ b/docs/concepts/bot.md @@ -226,6 +226,51 @@ It can be changed after the bot is federated. > or `FollowRequest.reject()` in the [`Bot.onFollow`](./events.md#follow) event > handler. +### `~CreateBotOptions.quotePolicy` + +Who can quote messages published by the bot by default. BotKit serializes the +policy into each outgoing message and uses it as the fallback for older stored +messages that do not have a quote policy yet. + +The shorthand values are: + +`"public"` (default) +: Accept every quote request automatically. + +`"followers"` +: Accept quote requests from followers automatically. + +`"manual"` +: Leave every quote request pending for the + [`Bot.onQuoteRequest`](./events.md#quote-request) event handler. + +`"nobody"` +: Reject every quote request except the bot's own requests. + +You can also use the full two-axis policy object: + +~~~~ typescript twoslash +import type { KvStore } from "@fedify/fedify"; + +declare const kv: KvStore; +// ---cut-before--- +import { createBot } from "@fedify/botkit"; + +const bot = createBot({ + username: "mybot", + name: "My Bot", + kv, + quotePolicy: { + automatic: "followers", + manual: "public", + }, +}); +~~~~ + +This accepts followers automatically and sends other quote requests to +`Bot.onQuoteRequest` for manual moderation. A publish call can override the +default with `Session.publish()`'s `quotePolicy` option. + ### `~CreateBotOptions.queue` The message queue for handling incoming and outgoing activities. If omitted, diff --git a/docs/concepts/events.md b/docs/concepts/events.md index cb4a499..c7c8215 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -265,6 +265,54 @@ property. See also the [*Quotes* section](./message.md#quotes) in the *Message* concept document. +Quote request +------------- + +*This API is available since BotKit 0.5.0.* + +The `~Bot.onQuoteRequest` event handler is called when a remote actor asks +permission to quote one of your bot's messages through [FEP-044f]. It +receives a `QuoteRequest` object, whose `quote` property is the remote quote +post and whose `target` property is the bot's quoted message. + +If the message's quote policy automatically accepts or rejects the request, +BotKit sends the response before the event handler is called. The handler can +inspect `~QuoteRequest.state` to see whether the request is still pending: + +~~~~ typescript twoslash +import { type Bot } from "@fedify/botkit"; +const bot = {} as unknown as Bot; +// ---cut-before--- +bot.onQuoteRequest = async (session, request) => { + if (request.state === "pending") { + await request.accept(); + } +}; +~~~~ + +If you later need to revoke a quote authorization, call +`~AuthorizedMessage.unauthorizeQuote()` on the target message with the quote +message: + +~~~~ typescript twoslash +import { type Bot } from "@fedify/botkit"; +const bot = {} as unknown as Bot; +// ---cut-before--- +bot.onQuoteRequest = async (session, request) => { + if (request.state === "accepted") { + await request.target.unauthorizeQuote(request.quote); + } +}; +~~~~ + +Use the `quotePolicy` option on `createBot()` or `Session.publish()` to choose +which quote requests are automatic and which require manual review. For +details, see the [*Quote policy* section](./message.md#quote-policy) in +the *Message* concept document. + +[FEP-044f]: https://w3id.org/fep/044f + + Message ------- diff --git a/docs/concepts/message.md b/docs/concepts/message.md index 061c890..72da1a4 100644 --- a/docs/concepts/message.md +++ b/docs/concepts/message.md @@ -158,6 +158,73 @@ await session.publish(text`Hello, ${mention("@fedify@hollo.social")}!`, { }); ~~~~ +### Quote policy + +You can control who may quote a message by providing +`~SessionPublishOptions.quotePolicy`. BotKit serializes this policy as +the [FEP-044f] interaction policy on the ActivityPub object and uses it when +answering incoming quote requests. + +The shorthand values are: + +`"public"` +: Anyone may quote the message. This is the default. + +`"followers"` +: Followers may quote the message automatically. + +`"nobody"` +: Nobody may quote the message, except the bot itself. + +`"manual"` +: Quote requests are left pending for `~Bot.onQuoteRequest`. + +~~~~ typescript twoslash +import { type Session, text } from "@fedify/botkit"; +const session = {} as unknown as Session; +// ---cut-before--- +await session.publish(text`Followers can quote this.`, { + quotePolicy: "followers", +}); +~~~~ + +You can also use the two-axis form to distinguish actors whose quotes are +approved automatically from actors whose quotes await manual review: + +~~~~ typescript twoslash +import { type Session, text } from "@fedify/botkit"; +const session = {} as unknown as Session; +// ---cut-before--- +await session.publish(text`Followers are reviewed before quoting this.`, { + quotePolicy: { manual: "followers" }, +}); +~~~~ + +The quote policy can be changed when editing a message: + +~~~~ typescript twoslash +import { type Session, text } from "@fedify/botkit"; +const session = {} as unknown as Session; +// ---cut-before--- +const message = await session.publish(text`This starts public.`); +await message.update(text`This is now harder to quote.`, { + quotePolicy: "nobody", +}); +~~~~ + +If a quote was already authorized through an incoming quote request, you can +revoke its authorization stamp from the quoted message: + +~~~~ typescript twoslash +import { type AuthorizedMessage, type Message, type MessageClass } from "@fedify/botkit"; +declare const message: AuthorizedMessage; +declare const quote: Message; +// ---cut-before--- +await message.unauthorizeQuote(quote); +~~~~ + +[FEP-044f]: https://w3id.org/fep/044f + ### Attaching media You can attach media files to a message by providing diff --git a/packages/botkit-postgres/src/mod.test.ts b/packages/botkit-postgres/src/mod.test.ts index dca184a..40505bc 100644 --- a/packages/botkit-postgres/src/mod.test.ts +++ b/packages/botkit-postgres/src/mod.test.ts @@ -18,7 +18,14 @@ import { PostgresRepository, } from "@fedify/botkit-postgres"; import { importJwk } from "@fedify/fedify/sig"; -import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; +import { + Create, + Follow, + Note, + Person, + PUBLIC_COLLECTION, + QuoteAuthorization, +} from "@fedify/vocab"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; import postgres from "postgres"; @@ -129,6 +136,7 @@ if (postgresUrl == null) { "key_pairs", "messages", "poll_votes", + "quote_authorizations", "sent_follows", ], ); @@ -1027,6 +1035,47 @@ if (postgresUrl == null) { await sql.end(); } }); + + test("keeps the first quote authorization for a quote", async () => { + const harness = createHarness(); + try { + const repo = harness.repository; + const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4"; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5"; + const quote = new URL("https://remote.example/notes/quote"); + const target = new URL("https://example.com/ap/note/1"); + const first = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${firstId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + const second = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${secondId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + + await repo.addQuoteAuthorization("bot", firstId, first); + await repo.addQuoteAuthorization("bot", secondId, second); + + assert.deepStrictEqual( + (await repo.findQuoteAuthorization("bot", quote))?.id?.href, + first.id?.href, + ); + assert.deepStrictEqual( + await repo.getQuoteAuthorization("bot", secondId), + undefined, + ); + } finally { + await harness.cleanup(); + } + }); }); } diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index a6dd01d..7c49ea4 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -30,6 +30,7 @@ import { Follow, isActor, Object, + QuoteAuthorization, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import postgres from "postgres"; @@ -48,6 +49,7 @@ const followerAdvisoryLockNamespace = 0x4246; const schemaUpgradeAdvisoryLockNamespace = 0x424b; type Queryable = Pick; +type TransactionalQueryable = Queryable & Pick; type QueryParameter = postgres.SerializableParameter; /** @@ -133,6 +135,37 @@ export async function initializePostgresRepositorySchema( prepare = true, ): Promise { const validatedSchema = validateSchemaName(schema); + if (hasTransaction(sql)) { + await sql.begin(async (tx) => { + await initializePostgresRepositorySchemaInTransaction( + tx, + validatedSchema, + prepare, + ); + }); + return; + } + await initializePostgresRepositorySchemaInTransaction( + sql, + validatedSchema, + prepare, + ); +} + +async function initializePostgresRepositorySchemaInTransaction( + sql: Queryable, + validatedSchema: string, + prepare: boolean, +): Promise { + await execute( + sql, + `SELECT pg_catalog.pg_advisory_xact_lock( + $1, + pg_catalog.hashtext($2) + )`, + [schemaUpgradeAdvisoryLockNamespace, validatedSchema], + prepare, + ); await execute( sql, `CREATE SCHEMA IF NOT EXISTS "${validatedSchema}"`, @@ -242,6 +275,19 @@ export async function initializePostgresRepositorySchema( [], prepare, ); + await execute( + sql, + `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."quote_authorizations" ( + bot_id TEXT NOT NULL, + id TEXT NOT NULL, + interacting_object TEXT NOT NULL, + authorization_json JSONB NOT NULL, + PRIMARY KEY (bot_id, id), + UNIQUE (bot_id, interacting_object) + )`, + [], + prepare, + ); await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."poll_votes" ( @@ -270,6 +316,7 @@ const upgradableTables = [ "follow_requests", "sent_follows", "followees", + "quote_authorizations", "poll_votes", ] as const; @@ -413,6 +460,20 @@ async function upgradeLegacySchema( upgraded := true; END IF; + IF ${legacyTable("quote_authorizations")} THEN + ALTER TABLE "${schema}"."quote_authorizations" + ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; + ALTER TABLE "${schema}"."quote_authorizations" + ALTER COLUMN bot_id DROP DEFAULT; + ALTER TABLE "${schema}"."quote_authorizations" + DROP CONSTRAINT IF EXISTS "quote_authorizations_pkey"; + ALTER TABLE "${schema}"."quote_authorizations" + ADD PRIMARY KEY (bot_id, id); + ALTER TABLE "${schema}"."quote_authorizations" + ADD UNIQUE (bot_id, interacting_object); + upgraded := true; + END IF; + IF ${legacyTable("poll_votes")} THEN ALTER TABLE "${schema}"."poll_votes" ADD COLUMN bot_id TEXT NOT NULL DEFAULT ''; @@ -964,6 +1025,78 @@ export class PostgresRepository implements Repository, AsyncDisposable { for (const row of rows) yield row.bot_id; } + async addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + await this.ensureReady(); + const interactingObject = authorization.interactingObjectId; + if (interactingObject == null) { + throw new TypeError( + "The quote authorization interacting object is missing.", + ); + } + await this.query( + this.sql, + `INSERT INTO ${this.table("quote_authorizations")} + (bot_id, id, interacting_object, authorization_json) + VALUES ($1, $2, $3, $4::jsonb) + ON CONFLICT (bot_id, interacting_object) DO NOTHING`, + [ + identifier, + id, + interactingObject.href, + serializeJson(await authorization.toJsonLd({ format: "compact" })), + ], + ); + } + + async getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + await this.ensureReady(); + const rows = await this.query<{ readonly authorization_json: unknown }>( + this.sql, + `SELECT authorization_json + FROM ${this.table("quote_authorizations")} + WHERE bot_id = $1 AND id = $2`, + [identifier, id], + ); + return await parseQuoteAuthorization(rows[0]?.authorization_json); + } + + async findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise { + await this.ensureReady(); + const rows = await this.query<{ readonly authorization_json: unknown }>( + this.sql, + `SELECT authorization_json + FROM ${this.table("quote_authorizations")} + WHERE bot_id = $1 AND interacting_object = $2`, + [identifier, interactingObject.href], + ); + return await parseQuoteAuthorization(rows[0]?.authorization_json); + } + + async removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + await this.ensureReady(); + const rows = await this.query<{ readonly authorization_json: unknown }>( + this.sql, + `DELETE FROM ${this.table("quote_authorizations")} + WHERE bot_id = $1 AND id = $2 + RETURNING authorization_json`, + [identifier, id], + ); + return await parseQuoteAuthorization(rows[0]?.authorization_json); + } + async vote( identifier: string, messageId: Uuid, @@ -1154,6 +1287,10 @@ function validateSchemaName(schema: string): string { return schema; } +function hasTransaction(sql: Queryable): sql is TransactionalQueryable { + return typeof (sql as Partial).begin === "function"; +} + async function execute( sql: Queryable, query: string, @@ -1214,6 +1351,19 @@ async function parseFollow(json: unknown): Promise { return undefined; } +async function parseQuoteAuthorization( + json: unknown, +): Promise { + const normalized = normalizeJsonObject(json); + if (normalized == null) return undefined; + try { + return await QuoteAuthorization.fromJsonLd(normalized); + } catch (error) { + logger.warn("Failed to parse quote authorization.", { error }); + } + return undefined; +} + function normalizeJsonObject( value: unknown, ): Record | undefined { diff --git a/packages/botkit-sqlite/src/mod.test.ts b/packages/botkit-sqlite/src/mod.test.ts index 61ae35f..0038051 100644 --- a/packages/botkit-sqlite/src/mod.test.ts +++ b/packages/botkit-sqlite/src/mod.test.ts @@ -18,7 +18,14 @@ import { type SqliteRepositoryOptions, } from "@fedify/botkit-sqlite"; import { importJwk } from "@fedify/fedify/sig"; -import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; +import { + Create, + Follow, + Note, + Person, + PUBLIC_COLLECTION, + QuoteAuthorization, +} from "@fedify/vocab"; import assert from "node:assert"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -323,6 +330,46 @@ describe("SqliteRepository", () => { } }); + test("keeps the first quote authorization for a quote", async () => { + const repo = createSqliteRepository(); + try { + const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4"; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5"; + const quote = new URL("https://remote.example/notes/quote"); + const target = new URL("https://example.com/ap/note/1"); + const first = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${firstId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + const second = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${secondId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quote, + interactionTarget: target, + }); + + await repo.addQuoteAuthorization("bot", firstId, first); + await repo.addQuoteAuthorization("bot", secondId, second); + + assert.deepStrictEqual( + (await repo.findQuoteAuthorization("bot", quote))?.id?.href, + first.id?.href, + ); + assert.deepStrictEqual( + await repo.getQuoteAuthorization("bot", secondId), + undefined, + ); + } finally { + repo.close(); + } + }); + test("file-based database persistence", async () => { const tempDir = await mkdtemp(join(tmpdir(), "botkit_sqlite_test_")); const dbPath = `${tempDir}/test.db`; diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index 1e9956e..63b9600 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -29,6 +29,7 @@ import { Follow, isActor, Object, + QuoteAuthorization, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import { DatabaseSync } from "node:sqlite"; @@ -122,6 +123,7 @@ export class SqliteRepository implements Repository, Disposable { "follow_requests", "sent_follows", "followees", + "quote_authorizations", "poll_votes", ].filter((table) => this.tableExists(table) && !this.hasBotIdColumn(table)); if (tables.length < 1) return; @@ -237,6 +239,28 @@ export class SqliteRepository implements Repository, Disposable { this.db.exec("DROP TABLE followees"); this.db.exec("ALTER TABLE followees_new RENAME TO followees"); } + if (tables.includes("quote_authorizations")) { + this.db.exec(` + CREATE TABLE quote_authorizations_new ( + bot_id TEXT NOT NULL, + id TEXT NOT NULL, + interacting_object TEXT NOT NULL, + authorization_json TEXT NOT NULL, + PRIMARY KEY (bot_id, id), + UNIQUE (bot_id, interacting_object) + ) + `); + this.db.exec(` + INSERT INTO quote_authorizations_new + (bot_id, id, interacting_object, authorization_json) + SELECT '', id, interacting_object, authorization_json + FROM quote_authorizations + `); + this.db.exec("DROP TABLE quote_authorizations"); + this.db.exec( + "ALTER TABLE quote_authorizations_new RENAME TO quote_authorizations", + ); + } if (tables.includes("poll_votes")) { this.db.exec(` CREATE TABLE poll_votes_new ( @@ -300,6 +324,7 @@ export class SqliteRepository implements Repository, Disposable { "follow_requests", "sent_follows", "followees", + "quote_authorizations", "poll_votes", ] ) { @@ -403,6 +428,17 @@ export class SqliteRepository implements Repository, Disposable { ON followees(followee_id) `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS quote_authorizations ( + bot_id TEXT NOT NULL, + id TEXT NOT NULL, + interacting_object TEXT NOT NULL, + authorization_json TEXT NOT NULL, + PRIMARY KEY (bot_id, id), + UNIQUE (bot_id, interacting_object) + ) + `); + // Poll votes table this.db.exec(` CREATE TABLE IF NOT EXISTS poll_votes ( @@ -966,6 +1002,72 @@ export class SqliteRepository implements Repository, Disposable { for (const row of rows) yield row.bot_id; } + async addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + const interactingObject = authorization.interactingObjectId; + if (interactingObject == null) { + throw new TypeError( + "The quote authorization interacting object is missing.", + ); + } + const stmt = this.db.prepare(` + INSERT INTO quote_authorizations + (bot_id, id, interacting_object, authorization_json) + VALUES (?, ?, ?, ?) + ON CONFLICT(bot_id, interacting_object) DO NOTHING + `); + stmt.run( + identifier, + id, + interactingObject.href, + JSON.stringify(await authorization.toJsonLd({ format: "compact" })), + ); + } + + async getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const stmt = this.db.prepare(` + SELECT authorization_json FROM quote_authorizations + WHERE bot_id = ? AND id = ? + `); + const row = stmt.get(identifier, id) as + | { authorization_json: string } + | undefined; + return await parseQuoteAuthorizationJson(row?.authorization_json); + } + + async findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise { + const stmt = this.db.prepare(` + SELECT authorization_json FROM quote_authorizations + WHERE bot_id = ? AND interacting_object = ? + `); + const row = stmt.get(identifier, interactingObject.href) as + | { authorization_json: string } + | undefined; + return await parseQuoteAuthorizationJson(row?.authorization_json); + } + + async removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const authorization = await this.getQuoteAuthorization(identifier, id); + if (authorization == null) return undefined; + const stmt = this.db.prepare( + "DELETE FROM quote_authorizations WHERE bot_id = ? AND id = ?", + ); + stmt.run(identifier, id); + return authorization; + } + vote( identifier: string, messageId: Uuid, @@ -1018,3 +1120,15 @@ export class SqliteRepository implements Repository, Disposable { return new ActorScopedRepository(this, identifier); } } + +async function parseQuoteAuthorizationJson( + json: string | undefined, +): Promise { + if (json == null) return undefined; + try { + return await QuoteAuthorization.fromJsonLd(JSON.parse(json)); + } catch (error) { + logger.warn("Failed to parse quote authorization", { error }); + return undefined; + } +} diff --git a/packages/botkit/deno.json b/packages/botkit/deno.json index 4ed54bd..a601524 100644 --- a/packages/botkit/deno.json +++ b/packages/botkit/deno.json @@ -11,6 +11,7 @@ "./instance": "./src/instance.ts", "./message": "./src/message.ts", "./poll": "./src/poll.ts", + "./quote": "./src/quote.ts", "./reaction": "./src/reaction.ts", "./repository": "./src/repository.ts", "./session": "./src/session.ts", diff --git a/packages/botkit/package.json b/packages/botkit/package.json index a54aa28..1ba6448 100644 --- a/packages/botkit/package.json +++ b/packages/botkit/package.json @@ -62,6 +62,10 @@ "types": "./dist/poll.d.ts", "import": "./dist/poll.js" }, + "./quote": { + "types": "./dist/quote.d.ts", + "import": "./dist/quote.js" + }, "./reaction": { "types": "./dist/reaction.d.ts", "import": "./dist/reaction.js" diff --git a/packages/botkit/src/bot-group.test.ts b/packages/botkit/src/bot-group.test.ts index f7af15b..cdbf19d 100644 --- a/packages/botkit/src/bot-group.test.ts +++ b/packages/botkit/src/bot-group.test.ts @@ -14,12 +14,20 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . import { type InboxContext, MemoryKvStore } from "@fedify/fedify/federation"; -import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; +import { + Create, + Follow, + Note, + Person, + PUBLIC_COLLECTION, + QuoteRequest, +} from "@fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; import { InstanceImpl } from "./instance-impl.ts"; import type { BotProfile } from "./instance.ts"; import { MemoryRepository } from "./repository.ts"; +import { text } from "./text.ts"; function createInstance(): { instance: InstanceImpl; @@ -262,6 +270,60 @@ describe("dynamic bots", () => { }); describe("dynamic bots in routing and web pages", () => { + test("receive quote requests for their messages", async () => { + const { instance, repository } = createInstance(); + const group = instance.createBot( + (_ctx, identifier) => regionProfile(identifier), + ); + const events: string[] = []; + group.onQuoteRequest = (session, request) => { + events.push(`${session.bot.identifier}:${request.state}`); + }; + const ctx = instance.federation.createContext( + new URL("https://example.com/"), + undefined, + ) as InboxContext; + Object.defineProperty(ctx, "recipient", { + value: null, + configurable: true, + }); + Object.defineProperty(ctx, "sendActivity", { + value: () => Promise.resolve(), + configurable: true, + }); + const bot = await instance.resolveBot(ctx, "region_kr"); + assert.ok(bot != null); + const target = await bot.getSession(ctx).publish(text`Quote me`); + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor, + quoteUrl: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + + await instance.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(events, ["region_kr:accepted"]); + assert.deepStrictEqual( + (await repository.findQuoteAuthorization("region_kr", quote.id!)) + ?.interactionTargetId, + target.id, + ); + }); + test("receive timeline messages via followees", async () => { const { instance, repository } = createInstance(); const group = instance.createBot( diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index c71e7d9..bb4dc77 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -36,6 +36,7 @@ import { PropertyValue, PUBLIC_COLLECTION, Question, + QuoteRequest, type Recipient, Reject, Service, @@ -47,7 +48,12 @@ import { describe, test } from "node:test"; import { BotImpl } from "./bot-impl.ts"; import type { CustomEmoji } from "./emoji.ts"; import type { FollowRequest } from "./follow.ts"; -import type { Message, MessageClass, SharedMessage } from "./message.ts"; +import type { + AuthorizedMessage, + Message, + MessageClass, + SharedMessage, +} from "./message.ts"; import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; import { MemoryRepository } from "./repository.ts"; @@ -3290,3 +3296,309 @@ test("BotImpl.onLiked() with legacy message URIs", async () => { new URL(`https://example.com/ap/actor/bot/note/${messageId}`), ); }); + +test("BotImpl.onQuoteRequested() accepts public quote requests", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + let targetMessage: AuthorizedMessage | undefined; + let quoteMessage: Message | undefined; + bot.onQuoteRequest = (_session, request) => { + targetMessage = request.target; + quoteMessage = request.quote; + }; + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + const request = new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1"), + actor, + object: target.id, + instrument: quote, + }); + + await bot.onQuoteRequested(ctx, request); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Accept); + assert.deepStrictEqual( + activity.resultId?.href.startsWith( + "https://example.com/ap/actor/bot/quote-authorization/", + ), + true, + ); + const authorization = await repository.findQuoteAuthorization( + "bot", + quote.id!, + ); + assert.deepStrictEqual(authorization?.interactingObjectId, quote.id); + assert.deepStrictEqual(authorization?.interactionTargetId, target.id); + assert.ok(authorization?.id != null); + + ctx.sentActivities = []; + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/1-again"), + actor, + object: target.id, + instrument: quote, + }), + ); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); + assert.deepStrictEqual( + ctx.sentActivities[0].activity.resultId?.href, + authorization.id.href, + ); + + assert.ok(targetMessage != null); + assert.ok(quoteMessage != null); + ctx.sentActivities = []; + await targetMessage.unauthorizeQuote(quoteMessage); + + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 2); + const { recipients: deleteRecipients, activity: deleteActivity } = + ctx.sentActivities[0]; + assert.deepStrictEqual(deleteRecipients, [actor]); + assert.ok(deleteActivity instanceof Delete); + assert.deepStrictEqual(deleteActivity.objectId, authorization.id); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, "followers"); +}); + +test("BotImpl.onQuoteRequested() rejects disallowed quote requests", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "nobody", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Do not quote`, { + quotePolicy: "nobody", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + actor, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/rejected-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/2"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Reject); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + +test("AuthorizedMessage.unauthorizeQuote() checks the target message", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + let quoteMessage: Message | undefined; + bot.onQuoteRequest = (_session, request) => { + quoteMessage = request.quote; + }; + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const firstTarget = await session.publish(text`Quote this`); + const secondTarget = await session.publish(text`Not this`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/quote"), + attribution: actor.id, + quoteUrl: firstTarget.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/3"), + actor, + object: firstTarget.id, + instrument: quote, + }), + ); + + const authorization = await repository.findQuoteAuthorization( + "bot", + quote.id!, + ); + assert.ok(quoteMessage != null); + assert.ok(authorization != null); + const authorizedQuote = quoteMessage; + ctx.sentActivities = []; + await assert.rejects( + () => secondTarget.unauthorizeQuote(authorizedQuote), + /does not belong to this message/, + ); + + assert.deepStrictEqual( + (await repository.findQuoteAuthorization("bot", quote.id!))?.id?.href, + authorization.id?.href, + ); + assert.deepStrictEqual(ctx.sentActivities, []); +}); + +test("AuthorizedMessage.unauthorizeQuote() revokes when quote lookup fails", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const target = await new SessionImpl(bot, ctx).publish(text`Quote this`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/deleted-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/4"), + actor, + object: target.id, + instrument: quote, + }), + ); + + const authorization = await repository.findQuoteAuthorization( + "bot", + quote.id!, + ); + assert.ok(authorization != null); + Object.defineProperty(ctx, "lookupObject", { + value: () => Promise.reject(new TypeError("Remote quote is gone.")), + configurable: true, + }); + ctx.sentActivities = []; + + await target.unauthorizeQuote(quote.id!); + + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, "followers"); + assert.ok(activity instanceof Delete); + assert.deepStrictEqual(activity.objectId, authorization.id); +}); + +test("BotImpl.onQuoteRequested() rejects another actor's quote", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers may quote me`); + ctx.sentActivities = []; + const requester = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quoteAuthor = new Person({ + id: new URL("https://remote.example/users/mallory"), + preferredUsername: "mallory", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + requester, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/borrowed-quote"), + attribution: quoteAuthor.id, + quoteUrl: target.id, + content: "Quoted by somebody else.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/3"), + actor: requester, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [requester]); + assert.ok(activity instanceof Reject); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 0914fda..8ae02a9 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -47,8 +47,10 @@ import { PropertyValue, PUBLIC_COLLECTION, Question, + type QuoteAuthorization, + type QuoteRequest as RawQuoteRequest, type Recipient, - type Reject, + Reject, Service, type Undo, Update, @@ -73,6 +75,7 @@ import type { MentionEventHandler, MessageEventHandler, QuoteEventHandler, + QuoteRequestEventHandler, ReactionEventHandler, RejectEventHandler, ReplyEventHandler, @@ -93,6 +96,8 @@ import { } from "./message-impl.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; import type { Vote } from "./poll.ts"; +import { QuoteRequestImpl } from "./quote-impl.ts"; +import { normalizeQuotePolicy, type QuotePolicyOption } from "./quote.ts"; import type { Like, Reaction } from "./reaction.ts"; import { ActorScopedRepository, @@ -137,6 +142,7 @@ export const botEventHandlerNames = [ "onMention", "onReply", "onQuote", + "onQuoteRequest", "onMessage", "onSharedMessage", "onLike", @@ -158,6 +164,7 @@ export class BotImpl implements Bot { readonly properties: Record>; #properties: { pairs: PropertyValue[]; tags: (Link | Object)[] } | null; readonly followerPolicy: "accept" | "reject" | "manual"; + readonly quotePolicy: QuotePolicyOption; readonly repository: ActorScopedRepository; /** @@ -208,6 +215,7 @@ export class BotImpl implements Bot { onMention?: MentionEventHandler; onReply?: ReplyEventHandler; onQuote?: QuoteEventHandler; + onQuoteRequest?: QuoteRequestEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; @@ -228,6 +236,7 @@ export class BotImpl implements Bot { this.properties = options.properties ?? {}; this.#properties = null; this.followerPolicy = options.followerPolicy ?? "accept"; + this.quotePolicy = options.quotePolicy ?? "public"; this.instance = options.instance ?? new InstanceImpl({ kv: options.kv, // The single-bot deployment may carry data from BotKit 0.4 or @@ -531,6 +540,15 @@ export class BotImpl implements Bot { return isVisible(activity) ? activity : null; } + async dispatchQuoteAuthorization( + _ctx: RequestContext, + values: { identifier: string; id: string }, + ): Promise { + if (values.identifier !== this.identifier) return null; + return await this.repository.getQuoteAuthorization(values.id as Uuid) ?? + null; + } + dispatchEmoji( ctx: Context, values: { name: string }, @@ -661,6 +679,141 @@ export class BotImpl implements Bot { } } + async onQuoteRequested( + ctx: InboxContext, + request: RawQuoteRequest, + ): Promise { + if (request.actorId == null) return; + const parsedObj = parseLocalUri( + ctx, + request.objectId, + this.legacyObjectUrisIdentifier, + ); + if ( + parsedObj?.type !== "object" || + // deno-lint-ignore no-explicit-any + !messageClasses.includes(parsedObj.class as any) || + parsedObj.values.identifier !== this.identifier + ) return; + const stored = await this.repository.getMessage( + parsedObj.values.id as Uuid, + ); + if (!(stored instanceof Create)) return; + const targetObject = await stored.getObject(ctx); + if (!isMessageObject(targetObject)) return; + const documentLoader = await ctx.getDocumentLoader(this); + const instrument = await request.getInstrument({ + contextLoader: ctx.contextLoader, + documentLoader, + suppressError: true, + }); + if (!isMessageObject(instrument)) return; + const actor = await request.getActor({ + contextLoader: ctx.contextLoader, + documentLoader, + suppressError: true, + }); + if (!isActor(actor) || actor.id == null) return; + const session = this.getSession(ctx); + if ( + instrument.attributionId != null && + instrument.attributionId.href !== actor.id.href + ) { + await session.context.sendActivity( + this, + actor, + new Reject({ + id: new URL(`/#reject/${request.id?.href}`, session.actorId), + actor: session.actorId, + to: actor.id, + object: request, + }), + { excludeBaseUris: [new URL(session.context.origin)] }, + ); + return; + } + const quoteObject = instrument.attributionId == null + ? instrument.clone({ attribution: actor.id }) + : instrument; + const target = await createMessage( + targetObject, + session, + {}, + undefined, + undefined, + true, + ); + const quote = await createMessage(quoteObject, session, { + [actor.id.href]: actor, + }); + const quoteRequest = new QuoteRequestImpl( + session, + request, + actor, + quote, + target, + ); + const rule = targetObject.interactionPolicy?.canQuote; + if (rule == null) { + const policy = normalizeQuotePolicy(this.quotePolicy); + if (await this.#matchesQuoteAcceptance(ctx, actor.id, policy.automatic)) { + await quoteRequest.accept(); + } else if ( + await this.#matchesQuoteAcceptance(ctx, actor.id, policy.manual) + ) { + // Leave pending for the event handler below. + } else { + await quoteRequest.reject(); + } + } else if ( + await this.#matchesQuoteApprovals(ctx, actor.id, rule.automaticApprovals) + ) { + await quoteRequest.accept(); + } else if ( + await this.#matchesQuoteApprovals(ctx, actor.id, rule.manualApprovals) + ) { + // Leave pending for the event handler below. + } else { + await quoteRequest.reject(); + } + await this.onQuoteRequest?.(session, quoteRequest); + } + + async #matchesQuoteAcceptance( + ctx: InboxContext, + actorId: URL, + acceptance: ReturnType["automatic"], + ): Promise { + if (actorId.href === ctx.getActorUri(this.identifier).href) return true; + switch (acceptance) { + case "public": + return true; + case "followers": + return await this.repository.hasFollower(actorId); + case "nobody": + default: + return false; + } + } + + async #matchesQuoteApprovals( + ctx: InboxContext, + actorId: URL, + approvals: readonly URL[], + ): Promise { + if (actorId.href === ctx.getActorUri(this.identifier).href) return true; + const followerCollection = ctx.getFollowersUri(this.identifier).href; + for (const approval of approvals) { + if (approval.href === PUBLIC_COLLECTION.href) return true; + if (approval.href === actorId.href) return true; + if ( + approval.href === followerCollection && + await this.repository.hasFollower(actorId) + ) return true; + } + return false; + } + async onCreated( ctx: InboxContext, create: Create, @@ -1207,6 +1360,12 @@ export function wrapBotImpl( set onQuote(value) { bot.onQuote = value; }, + get onQuoteRequest() { + return bot.onQuoteRequest; + }, + set onQuoteRequest(value) { + bot.onQuoteRequest = value; + }, get onMessage() { return bot.onMessage; }, @@ -1429,6 +1588,46 @@ export class MigrationGatedRepository implements Repository { yield* this.#repository.findFollowedBots(followeeId); } + async addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + await this.#migration; + return await this.#repository.addQuoteAuthorization( + identifier, + id, + authorization, + ); + } + + async getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + await this.#migration; + return await this.#repository.getQuoteAuthorization(identifier, id); + } + + async findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise { + await this.#migration; + return await this.#repository.findQuoteAuthorization( + identifier, + interactingObject, + ); + } + + async removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + await this.#migration; + return await this.#repository.removeQuoteAuthorization(identifier, id); + } + async vote( identifier: string, messageId: Uuid, @@ -1482,6 +1681,7 @@ export class BotGroupImpl implements BotGroup { onMention?: MentionEventHandler; onReply?: ReplyEventHandler; onQuote?: QuoteEventHandler; + onQuoteRequest?: QuoteRequestEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; @@ -1549,6 +1749,7 @@ export class GroupBotImpl extends BotImpl { image: profile.image, properties: profile.properties, followerPolicy: profile.followerPolicy, + quotePolicy: profile.quotePolicy, }); this.group = group; for (const name of botEventHandlerNames) { diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index fae5054..03edb43 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -30,6 +30,7 @@ import type { MentionEventHandler, MessageEventHandler, QuoteEventHandler, + QuoteRequestEventHandler, ReactionEventHandler, RejectEventHandler, ReplyEventHandler, @@ -39,6 +40,7 @@ import type { UnlikeEventHandler, VoteEventHandler, } from "./events.ts"; +import type { QuotePolicyOption } from "./quote.ts"; import type { Repository } from "./repository.ts"; import type { Session } from "./session.ts"; import type { Text } from "./text.ts"; @@ -88,6 +90,12 @@ export interface BotEventHandlers { */ onQuote?: QuoteEventHandler; + /** + * An event handler for a quote request to the bot. + * @since 0.5.0 + */ + onQuoteRequest?: QuoteRequestEventHandler; + /** * An event handler for a message shown to the bot's timeline. To listen * to this event, your bot needs to follow others first. @@ -180,6 +188,12 @@ export interface ReadonlyBot { * How the bot handles incoming follow requests. */ readonly followerPolicy: "accept" | "reject" | "manual"; + + /** + * How the bot handles incoming quote requests for its messages. + * @since 0.5.0 + */ + readonly quotePolicy: QuotePolicyOption; } /** @@ -350,6 +364,16 @@ export interface CreateBotOptions { */ readonly followerPolicy?: "accept" | "reject" | "manual"; + /** + * Who can quote messages published by the bot. + * + * This policy is advertised on outgoing messages and is used as the + * fallback for older messages that do not have a serialized quote policy. + * @default `"public"` + * @since 0.5.0 + */ + readonly quotePolicy?: QuotePolicyOption; + /** * The underlying key-value store to use for storing data. */ diff --git a/packages/botkit/src/events.ts b/packages/botkit/src/events.ts index b22ce05..032463f 100644 --- a/packages/botkit/src/events.ts +++ b/packages/botkit/src/events.ts @@ -17,6 +17,7 @@ import type { Actor } from "@fedify/vocab"; import type { FollowRequest } from "./follow.ts"; import type { Message, MessageClass, SharedMessage } from "./message.ts"; import type { Vote } from "./poll.ts"; +import type { QuoteRequest } from "./quote.ts"; import type { Like, Reaction } from "./reaction.ts"; import type { Session } from "./session.ts"; @@ -98,6 +99,18 @@ export type QuoteEventHandler = ( quote: Message, ) => void | Promise; +/** + * An event handler for a quote request to the bot. + * @typeParam TContextData The type of the context data. + * @param session The session of the bot. + * @param quoteRequest The quote request. + * @since 0.5.0 + */ +export type QuoteRequestEventHandler = ( + session: Session, + quoteRequest: QuoteRequest, +) => void | Promise; + /** * An event handler for a message shown to the bot's timeline. To listen to * this event, your bot needs to follow others first. diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index eef6ae0..b1e1713 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -43,6 +43,8 @@ import { Mention, Note, Question, + QuoteAuthorization, + QuoteRequest, Reject, Undo, } from "@fedify/vocab"; @@ -296,6 +298,14 @@ export class InstanceImpl return await bot?.dispatchAnnounce(ctx, values) ?? null; }, ); + this.federation.setObjectDispatcher( + QuoteAuthorization, + "/ap/actor/{identifier}/quote-authorization/{id}", + async (ctx, values) => { + const bot = await this.resolveBot(ctx, values.identifier); + return await bot?.dispatchQuoteAuthorization(ctx, values) ?? null; + }, + ); this.federation.setObjectDispatcher( APEmoji, "/ap/emoji/{name}", @@ -307,6 +317,7 @@ export class InstanceImpl this.onUnverifiedActivity(ctx, activity, reason) ) .on(Follow, (ctx, follow) => this.onFollowed(ctx, follow)) + .on(QuoteRequest, (ctx, request) => this.onQuoteRequested(ctx, request)) .on(Undo, (ctx, undo) => this.onUndone(ctx, undo)) .on(Accept, (ctx, accept) => this.onFollowAccepted(ctx, accept)) .on(Reject, (ctx, reject) => this.onFollowRejected(ctx, reject)) @@ -620,6 +631,17 @@ export class InstanceImpl } } + async onQuoteRequested( + ctx: InboxContext, + request: QuoteRequest, + ): Promise { + const bots = await this.#resolveTargets( + ctx, + () => this.#localObjectTarget(ctx, request.objectId), + ); + for (const bot of bots) await bot.onQuoteRequested(ctx, request); + } + async onFollowAccepted( ctx: InboxContext, accept: Accept, diff --git a/packages/botkit/src/instance.ts b/packages/botkit/src/instance.ts index 0960062..666f659 100644 --- a/packages/botkit/src/instance.ts +++ b/packages/botkit/src/instance.ts @@ -25,6 +25,7 @@ import type { Bot, BotEventHandlers, PagesOptions } from "./bot.ts"; import type { CustomEmoji, DeferredCustomEmoji } from "./emoji.ts"; import { InstanceImpl } from "./instance-impl.ts"; export { DEFAULT_INSTANCE_ACTOR_IDENTIFIER } from "./instance-impl.ts"; +import type { QuotePolicyOption } from "./quote.ts"; import type { Repository } from "./repository.ts"; import type { Session } from "./session.ts"; import type { Text } from "./text.ts"; @@ -93,6 +94,16 @@ export interface BotProfile { * @default `"accept"` */ readonly followerPolicy?: "accept" | "reject" | "manual"; + + /** + * Who can quote messages published by the bot. + * + * This policy is advertised on outgoing messages and is used as the + * fallback for older messages that do not have a serialized quote policy. + * @default `"public"` + * @since 0.5.0 + */ + readonly quotePolicy?: QuotePolicyOption; } /** diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index ce37ae7..9c04cbe 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -595,6 +595,27 @@ test("AuthorizedMessage.update()", async (t) => { } }); } + + await t.test("quote policy", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockContext(bot, "https://example.com"); + const session = new SessionImpl(bot, ctx); + const msg = await session.publish(text`Hello`); + await msg.update(text`Hello again`, { quotePolicy: "nobody" }); + const [create] = await Array.fromAsync(repository.getMessages("bot")); + assert.ok(create instanceof Create); + const object = await create.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual( + object.interactionPolicy?.canQuote?.automaticApprovals, + [ctx.getActorUri(bot.identifier)], + ); + }); }); test("getMessageVisibility()", () => { diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 5afb4a0..a27ec92 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -33,6 +33,7 @@ import { type Object, PUBLIC_COLLECTION, Question, + QuoteAuthorization, Tombstone, Undo, Update, @@ -44,6 +45,7 @@ import xss from "xss"; import type { DeferredCustomEmoji, Emoji } from "./emoji.ts"; import type { AuthorizedMessage, + AuthorizedMessageUpdateOptions, AuthorizedSharedMessage, Message, MessageClass, @@ -52,6 +54,7 @@ import type { } from "./message.ts"; import type { AuthorizedLike, AuthorizedReaction } from "./reaction.ts"; import type { Uuid } from "./repository.ts"; +import { serializeQuotePolicy } from "./quote.ts"; import type { SessionImpl } from "./session-impl.ts"; import type { SessionPublishOptions, @@ -376,7 +379,10 @@ export class MessageImpl export class AuthorizedMessageImpl extends MessageImpl implements AuthorizedMessage { - async update(text: Text<"block", TContextData>): Promise { + async update( + text: Text<"block", TContextData>, + options: AuthorizedMessageUpdateOptions = {}, + ): Promise { const parsed = parseLocalUri( this.session.context, this.id, @@ -459,6 +465,15 @@ export class AuthorizedMessageImpl ? [PUBLIC_COLLECTION] : [], updated, + interactionPolicy: options.quotePolicy == null + ? message.interactionPolicy + : serializeQuotePolicy( + options.quotePolicy, + this.session.actorId, + this.session.context.getFollowersUri( + this.session.bot.identifier, + ), + ), }); this.raw = newMessage as T; create = create.clone({ object: newMessage, updated }); @@ -596,6 +611,84 @@ export class AuthorizedMessageImpl ); } } + + async unauthorizeQuote( + quote: Message | URL, + ): Promise { + const quoteId = quote instanceof URL ? quote : quote.id; + const authorization = await this.session.bot.repository + .findQuoteAuthorization(quoteId); + if (authorization == null || authorization.id == null) { + throw new TypeError("The quote authorization does not exist."); + } + if (authorization.interactionTargetId?.href !== this.id.href) { + throw new TypeError( + "The quote authorization does not belong to this message.", + ); + } + const parsed = parseLocalUri( + this.session.context, + authorization.id, + this.session.bot.legacyObjectUrisIdentifier, + ); + if ( + parsed?.type !== "object" || + parsed.class !== QuoteAuthorization || + parsed.values.identifier !== this.session.bot.identifier + ) { + throw new TypeError("The quote authorization is not local."); + } + await this.session.bot.repository.removeQuoteAuthorization( + parsed.values.id as Uuid, + ); + const quoteActor = quote instanceof URL + ? await this.#getQuoteActor(quote).catch(() => undefined) + : quote.actor; + const followersUri = this.session.context.getFollowersUri( + this.session.bot.identifier, + ); + const del = new Delete({ + id: new URL("#delete", authorization.id), + actor: this.session.actorId, + object: authorization.id, + to: quoteActor?.id ?? followersUri, + cc: quoteActor?.id == null ? undefined : followersUri, + }); + if (quoteActor?.id != null) { + await this.session.context.sendActivity( + this.session.bot, + quoteActor, + del, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(this.session.context.origin)], + fanout: "skip", + }, + ); + } + await this.session.context.sendActivity( + this.session.bot, + "followers", + del, + { + preferSharedInbox: true, + excludeBaseUris: [new URL(this.session.context.origin)], + }, + ); + } + + async #getQuoteActor(quoteId: URL): Promise { + const documentLoader = await this.session.context.getDocumentLoader( + this.session.bot, + ); + const object = await this.session.context.lookupObject(quoteId, { + documentLoader, + }); + if (!isMessageObject(object)) { + throw new TypeError("The quote message does not exist."); + } + return (await createMessage(object, this.session, {})).actor; + } } // @ts-ignore: The `xss` module has `getDefaultWhiteList` function. @@ -647,12 +740,17 @@ export async function createMessage( documentLoader, suppressError: true, }; - const actor = raw.attributionId?.href === session.actorId?.href + const rawActor = raw.attributionId?.href === session.actorId?.href ? await session.getActor() - : await raw.getAttribution(options); - if (actor == null) { + : raw.attributionId == null + ? null + : cachedObjects[raw.attributionId.href] == null + ? await raw.getAttribution(options) + : cachedObjects[raw.attributionId.href]; + if (!isActor(rawActor)) { throw new TypeError("The raw.attributionId is required."); } + const actor = rawActor; const content = raw.content.toString(); const text = textXss.process(content); const html = htmlXss.process(content); diff --git a/packages/botkit/src/message.ts b/packages/botkit/src/message.ts index ac7772c..646ee37 100644 --- a/packages/botkit/src/message.ts +++ b/packages/botkit/src/message.ts @@ -30,6 +30,7 @@ import type { SessionPublishOptions, SessionPublishOptionsWithClass, } from "./session.ts"; +import type { QuotePolicyOption } from "./quote.ts"; import type { Text } from "./text.ts"; export { Article, @@ -227,8 +228,12 @@ export interface AuthorizedMessage /** * Updates the message with new content. * @param text The new content of the message. + * @param options The options for updating the message. */ - update(text: Text<"block", TContextData>): Promise; + update( + text: Text<"block", TContextData>, + options?: AuthorizedMessageUpdateOptions, + ): Promise; /** * Deletes the message, if possible. @@ -236,6 +241,27 @@ export interface AuthorizedMessage * If the message is already deleted, it will be a no-op. */ delete(): Promise; + + /** + * Revokes the authorization stamp issued to a quote of this message. + * @param quote The quoted message or its URI. + * @throws {TypeError} The quote authorization does not exist. + * @since 0.5.0 + */ + unauthorizeQuote( + quote: Message | URL, + ): Promise; +} + +/** + * Options for updating an authorized message. + * @since 0.5.0 + */ +export interface AuthorizedMessageUpdateOptions { + /** + * Who can quote the updated message. + */ + readonly quotePolicy?: QuotePolicyOption; } /** diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index dcfb414..fc2cd77 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -74,6 +74,13 @@ export type { SharedMessage, } from "./message.ts"; export type { Poll, Vote } from "./poll.ts"; +export type { + QuoteAcceptance, + QuotePolicy, + QuotePolicyOption, + QuoteRequest, +} from "./quote.ts"; +export { normalizeQuotePolicy } from "./quote.ts"; export { type AuthorizedLike, type AuthorizedReaction, diff --git a/packages/botkit/src/quote-impl.ts b/packages/botkit/src/quote-impl.ts new file mode 100644 index 0000000..cb4a9d2 --- /dev/null +++ b/packages/botkit/src/quote-impl.ts @@ -0,0 +1,123 @@ +// 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 { + Accept, + type Actor, + QuoteAuthorization, + type QuoteRequest as RawQuoteRequest, + Reject, +} from "@fedify/vocab"; +import { v7 as uuidv7 } from "uuid"; +import type { AuthorizedMessage, Message, MessageClass } from "./message.ts"; +import type { QuoteRequest } from "./quote.ts"; +import type { Uuid } from "./repository.ts"; +import type { SessionImpl } from "./session-impl.ts"; + +export class QuoteRequestImpl + implements QuoteRequest { + readonly session: SessionImpl; + readonly id: URL; + readonly raw: RawQuoteRequest; + readonly actor: Actor; + readonly quote: Message; + readonly target: AuthorizedMessage; + #state: "pending" | "accepted" | "rejected"; + + get state(): "pending" | "accepted" | "rejected" { + return this.#state; + } + + constructor( + session: SessionImpl, + raw: RawQuoteRequest, + actor: Actor, + quote: Message, + target: AuthorizedMessage, + ) { + if (raw.id == null) { + throw new TypeError("The quote request ID is missing."); + } else if (actor.id == null) { + throw new TypeError("The quote requester ID is missing."); + } else if (quote.id == null) { + throw new TypeError("The quote message ID is missing."); + } + this.session = session; + this.id = raw.id; + this.raw = raw; + this.actor = actor; + this.quote = quote; + this.target = target; + this.#state = "pending"; + } + + async accept(): Promise { + if (this.#state !== "pending") { + throw new TypeError("The quote request is not pending."); + } + const existing = await this.session.bot.repository.findQuoteAuthorization( + this.quote.id, + ); + const authorization = existing ?? await this.#createAuthorization(); + await this.session.context.sendActivity( + this.session.bot, + this.actor, + new Accept({ + id: new URL(`/#accept/${this.id.href}`, this.session.actorId), + actor: this.session.actorId, + to: this.actor.id, + object: this.raw, + result: authorization.id, + }), + { excludeBaseUris: [new URL(this.session.context.origin)] }, + ); + this.#state = "accepted"; + } + + async reject(): Promise { + if (this.#state !== "pending") { + throw new TypeError("The quote request is not pending."); + } + await this.session.context.sendActivity( + this.session.bot, + this.actor, + new Reject({ + id: new URL(`/#reject/${this.id.href}`, this.session.actorId), + actor: this.session.actorId, + to: this.actor.id, + object: this.raw, + }), + { excludeBaseUris: [new URL(this.session.context.origin)] }, + ); + this.#state = "rejected"; + } + + async #createAuthorization(): Promise { + const id = uuidv7() as Uuid; + const authorization = new QuoteAuthorization({ + id: this.session.context.getObjectUri(QuoteAuthorization, { + identifier: this.session.bot.identifier, + id, + }), + attribution: this.session.actorId, + interactingObject: this.quote.id, + interactionTarget: this.target.id, + }); + await this.session.bot.repository.addQuoteAuthorization(id, authorization); + return await this.session.bot.repository.findQuoteAuthorization( + this.quote.id, + ) ?? authorization; + } +} diff --git a/packages/botkit/src/quote.test.ts b/packages/botkit/src/quote.test.ts new file mode 100644 index 0000000..739703f --- /dev/null +++ b/packages/botkit/src/quote.test.ts @@ -0,0 +1,77 @@ +// 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 { PUBLIC_COLLECTION } from "@fedify/vocab"; +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import { normalizeQuotePolicy, serializeQuotePolicy } from "./quote.ts"; + +describe("normalizeQuotePolicy()", () => { + test("normalizes string policies", () => { + assert.deepStrictEqual(normalizeQuotePolicy(), { automatic: "public" }); + assert.deepStrictEqual(normalizeQuotePolicy("public"), { + automatic: "public", + }); + assert.deepStrictEqual(normalizeQuotePolicy("followers"), { + automatic: "followers", + }); + assert.deepStrictEqual(normalizeQuotePolicy("nobody"), { + automatic: "nobody", + }); + assert.deepStrictEqual(normalizeQuotePolicy("manual"), { + manual: "public", + }); + }); + + test("preserves object policies", () => { + assert.deepStrictEqual( + normalizeQuotePolicy({ automatic: "followers", manual: "public" }), + { automatic: "followers", manual: "public" }, + ); + }); +}); + +describe("serializeQuotePolicy()", () => { + const actor = new URL("https://example.com/ap/actor/bot"); + const followers = new URL("https://example.com/ap/actor/bot/followers"); + + test("serializes public automatic approval", () => { + const rule = serializeQuotePolicy("public", actor, followers).canQuote; + assert.deepStrictEqual(rule?.automaticApprovals, [PUBLIC_COLLECTION]); + assert.deepStrictEqual(rule?.manualApprovals, []); + }); + + test("serializes followers automatic approval", () => { + const rule = serializeQuotePolicy("followers", actor, followers).canQuote; + assert.deepStrictEqual(rule?.automaticApprovals, [actor, followers]); + assert.deepStrictEqual(rule?.manualApprovals, []); + }); + + test("serializes nobody automatic approval as actor-only", () => { + const rule = serializeQuotePolicy("nobody", actor, followers).canQuote; + assert.deepStrictEqual(rule?.automaticApprovals, [actor]); + assert.deepStrictEqual(rule?.manualApprovals, []); + }); + + test("serializes manual followers approval", () => { + const rule = serializeQuotePolicy( + { manual: "followers" }, + actor, + followers, + ).canQuote; + assert.deepStrictEqual(rule?.automaticApprovals, []); + assert.deepStrictEqual(rule?.manualApprovals, [followers]); + }); +}); diff --git a/packages/botkit/src/quote.ts b/packages/botkit/src/quote.ts new file mode 100644 index 0000000..c5c7d30 --- /dev/null +++ b/packages/botkit/src/quote.ts @@ -0,0 +1,159 @@ +// BotKit by Fedify: A framework for creating ActivityPub bots +// Copyright (C) 2025–2026 Hong Minhee +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import { + type Actor, + InteractionPolicy, + InteractionRule, + PUBLIC_COLLECTION, + type QuoteRequest as RawQuoteRequest, +} from "@fedify/vocab"; +import type { AuthorizedMessage, Message, MessageClass } from "./message.ts"; + +/** + * The audience whose quote requests can be approved. + * @since 0.5.0 + */ +export type QuoteAcceptance = "public" | "followers" | "nobody"; + +/** + * A quote policy for messages published by the bot. + * + * The `automatic` axis is accepted immediately. The `manual` axis is exposed + * to {@link QuoteRequestEventHandler} as a pending request. + * @since 0.5.0 + */ +export interface QuotePolicy { + /** + * The audience whose quote requests are accepted automatically. + */ + readonly automatic?: QuoteAcceptance; + + /** + * The audience whose quote requests wait for manual approval. + */ + readonly manual?: QuoteAcceptance; +} + +/** + * A shorthand or full quote policy. + * @since 0.5.0 + */ +export type QuotePolicyOption = QuoteAcceptance | "manual" | QuotePolicy; + +/** + * A quote request to the bot. + * @typeParam TContextData The type of the context data. + * @since 0.5.0 + */ +export interface QuoteRequest { + /** + * The URI of the quote request. + */ + readonly id: URL; + + /** + * The raw quote request object. + */ + readonly raw: RawQuoteRequest; + + /** + * The actor requesting quote authorization. + */ + readonly actor: Actor; + + /** + * The message that quotes the bot's message. + */ + readonly quote: Message; + + /** + * The bot's message being quoted. + */ + readonly target: AuthorizedMessage; + + /** + * The state of the quote request. + */ + readonly state: "pending" | "accepted" | "rejected"; + + /** + * Accepts the quote request. + * @throws {TypeError} The quote request is not pending. + */ + accept(): Promise; + + /** + * Rejects the quote request. + * @throws {TypeError} The quote request is not pending. + */ + reject(): Promise; +} + +/** + * Normalizes a quote policy option into the two-axis form. + * @param policy The quote policy option to normalize. + * @returns The normalized quote policy. + * @since 0.5.0 + */ +export function normalizeQuotePolicy( + policy: QuotePolicyOption = "public", +): QuotePolicy { + if (policy === "manual") return { manual: "public" }; + if (typeof policy === "string") return { automatic: policy }; + return policy; +} + +export function serializeQuotePolicy( + policy: QuotePolicyOption | undefined, + actorUri: URL, + followersUri: URL, +): InteractionPolicy { + const normalized = normalizeQuotePolicy(policy); + return new InteractionPolicy({ + canQuote: new InteractionRule({ + automaticApprovals: serializeQuoteAcceptance( + normalized.automatic, + actorUri, + followersUri, + true, + ), + manualApprovals: serializeQuoteAcceptance( + normalized.manual, + actorUri, + followersUri, + false, + ), + }), + }); +} + +function serializeQuoteAcceptance( + acceptance: QuoteAcceptance | undefined, + actorUri: URL, + followersUri: URL, + automatic: boolean, +): URL[] { + switch (acceptance) { + case "public": + return [PUBLIC_COLLECTION]; + case "followers": + return automatic ? [actorUri, followersUri] : [followersUri]; + case "nobody": + return automatic ? [actorUri] : []; + default: + return []; + } +} diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 2fdab2e..b5d414c 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -21,7 +21,14 @@ import { MemoryKvStore, } from "@fedify/fedify/federation"; import { exportJwk, importJwk } from "@fedify/fedify/sig"; -import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab"; +import { + Create, + Follow, + Note, + Person, + PUBLIC_COLLECTION, + QuoteAuthorization, +} from "@fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; import { @@ -50,6 +57,121 @@ const factories: Record Repository> = { MemoryCachedRepository: createMemoryCachedRepository, }; +for (const [name, factory] of Object.entries(factories)) { + test(`${name} stores quote authorizations`, async () => { + const repository = factory(); + const id = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + const interactingObject = new URL("https://remote.example/notes/1"); + const authorization = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${id}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: new URL("https://example.com/ap/note/1"), + }); + + await repository.addQuoteAuthorization("bot", id, authorization); + + assert.deepStrictEqual( + (await repository.getQuoteAuthorization("bot", id))?.id?.href, + authorization.id?.href, + ); + assert.deepStrictEqual( + (await repository.findQuoteAuthorization("bot", interactingObject))?.id + ?.href, + authorization.id?.href, + ); + assert.deepStrictEqual( + (await repository.removeQuoteAuthorization("bot", id))?.id?.href, + authorization.id?.href, + ); + assert.deepStrictEqual( + await repository.getQuoteAuthorization("bot", id), + undefined, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", interactingObject), + undefined, + ); + }); + + test(`${name} keeps the first quote authorization for a quote`, async () => { + const repository = factory(); + const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5" as Uuid; + const interactingObject = new URL("https://remote.example/notes/1"); + const target = new URL("https://example.com/ap/note/1"); + const first = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${firstId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: target, + }); + const second = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${secondId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: target, + }); + + await repository.addQuoteAuthorization("bot", firstId, first); + await repository.addQuoteAuthorization("bot", secondId, second); + + assert.deepStrictEqual( + (await repository.findQuoteAuthorization("bot", interactingObject))?.id + ?.href, + first.id?.href, + ); + assert.deepStrictEqual( + await repository.getQuoteAuthorization("bot", secondId), + undefined, + ); + }); +} + +test("MemoryCachedRepository keeps duplicate quote authorizations out of cache", async () => { + const underlying = createKvRepository(); + const repository = new MemoryCachedRepository(underlying); + const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5" as Uuid; + const interactingObject = new URL("https://remote.example/notes/1"); + const target = new URL("https://example.com/ap/note/1"); + const first = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${firstId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: target, + }); + const second = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${secondId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: target, + }); + + await underlying.addQuoteAuthorization("bot", firstId, first); + await repository.addQuoteAuthorization("bot", secondId, second); + + assert.deepStrictEqual( + (await repository.findQuoteAuthorization("bot", interactingObject))?.id + ?.href, + first.id?.href, + ); + assert.deepStrictEqual( + await repository.getQuoteAuthorization("bot", secondId), + undefined, + ); +}); + function scopedKvKey(...rest: readonly string[]): KvKey { return ["_botkit", "bots", "bot", ...rest]; } diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index 051cf08..55903bf 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -23,6 +23,7 @@ import { Follow, isActor, Object, + QuoteAuthorization, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; export type { KvKey, KvStore } from "@fedify/fedify/federation"; @@ -294,6 +295,58 @@ export interface Repository { */ findFollowedBots(followeeId: URL): AsyncIterable; + /** + * Adds a quote authorization stamp to the repository. + * @param identifier The identifier of the bot actor that issued the stamp. + * @param id The UUID of the quote authorization. + * @param authorization The quote authorization stamp to add. + * @since 0.5.0 + */ + addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise; + + /** + * Gets a quote authorization stamp from the repository. + * @param identifier The identifier of the bot actor that issued the stamp. + * @param id The UUID of the quote authorization. + * @returns The quote authorization stamp, or `undefined` if it does not + * exist. + * @since 0.5.0 + */ + getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise; + + /** + * Finds a quote authorization stamp by the quote post it authorizes. + * @param identifier The identifier of the bot actor that issued the stamp. + * @param interactingObject The URI of the quote post. + * @returns The quote authorization stamp, or `undefined` if it does not + * exist. + * @since 0.5.0 + */ + findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise; + + /** + * Removes a quote authorization stamp from the repository. + * @param identifier The identifier of the bot actor that issued the stamp. + * @param id The UUID of the quote authorization. + * @returns The removed quote authorization stamp, or `undefined` if it does + * not exist. + * @since 0.5.0 + */ + removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise; + /** * Records a vote in a poll. If the same voter had already voted for the * same option in a poll, the vote will be silently ignored. @@ -616,6 +669,60 @@ export class ActorScopedRepository { countVotes(messageId: Uuid): Promise>> { return this.repository.countVotes(this.identifier, messageId); } + + /** + * Adds a quote authorization stamp to the repository. + * @param id The UUID of the quote authorization. + * @param authorization The quote authorization stamp to add. + * @since 0.5.0 + */ + addQuoteAuthorization( + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + return this.repository.addQuoteAuthorization( + this.identifier, + id, + authorization, + ); + } + + /** + * Gets a quote authorization stamp from the repository. + * @param id The UUID of the quote authorization. + * @returns The quote authorization stamp, or `undefined`. + * @since 0.5.0 + */ + getQuoteAuthorization(id: Uuid): Promise { + return this.repository.getQuoteAuthorization(this.identifier, id); + } + + /** + * Finds a quote authorization stamp by the quote post it authorizes. + * @param interactingObject The URI of the quote post. + * @returns The quote authorization stamp, or `undefined`. + * @since 0.5.0 + */ + findQuoteAuthorization( + interactingObject: URL, + ): Promise { + return this.repository.findQuoteAuthorization( + this.identifier, + interactingObject, + ); + } + + /** + * Removes a quote authorization stamp from the repository. + * @param id The UUID of the quote authorization. + * @returns The removed quote authorization stamp, or `undefined`. + * @since 0.5.0 + */ + removeQuoteAuthorization( + id: Uuid, + ): Promise { + return this.repository.removeQuoteAuthorization(this.identifier, id); + } } /** @@ -717,6 +824,17 @@ export class KvRepository implements Repository { return [...this.prefix, "index", "followees", followeeId.href]; } + #quoteAuthorizationIndexKey( + identifier: string, + interactingObject: URL, + ): KvKey { + return this.#key( + identifier, + "quoteAuthorizationsByInteractingObject", + interactingObject.href, + ); + } + /** * Migrates data stored by BotKit 0.4 or earlier, which was not scoped by * bot actor identifiers, so that it belongs to the given identifier. @@ -764,6 +882,8 @@ export class KvRepository implements Repository { "followRequests", "followees", "follows", + "quoteAuthorizations", + "quoteAuthorizationsByInteractingObject", "polls", ] as const; for (const category of categories) { @@ -1440,6 +1560,79 @@ export class KvRepository implements Repository { for (const identifier of identifiers) yield identifier; } + async addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + const interactingObject = authorization.interactingObjectId; + if (interactingObject == null) { + throw new TypeError( + "The quote authorization interacting object is missing.", + ); + } + const existing = await this.kv.get( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + if (existing != null) return; + await this.kv.set( + this.#key(identifier, "quoteAuthorizations", id), + await authorization.toJsonLd({ format: "compact" }), + ); + await this.kv.set( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + id, + ); + } + + async getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const json = await this.kv.get( + this.#key(identifier, "quoteAuthorizations", id), + ); + if (json == null) return undefined; + try { + return await QuoteAuthorization.fromJsonLd(json); + } catch { + return undefined; + } + } + + async findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise { + const id = await this.kv.get( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + if (id == null) return undefined; + const authorization = await this.getQuoteAuthorization(identifier, id); + if (authorization == null) { + await this.kv.delete( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + } + return authorization; + } + + async removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const authorization = await this.getQuoteAuthorization(identifier, id); + if (authorization == null) return undefined; + await this.kv.delete(this.#key(identifier, "quoteAuthorizations", id)); + const interactingObject = authorization.interactingObjectId; + if (interactingObject != null) { + await this.kv.delete( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + } + return authorization; + } + async #addToFolloweeIndex( identifier: string, followeeId: URL, @@ -1606,6 +1799,8 @@ interface MemoryActorData { followRequests: Record; sentFollows: Record; followees: Record; + quoteAuthorizations: Map; + quoteAuthorizationsByInteractingObject: Map; polls: Record>>; } @@ -1625,6 +1820,8 @@ export class MemoryRepository implements Repository { followRequests: {}, sentFollows: {}, followees: {}, + quoteAuthorizations: new Map(), + quoteAuthorizationsByInteractingObject: new Map(), polls: {}, }; this.#data.set(identifier, data); @@ -1847,6 +2044,67 @@ export class MemoryRepository implements Repository { } } + addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + const interactingObject = authorization.interactingObjectId; + if (interactingObject == null) { + throw new TypeError( + "The quote authorization interacting object is missing.", + ); + } + const data = this.#bucket(identifier); + if ( + data.quoteAuthorizationsByInteractingObject.has(interactingObject.href) + ) { + return Promise.resolve(); + } + data.quoteAuthorizations.set(id, authorization); + data.quoteAuthorizationsByInteractingObject.set(interactingObject.href, id); + return Promise.resolve(); + } + + getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + return Promise.resolve( + this.#data.get(identifier)?.quoteAuthorizations.get(id), + ); + } + + findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise { + const data = this.#data.get(identifier); + if (data == null) return Promise.resolve(undefined); + const id = data.quoteAuthorizationsByInteractingObject.get( + interactingObject.href, + ); + if (id == null) return Promise.resolve(undefined); + return Promise.resolve(data.quoteAuthorizations.get(id)); + } + + removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const data = this.#data.get(identifier); + if (data == null) return Promise.resolve(undefined); + const authorization = data.quoteAuthorizations.get(id); + data.quoteAuthorizations.delete(id); + const interactingObject = authorization?.interactingObjectId; + if (interactingObject != null) { + data.quoteAuthorizationsByInteractingObject.delete( + interactingObject.href, + ); + } + return Promise.resolve(authorization); + } + vote( identifier: string, messageId: Uuid, @@ -2139,6 +2397,53 @@ export class MemoryCachedRepository implements Repository { return this.underlying.findFollowedBots(followeeId); } + async addQuoteAuthorization( + identifier: string, + id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + await this.underlying.addQuoteAuthorization(identifier, id, authorization); + } + + async getQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + let authorization = await this.cache.getQuoteAuthorization(identifier, id); + if (authorization === undefined) { + authorization = await this.underlying.getQuoteAuthorization( + identifier, + id, + ); + if (authorization !== undefined) { + await this.cache.addQuoteAuthorization(identifier, id, authorization); + } + } + return authorization; + } + + async findQuoteAuthorization( + identifier: string, + interactingObject: URL, + ): Promise { + return await this.underlying.findQuoteAuthorization( + identifier, + interactingObject, + ); + } + + async removeQuoteAuthorization( + identifier: string, + id: Uuid, + ): Promise { + const removed = await this.underlying.removeQuoteAuthorization( + identifier, + id, + ); + await this.cache.removeQuoteAuthorization(identifier, id); + return removed; + } + async vote( identifier: string, messageId: Uuid, diff --git a/packages/botkit/src/session-impl.test.ts b/packages/botkit/src/session-impl.test.ts index ddc2d8f..ab26408 100644 --- a/packages/botkit/src/session-impl.test.ts +++ b/packages/botkit/src/session-impl.test.ts @@ -342,6 +342,30 @@ test("SessionImpl.publish()", async (t) => { assert.deepStrictEqual(publicMsg.html, "

Hello, world!

"); assert.deepStrictEqual(publicMsg.visibility, "public"); assert.deepStrictEqual(publicMsg.mentions, []); + assert.deepStrictEqual( + object.interactionPolicy?.canQuote + ?.automaticApprovals, + [PUBLIC_COLLECTION], + ); + }); + + await t.test("quotePolicy", async () => { + ctx.sentActivities = []; + const followersMsg = await session.publish(text`Followers can quote`, { + quotePolicy: "followers", + }); + const activity = ctx.sentActivities[0].activity; + assert.ok(activity instanceof Create); + const object = await activity.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual( + object.interactionPolicy?.canQuote?.automaticApprovals, + [ + ctx.getActorUri(bot.identifier), + ctx.getFollowersUri(bot.identifier), + ], + ); + assert.deepStrictEqual(followersMsg.raw.id, object.id); }); await t.test("unlisted", async () => { diff --git a/packages/botkit/src/session-impl.ts b/packages/botkit/src/session-impl.ts index a7dde04..a8dfcc0 100644 --- a/packages/botkit/src/session-impl.ts +++ b/packages/botkit/src/session-impl.ts @@ -41,6 +41,7 @@ import { Question, } from "./message.ts"; import type { Uuid } from "./repository.ts"; +import { serializeQuotePolicy } from "./quote.ts"; import type { Session, SessionGetOutboxOptions, @@ -322,6 +323,11 @@ export class SessionImpl implements Session { replyTarget: options.replyTarget?.id, quoteUrl: options.quoteTarget?.id, tags, + interactionPolicy: serializeQuotePolicy( + options.quotePolicy ?? this.bot.quotePolicy, + this.context.getActorUri(this.bot.identifier), + this.context.getFollowersUri(this.bot.identifier), + ), attribution: this.context.getActorUri(this.bot.identifier), attachments: options.attachments ?? [], inclusiveOptions, diff --git a/packages/botkit/src/session.ts b/packages/botkit/src/session.ts index 9244562..63d38bb 100644 --- a/packages/botkit/src/session.ts +++ b/packages/botkit/src/session.ts @@ -30,6 +30,7 @@ import type { MessageVisibility, } from "./message.ts"; import type { Poll } from "./poll.ts"; +import type { QuotePolicyOption } from "./quote.ts"; import type { Text } from "./text.ts"; /** @@ -192,6 +193,14 @@ export interface SessionPublishOptions { * @since 0.2.0 */ readonly quoteTarget?: Message; + + /** + * Who can quote the published message. + * + * If omitted, the bot's default quote policy is used. + * @since 0.5.0 + */ + readonly quotePolicy?: QuotePolicyOption; } /** diff --git a/packages/botkit/src/text.test.ts b/packages/botkit/src/text.test.ts index db27095..688c8a8 100644 --- a/packages/botkit/src/text.test.ts +++ b/packages/botkit/src/text.test.ts @@ -129,6 +129,7 @@ const bot: BotWithVoidContextData = { username: "bot", class: Service, followerPolicy: "accept", + quotePolicy: "public", }, context: ctx, actorId: ctx.getActorUri(bot.identifier), From 8f1bf33cc4a851dc8e35dec3e1df3a18f7b72b7c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 06:51:26 +0900 Subject: [PATCH 02/25] Require transactional schema init Run PostgreSQL schema initialization only through clients that can open a transaction, so the advisory transaction lock covers the schema DDL it is meant to serialize. This removes the fallback path where a plain Queryable could acquire pg_advisory_xact_lock for only one statement and then continue with unlocked DDL. Adjust the schema initialization rejection test to use a transaction-capable fake client, preserving the unhandled rejection coverage under the stricter initializer contract. https://github.com/fedify-dev/botkit/pull/31#pullrequestreview-4632045340 Assisted-by: Codex:gpt-5.5 --- packages/botkit-postgres/src/mod.test.ts | 7 ++++++ packages/botkit-postgres/src/mod.ts | 27 +++++++++++------------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/botkit-postgres/src/mod.test.ts b/packages/botkit-postgres/src/mod.test.ts index 40505bc..7890c0b 100644 --- a/packages/botkit-postgres/src/mod.test.ts +++ b/packages/botkit-postgres/src/mod.test.ts @@ -219,6 +219,13 @@ if (postgresUrl == null) { test("does not emit unhandled rejections for schema initialization", async () => { const error = new Error("Schema initialization failed."); const sql = { + begin: async ( + callback: ( + transactionSql: { unsafe: () => Promise }, + ) => Promise, + ) => { + await callback(sql); + }, // deno-lint-ignore require-await unsafe: async () => { throw error; diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index 7c49ea4..f42f58d 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -130,26 +130,23 @@ export type PostgresRepositoryOptions = * @since 0.4.0 */ export async function initializePostgresRepositorySchema( - sql: Queryable, + sql: TransactionalQueryable, schema = "botkit", prepare = true, ): Promise { const validatedSchema = validateSchemaName(schema); - if (hasTransaction(sql)) { - await sql.begin(async (tx) => { - await initializePostgresRepositorySchemaInTransaction( - tx, - validatedSchema, - prepare, - ); - }); - return; + if (!hasTransaction(sql)) { + throw new TypeError( + "The PostgreSQL client must support transactions.", + ); } - await initializePostgresRepositorySchemaInTransaction( - sql, - validatedSchema, - prepare, - ); + await sql.begin(async (tx) => { + await initializePostgresRepositorySchemaInTransaction( + tx, + validatedSchema, + prepare, + ); + }); } async function initializePostgresRepositorySchemaInTransaction( From f4dabfa4c227cb2ad55bbfafffe47dc7d1c24221 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 06:58:09 +0900 Subject: [PATCH 03/25] Gate quote requests by target visibility Quote authorization should not bypass the audience of the message being quoted. Check the target object's recipients before policy evaluation so hidden messages cannot issue stamps to actors outside their audience. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525674748 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 50 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 27 +++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index bb4dc77..7a24670 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3436,6 +3436,56 @@ test("BotImpl.onQuoteRequested() rejects disallowed quote requests", async () => ); }); +test("BotImpl.onQuoteRequested() rejects hidden target quotes", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + let handled = false; + bot.onQuoteRequest = () => { + handled = true; + }; + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers only`, { + visibility: "followers", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/hidden-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/5"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Reject); + assert.deepStrictEqual(handled, false); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + test("AuthorizedMessage.unauthorizeQuote() checks the target message", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 8ae02a9..4682953 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -715,6 +715,20 @@ export class BotImpl implements Bot { }); if (!isActor(actor) || actor.id == null) return; const session = this.getSession(ctx); + if (!await this.#canActorSeeObject(ctx, actor.id, targetObject)) { + await session.context.sendActivity( + this, + actor, + new Reject({ + id: new URL(`/#reject/${request.id?.href}`, session.actorId), + actor: session.actorId, + to: actor.id, + object: request, + }), + { excludeBaseUris: [new URL(session.context.origin)] }, + ); + return; + } if ( instrument.attributionId != null && instrument.attributionId.href !== actor.id.href @@ -779,6 +793,19 @@ export class BotImpl implements Bot { await this.onQuoteRequest?.(session, quoteRequest); } + async #canActorSeeObject( + ctx: InboxContext, + actorId: URL, + object: Object, + ): Promise { + if (actorId.href === ctx.getActorUri(this.identifier).href) return true; + const recipients = [...object.toIds, ...object.ccIds].map((u) => u.href); + if (recipients.includes(PUBLIC_COLLECTION.href)) return true; + if (recipients.includes(actorId.href)) return true; + return recipients.includes(ctx.getFollowersUri(this.identifier).href) && + await this.repository.hasFollower(actorId); + } + async #matchesQuoteAcceptance( ctx: InboxContext, actorId: URL, From 30aa4e4d4e388852e8653e5ea342648987fa5964 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 06:58:16 +0900 Subject: [PATCH 04/25] Serialize KV quote stamp writes Duplicate QuoteRequest delivery can race the reverse index in KV-backed repositories. Use the same quote-specific lock around insertion and removal so only one stamp is stored for a quote URI and stale removals cannot clear a newer index entry. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525674749 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525683978 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/repository.test.ts | 63 ++++++++++++++++++++++++++ packages/botkit/src/repository.ts | 61 ++++++++++++++++++------- 2 files changed, 108 insertions(+), 16 deletions(-) diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index b5d414c..03bedde 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -172,6 +172,46 @@ test("MemoryCachedRepository keeps duplicate quote authorizations out of cache", ); }); +test("KvRepository serializes concurrent quote authorization inserts", async () => { + const repository = new KvRepository(new RacingQuoteAuthorizationKvStore()); + const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5" as Uuid; + const interactingObject = new URL("https://remote.example/notes/1"); + const target = new URL("https://example.com/ap/note/1"); + const first = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${firstId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: target, + }); + const second = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${secondId}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: target, + }); + + await Promise.all([ + repository.addQuoteAuthorization("bot", firstId, first), + repository.addQuoteAuthorization("bot", secondId, second), + ]); + + const indexed = await repository.findQuoteAuthorization( + "bot", + interactingObject, + ); + const stored = [ + await repository.getQuoteAuthorization("bot", firstId), + await repository.getQuoteAuthorization("bot", secondId), + ].filter((authorization) => authorization != null); + assert.deepStrictEqual(stored.length, 1); + assert.deepStrictEqual(indexed?.id?.href, stored[0].id?.href); +}); + function scopedKvKey(...rest: readonly string[]): KvKey { return ["_botkit", "bots", "bot", ...rest]; } @@ -227,6 +267,29 @@ class RecordingListMemoryKvStore extends MemoryKvStore { } } +class RacingQuoteAuthorizationKvStore extends MemoryKvStore { + #indexGets = 0; + #releaseIndexGets: (() => void) | undefined; + #indexGetBarrier = new Promise((resolve) => { + this.#releaseIndexGets = resolve; + }); + + override async get(key: KvKey): Promise { + if ( + key.includes("quoteAuthorizationsByInteractingObject") && + !key.includes("lock") + ) { + const lock = await super.get([...key, "lock"]); + if (lock == null && this.#indexGets < 2) { + this.#indexGets++; + if (this.#indexGets === 2) this.#releaseIndexGets?.(); + await this.#indexGetBarrier; + } + } + return await super.get(key); + } +} + class NonCasMemoryKvStore implements KvStore { readonly #kv = new MemoryKvStore(); diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index 55903bf..a34fcb4 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -835,6 +835,18 @@ export class KvRepository implements Repository { ); } + #quoteAuthorizationLockKey( + identifier: string, + interactingObject: URL, + ): KvKey { + return this.#key( + identifier, + "quoteAuthorizationsByInteractingObject", + interactingObject.href, + "lock", + ); + } + /** * Migrates data stored by BotKit 0.4 or earlier, which was not scoped by * bot actor identifiers, so that it belongs to the given identifier. @@ -1571,17 +1583,22 @@ export class KvRepository implements Repository { "The quote authorization interacting object is missing.", ); } - const existing = await this.kv.get( - this.#quoteAuthorizationIndexKey(identifier, interactingObject), - ); - if (existing != null) return; - await this.kv.set( - this.#key(identifier, "quoteAuthorizations", id), - await authorization.toJsonLd({ format: "compact" }), - ); - await this.kv.set( - this.#quoteAuthorizationIndexKey(identifier, interactingObject), - id, + await this.#withKvLock( + this.#quoteAuthorizationLockKey(identifier, interactingObject), + async () => { + const existing = await this.kv.get( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + if (existing != null) return; + await this.kv.set( + this.#key(identifier, "quoteAuthorizations", id), + await authorization.toJsonLd({ format: "compact" }), + ); + await this.kv.set( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + id, + ); + }, ); } @@ -1623,13 +1640,25 @@ export class KvRepository implements Repository { ): Promise { const authorization = await this.getQuoteAuthorization(identifier, id); if (authorization == null) return undefined; - await this.kv.delete(this.#key(identifier, "quoteAuthorizations", id)); const interactingObject = authorization.interactingObjectId; - if (interactingObject != null) { - await this.kv.delete( - this.#quoteAuthorizationIndexKey(identifier, interactingObject), - ); + if (interactingObject == null) { + await this.kv.delete(this.#key(identifier, "quoteAuthorizations", id)); + return authorization; } + await this.#withKvLock( + this.#quoteAuthorizationLockKey(identifier, interactingObject), + async () => { + await this.kv.delete(this.#key(identifier, "quoteAuthorizations", id)); + const indexedId = await this.kv.get( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + if (indexedId === id) { + await this.kv.delete( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + } + }, + ); return authorization; } From 12bb347737409970b125bbec2e4ef35273f4c42a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 06:58:22 +0900 Subject: [PATCH 05/25] Document quote request review polish Expose cancellation on QuoteRequest accept and reject so handlers can thread abort state through moderation work. Also document the exported policy serializer and keep the changelog's issue markers in the repository style. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525683965 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525683973 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525683976 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 18 ++++++++++-------- packages/botkit/src/quote-impl.ts | 8 ++++++-- packages/botkit/src/quote.ts | 15 +++++++++++++-- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index a7f6662..0741f05 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -48,7 +48,7 @@ To be released. at the root. - Added inbound support for consent-respecting quote posts using - [FEP-044f]. [[#27], [#28], [#31]] + [FEP-044f]. [[#27]] [[#28]] [[#31]] BotKit now serializes quote policies on outgoing messages, handles incoming `QuoteRequest` activities, automatically accepts or rejects them @@ -59,15 +59,17 @@ To be released. requests with the new `Bot.onQuoteRequest` event handler. - Added `QuotePolicy`, `QuotePolicyOption`, `QuoteRequest`, and - `QuoteRequestEventHandler` types. - - Added `Bot.onQuoteRequest` event handler. + `QuoteRequestEventHandler` types. [[#27]] [[#28]] [[#31]] + - Added `Bot.onQuoteRequest` event handler. [[#27]] [[#28]] [[#31]] - Added `ReadonlyBot.quotePolicy`, `CreateBotOptions.quotePolicy`, and - `BotProfile.quotePolicy` properties. + `BotProfile.quotePolicy` properties. [[#27]] [[#28]] [[#31]] - Added `SessionPublishOptions.quotePolicy` and - `AuthorizedMessageUpdateOptions.quotePolicy` options. + `AuthorizedMessageUpdateOptions.quotePolicy` options. [[#27]] + [[#28]] [[#31]] - Added `AuthorizedMessage.unauthorizeQuote()` method for revoking an existing quote authorization stamp by the quoted message or its URI. - - Added `@fedify/botkit/quote` module. + [[#27]] [[#28]] [[#31]] + - Added `@fedify/botkit/quote` module. [[#27]] [[#28]] [[#31]] - The `Repository` interface now stores data for multiple bot actors: every method takes the identifier of the owning bot actor as its first @@ -122,7 +124,7 @@ To be released. ### @fedify/botkit-sqlite - Added a `quote_authorizations` table for [FEP-044f] quote authorization - stamps. [[#27], [#28], [#31]] + stamps. [[#27]] [[#28]] [[#31]] - All tables now have a `bot_id` column and composite primary keys, so a single database stores the data of multiple bots. Opening a database @@ -136,7 +138,7 @@ To be released. ### @fedify/botkit-postgres - Added a `quote_authorizations` table for [FEP-044f] quote authorization - stamps. [[#27], [#28], [#31]] + stamps. [[#27]] [[#28]] [[#31]] - All tables now have a `bot_id` column and composite primary keys, so a single schema stores the data of multiple bots. Initializing a schema diff --git a/packages/botkit/src/quote-impl.ts b/packages/botkit/src/quote-impl.ts index cb4a9d2..ce885eb 100644 --- a/packages/botkit/src/quote-impl.ts +++ b/packages/botkit/src/quote-impl.ts @@ -63,13 +63,15 @@ export class QuoteRequestImpl this.#state = "pending"; } - async accept(): Promise { + async accept(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); if (this.#state !== "pending") { throw new TypeError("The quote request is not pending."); } const existing = await this.session.bot.repository.findQuoteAuthorization( this.quote.id, ); + signal?.throwIfAborted(); const authorization = existing ?? await this.#createAuthorization(); await this.session.context.sendActivity( this.session.bot, @@ -86,10 +88,12 @@ export class QuoteRequestImpl this.#state = "accepted"; } - async reject(): Promise { + async reject(signal?: AbortSignal): Promise { + signal?.throwIfAborted(); if (this.#state !== "pending") { throw new TypeError("The quote request is not pending."); } + signal?.throwIfAborted(); await this.session.context.sendActivity( this.session.bot, this.actor, diff --git a/packages/botkit/src/quote.ts b/packages/botkit/src/quote.ts index c5c7d30..3cfccb2 100644 --- a/packages/botkit/src/quote.ts +++ b/packages/botkit/src/quote.ts @@ -91,15 +91,17 @@ export interface QuoteRequest { /** * Accepts the quote request. + * @param signal An abort signal. * @throws {TypeError} The quote request is not pending. */ - accept(): Promise; + accept(signal?: AbortSignal): Promise; /** * Rejects the quote request. + * @param signal An abort signal. * @throws {TypeError} The quote request is not pending. */ - reject(): Promise; + reject(signal?: AbortSignal): Promise; } /** @@ -116,6 +118,15 @@ export function normalizeQuotePolicy( return policy; } +/** + * Serializes a quote policy option into an interaction policy for outgoing + * messages. + * @param policy The quote policy option to serialize. + * @param actorUri The URI of the actor publishing the message. + * @param followersUri The URI of the actor's followers collection. + * @returns The serialized interaction policy. + * @since 0.5.0 + */ export function serializeQuotePolicy( policy: QuotePolicyOption | undefined, actorUri: URL, From d5ef282cc8d3802fcc51d403e95381f355a794f1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 08:33:18 +0900 Subject: [PATCH 06/25] Reject wider-audience quote requests Quote authorization should not let an allowed requester publish a quote to a broader audience than the target message. Compare the computed quote and target visibility before approval, and expose the update options type through the package root so the new update API can be named by callers. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525748373 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525748376 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 51 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 37 +++++++++++++++++++- packages/botkit/src/mod.ts | 1 + 3 files changed, 88 insertions(+), 1 deletion(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 7a24670..e32baf5 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3486,6 +3486,57 @@ test("BotImpl.onQuoteRequested() rejects hidden target quotes", async () => { ); }); +test("BotImpl.onQuoteRequested() rejects wider-audience quotes", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers only`, { + visibility: "followers", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + actor, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/public-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted publicly.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/6"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Reject); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + test("AuthorizedMessage.unauthorizeQuote() checks the target message", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 4682953..a4c7efc 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -94,7 +94,12 @@ import { isQuoteLink, messageClasses, } from "./message-impl.ts"; -import type { Message, MessageClass, SharedMessage } from "./message.ts"; +import type { + Message, + MessageClass, + MessageVisibility, + SharedMessage, +} from "./message.ts"; import type { Vote } from "./poll.ts"; import { QuoteRequestImpl } from "./quote-impl.ts"; import { normalizeQuotePolicy, type QuotePolicyOption } from "./quote.ts"; @@ -760,6 +765,20 @@ export class BotImpl implements Bot { const quote = await createMessage(quoteObject, session, { [actor.id.href]: actor, }); + if (this.#isQuoteAudienceWider(quote.visibility, target.visibility)) { + await session.context.sendActivity( + this, + actor, + new Reject({ + id: new URL(`/#reject/${request.id?.href}`, session.actorId), + actor: session.actorId, + to: actor.id, + object: request, + }), + { excludeBaseUris: [new URL(session.context.origin)] }, + ); + return; + } const quoteRequest = new QuoteRequestImpl( session, request, @@ -806,6 +825,22 @@ export class BotImpl implements Bot { await this.repository.hasFollower(actorId); } + #isQuoteAudienceWider( + quoteVisibility: MessageVisibility, + targetVisibility: MessageVisibility, + ): boolean { + const ranks: Record = { + public: 4, + unlisted: 3, + followers: 2, + direct: 1, + unknown: undefined, + }; + const quoteRank = ranks[quoteVisibility]; + const targetRank = ranks[targetVisibility]; + return quoteRank != null && targetRank != null && quoteRank > targetRank; + } + async #matchesQuoteAcceptance( ctx: InboxContext, actorId: URL, diff --git a/packages/botkit/src/mod.ts b/packages/botkit/src/mod.ts index fc2cd77..a727856 100644 --- a/packages/botkit/src/mod.ts +++ b/packages/botkit/src/mod.ts @@ -66,6 +66,7 @@ export { export type { Actor, AuthorizedMessage, + AuthorizedMessageUpdateOptions, AuthorizedSharedMessage, Message, MessageClass, From 2411c63db4a352ab3d6952b36d9ac79179c1d820 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 08:44:20 +0900 Subject: [PATCH 07/25] Treat unknown quote visibility defensively Unknown quote audiences should not receive authorization stamps for restricted targets. Reject those cases before policy approval, keep the boolean assertion style aligned with the test guidelines, and add defensive ID checks before quote authorization lookup or stamp creation. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525830253 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525830263 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525830264 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525830926 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525838138 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 53 +++++++++++++++++++++++++++- packages/botkit/src/bot-impl.ts | 12 ++++--- packages/botkit/src/message-impl.ts | 3 ++ packages/botkit/src/quote-impl.ts | 2 ++ 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index e32baf5..e562d54 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3479,7 +3479,7 @@ test("BotImpl.onQuoteRequested() rejects hidden target quotes", async () => { const { recipients, activity } = ctx.sentActivities[0]; assert.deepStrictEqual(recipients, [actor]); assert.ok(activity instanceof Reject); - assert.deepStrictEqual(handled, false); + assert.ok(!handled); assert.deepStrictEqual( await repository.findQuoteAuthorization("bot", quote.id!), undefined, @@ -3537,6 +3537,57 @@ test("BotImpl.onQuoteRequested() rejects wider-audience quotes", async () => { ); }); +test("BotImpl.onQuoteRequested() rejects unknown-audience quotes", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers only`, { + visibility: "followers", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + actor, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/custom-audience-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted to a custom audience.", + to: new URL("https://remote.example/custom-audience"), + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/7"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Reject); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + test("AuthorizedMessage.unauthorizeQuote() checks the target message", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index a4c7efc..6c4c985 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -829,16 +829,18 @@ export class BotImpl implements Bot { quoteVisibility: MessageVisibility, targetVisibility: MessageVisibility, ): boolean { - const ranks: Record = { + if (targetVisibility === "unknown") return quoteVisibility !== "direct"; + if (quoteVisibility === "unknown") { + return targetVisibility !== "public" && targetVisibility !== "unlisted"; + } + const ranks: Record = { public: 4, unlisted: 3, followers: 2, direct: 1, - unknown: undefined, + unknown: 0, }; - const quoteRank = ranks[quoteVisibility]; - const targetRank = ranks[targetVisibility]; - return quoteRank != null && targetRank != null && quoteRank > targetRank; + return ranks[quoteVisibility] > ranks[targetVisibility]; } async #matchesQuoteAcceptance( diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index a27ec92..bd91560 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -616,6 +616,9 @@ export class AuthorizedMessageImpl quote: Message | URL, ): Promise { const quoteId = quote instanceof URL ? quote : quote.id; + if (quoteId == null) { + throw new TypeError("The quote message ID is missing."); + } const authorization = await this.session.bot.repository .findQuoteAuthorization(quoteId); if (authorization == null || authorization.id == null) { diff --git a/packages/botkit/src/quote-impl.ts b/packages/botkit/src/quote-impl.ts index ce885eb..f3d8ba3 100644 --- a/packages/botkit/src/quote-impl.ts +++ b/packages/botkit/src/quote-impl.ts @@ -53,6 +53,8 @@ export class QuoteRequestImpl throw new TypeError("The quote requester ID is missing."); } else if (quote.id == null) { throw new TypeError("The quote message ID is missing."); + } else if (target.id == null) { + throw new TypeError("The target message ID is missing."); } this.session = session; this.id = raw.id; From 2a713888d48aea70555e332f8608329fc71564ea Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 09:00:43 +0900 Subject: [PATCH 08/25] Harden quote request validation Quote request handling now drops malformed requests before building the public request wrapper, avoids leaking restricted target existence, and removes a stale quote stamp when an already-authorized quote is redelivered with a wider audience. The quote request accept path also refuses to reuse an authorization that belongs to another target, and the Postgres transaction guard now tolerates nullish callers defensively. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525856353 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525856356 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525856359 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525856370 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525861379 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525861380 Assisted-by: Codex:gpt-5.5 --- packages/botkit-postgres/src/mod.ts | 3 +- packages/botkit/src/bot-impl.test.ts | 80 ++++++++++++++++++++++++++-- packages/botkit/src/bot-impl.ts | 38 ++++++++----- packages/botkit/src/quote-impl.ts | 8 +++ 4 files changed, 111 insertions(+), 18 deletions(-) diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index f42f58d..9bcdea1 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -1285,7 +1285,8 @@ function validateSchemaName(schema: string): string { } function hasTransaction(sql: Queryable): sql is TransactionalQueryable { - return typeof (sql as Partial).begin === "function"; + return sql != null && + typeof (sql as Partial).begin === "function"; } async function execute( diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index e562d54..bd4b5f0 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3475,10 +3475,7 @@ test("BotImpl.onQuoteRequested() rejects hidden target quotes", async () => { }), ); - assert.deepStrictEqual(ctx.sentActivities.length, 1); - const { recipients, activity } = ctx.sentActivities[0]; - assert.deepStrictEqual(recipients, [actor]); - assert.ok(activity instanceof Reject); + assert.deepStrictEqual(ctx.sentActivities, []); assert.ok(!handled); assert.deepStrictEqual( await repository.findQuoteAuthorization("bot", quote.id!), @@ -3537,6 +3534,81 @@ test("BotImpl.onQuoteRequested() rejects wider-audience quotes", async () => { ); }); +test("BotImpl.onQuoteRequested() revokes widened quote stamps", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers only`, { + visibility: "followers", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + followers: new URL("https://remote.example/users/alice/followers"), + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + actor, + ); + const quoteId = new URL("https://remote.example/notes/widened-quote"); + const followersQuote = new Note({ + id: quoteId, + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted to followers.", + to: actor.followersId, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/7"), + actor, + object: target.id, + instrument: followersQuote, + }), + ); + + const authorization = await repository.findQuoteAuthorization( + "bot", + quoteId, + ); + assert.ok(authorization != null); + ctx.sentActivities = []; + const publicQuote = followersQuote.clone({ to: PUBLIC_COLLECTION }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/8"), + actor, + object: target.id, + instrument: publicQuote, + }), + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quoteId), + undefined, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 3); + assert.ok(ctx.sentActivities[0].activity instanceof Delete); + assert.deepStrictEqual( + ctx.sentActivities[0].activity.objectId, + authorization.id, + ); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, "followers"); + assert.ok(ctx.sentActivities[2].activity instanceof Reject); +}); + test("BotImpl.onQuoteRequested() rejects unknown-audience quotes", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 6c4c985..489f6d6 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -688,7 +688,7 @@ export class BotImpl implements Bot { ctx: InboxContext, request: RawQuoteRequest, ): Promise { - if (request.actorId == null) return; + if (request.id == null || request.actorId == null) return; const parsedObj = parseLocalUri( ctx, request.objectId, @@ -712,7 +712,7 @@ export class BotImpl implements Bot { documentLoader, suppressError: true, }); - if (!isMessageObject(instrument)) return; + if (!isMessageObject(instrument) || instrument.id == null) return; const actor = await request.getActor({ contextLoader: ctx.contextLoader, documentLoader, @@ -721,17 +721,6 @@ export class BotImpl implements Bot { if (!isActor(actor) || actor.id == null) return; const session = this.getSession(ctx); if (!await this.#canActorSeeObject(ctx, actor.id, targetObject)) { - await session.context.sendActivity( - this, - actor, - new Reject({ - id: new URL(`/#reject/${request.id?.href}`, session.actorId), - actor: session.actorId, - to: actor.id, - object: request, - }), - { excludeBaseUris: [new URL(session.context.origin)] }, - ); return; } if ( @@ -765,7 +754,30 @@ export class BotImpl implements Bot { const quote = await createMessage(quoteObject, session, { [actor.id.href]: actor, }); + const existingAuthorization = await this.repository.findQuoteAuthorization( + quote.id, + ); + if ( + existingAuthorization != null && + existingAuthorization.interactionTargetId?.href !== target.id.href + ) { + await session.context.sendActivity( + this, + actor, + new Reject({ + id: new URL(`/#reject/${request.id.href}`, session.actorId), + actor: session.actorId, + to: actor.id, + object: request, + }), + { excludeBaseUris: [new URL(session.context.origin)] }, + ); + return; + } if (this.#isQuoteAudienceWider(quote.visibility, target.visibility)) { + if (existingAuthorization != null) { + await target.unauthorizeQuote(quote); + } await session.context.sendActivity( this, actor, diff --git a/packages/botkit/src/quote-impl.ts b/packages/botkit/src/quote-impl.ts index f3d8ba3..afe5ab4 100644 --- a/packages/botkit/src/quote-impl.ts +++ b/packages/botkit/src/quote-impl.ts @@ -73,6 +73,14 @@ export class QuoteRequestImpl const existing = await this.session.bot.repository.findQuoteAuthorization( this.quote.id, ); + if ( + existing != null && + existing.interactionTargetId?.href !== this.target.id.href + ) { + throw new TypeError( + "The quote authorization does not belong to this message.", + ); + } signal?.throwIfAborted(); const authorization = existing ?? await this.#createAuthorization(); await this.session.context.sendActivity( From 01f759f7d0943ecb5f8f20baa3912e4699795d6f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 09:35:27 +0900 Subject: [PATCH 09/25] Harden quote request edge cases Static instance bots now receive the configured quote policy, so their outgoing messages advertise the same fallback policy as top-level and dynamic bots. Inbound quote request handling also avoids nullable IDs before repository lookups, shares the local reject path, and rechecks first-wins quote stamps after insert before sending an Accept. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525878584 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525878586 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525878587 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525878588 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525880935 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525880936 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 81 ++++++++++++++++++++++- packages/botkit/src/bot-impl.ts | 39 ++++------- packages/botkit/src/instance-impl.test.ts | 32 +++++++++ packages/botkit/src/instance-impl.ts | 1 + packages/botkit/src/quote-impl.ts | 5 ++ 5 files changed, 130 insertions(+), 28 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index bd4b5f0..575625e 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -36,6 +36,7 @@ import { PropertyValue, PUBLIC_COLLECTION, Question, + QuoteAuthorization, QuoteRequest, type Recipient, Reject, @@ -56,7 +57,7 @@ import type { } from "./message.ts"; import type { Vote } from "./poll.ts"; import type { Like, Reaction } from "./reaction.ts"; -import { MemoryRepository } from "./repository.ts"; +import { MemoryRepository, type Uuid } from "./repository.ts"; import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; import { mention, strong, text } from "./text.ts"; @@ -3609,6 +3610,84 @@ test("BotImpl.onQuoteRequested() revokes widened quote stamps", async () => { assert.ok(ctx.sentActivities[2].activity instanceof Reject); }); +test("BotImpl.onQuoteRequested() does not accept a raced stamp", async () => { + class RacedQuoteAuthorizationRepository extends MemoryRepository { + readonly racedAuthorization: QuoteAuthorization; + + constructor(racedAuthorization: QuoteAuthorization) { + super(); + this.racedAuthorization = racedAuthorization; + } + + override async addQuoteAuthorization( + identifier: string, + _id: Uuid, + authorization: QuoteAuthorization, + ): Promise { + const interactingObject = authorization.interactingObjectId; + if (interactingObject == null) { + throw new TypeError( + "The quote authorization interacting object is missing.", + ); + } + if ( + await this.findQuoteAuthorization(identifier, interactingObject) == null + ) { + await super.addQuoteAuthorization( + identifier, + "01950000-0000-7000-8000-000000000001" as Uuid, + this.racedAuthorization, + ); + } + } + } + + const racedAuthorization = new QuoteAuthorization({ + id: new URL("https://example.com/ap/quote-authorization/raced"), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: new URL("https://remote.example/notes/raced-quote"), + interactionTarget: new URL("https://example.com/ap/note/other-target"), + }); + const repository = new RacedQuoteAuthorizationRepository( + racedAuthorization, + ); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "public", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: racedAuthorization.interactingObjectId, + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + + await assert.rejects( + bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/raced"), + actor, + object: target.id, + instrument: quote, + }), + ), + TypeError, + ); + assert.deepStrictEqual(ctx.sentActivities, []); +}); + test("BotImpl.onQuoteRequested() rejects unknown-audience quotes", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 489f6d6..59cabca 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -689,6 +689,7 @@ export class BotImpl implements Bot { request: RawQuoteRequest, ): Promise { if (request.id == null || request.actorId == null) return; + const requestId = request.id; const parsedObj = parseLocalUri( ctx, request.objectId, @@ -723,21 +724,24 @@ export class BotImpl implements Bot { if (!await this.#canActorSeeObject(ctx, actor.id, targetObject)) { return; } - if ( - instrument.attributionId != null && - instrument.attributionId.href !== actor.id.href - ) { + const rejectRequest = async () => { await session.context.sendActivity( this, actor, new Reject({ - id: new URL(`/#reject/${request.id?.href}`, session.actorId), + id: new URL(`/#reject/${requestId.href}`, session.actorId), actor: session.actorId, to: actor.id, object: request, }), { excludeBaseUris: [new URL(session.context.origin)] }, ); + }; + if ( + instrument.attributionId != null && + instrument.attributionId.href !== actor.id.href + ) { + await rejectRequest(); return; } const quoteObject = instrument.attributionId == null @@ -754,6 +758,7 @@ export class BotImpl implements Bot { const quote = await createMessage(quoteObject, session, { [actor.id.href]: actor, }); + if (quote.id == null || target.id == null) return; const existingAuthorization = await this.repository.findQuoteAuthorization( quote.id, ); @@ -761,34 +766,14 @@ export class BotImpl implements Bot { existingAuthorization != null && existingAuthorization.interactionTargetId?.href !== target.id.href ) { - await session.context.sendActivity( - this, - actor, - new Reject({ - id: new URL(`/#reject/${request.id.href}`, session.actorId), - actor: session.actorId, - to: actor.id, - object: request, - }), - { excludeBaseUris: [new URL(session.context.origin)] }, - ); + await rejectRequest(); return; } if (this.#isQuoteAudienceWider(quote.visibility, target.visibility)) { if (existingAuthorization != null) { await target.unauthorizeQuote(quote); } - await session.context.sendActivity( - this, - actor, - new Reject({ - id: new URL(`/#reject/${request.id?.href}`, session.actorId), - actor: session.actorId, - to: actor.id, - object: request, - }), - { excludeBaseUris: [new URL(session.context.origin)] }, - ); + await rejectRequest(); return; } const quoteRequest = new QuoteRequestImpl( diff --git a/packages/botkit/src/instance-impl.test.ts b/packages/botkit/src/instance-impl.test.ts index 7d250ac..f6f393c 100644 --- a/packages/botkit/src/instance-impl.test.ts +++ b/packages/botkit/src/instance-impl.test.ts @@ -14,9 +14,12 @@ // 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 { Create, Note } from "@fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; import { createInstance } from "./instance.ts"; +import { MemoryRepository } from "./repository.ts"; +import { text } from "./text.ts"; describe("createInstance()", () => { test("serves a static bot's actor", async () => { @@ -51,6 +54,35 @@ describe("createInstance()", () => { assert.deepStrictEqual(jrd.subject, "acct:mybot@example.com"); }); + test("passes quotePolicy to a static bot", async () => { + const repository = new MemoryRepository(); + const instance = createInstance({ + kv: new MemoryKvStore(), + repository, + }); + const bot = instance.createBot("bot", { + username: "mybot", + quotePolicy: "nobody", + }); + const session = bot.getSession("https://example.com"); + + await session.publish(text`Nobody can quote this.`); + + const messages = []; + for await (const message of repository.getMessages("bot")) { + messages.push(message); + } + assert.deepStrictEqual(messages.length, 1); + const activity = messages[0]; + assert.ok(activity instanceof Create); + const object = await activity.getObject(session.context); + assert.ok(object instanceof Note); + assert.deepStrictEqual( + object.interactionPolicy?.canQuote?.automaticApprovals, + [session.actorId], + ); + }); + test("returns 404 for unregistered identifiers", async () => { const instance = createInstance({ kv: new MemoryKvStore() }); instance.createBot("bot", { username: "mybot" }); diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index b1e1713..e58bfc6 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -473,6 +473,7 @@ export class InstanceImpl image: profile.image, properties: profile.properties, followerPolicy: profile.followerPolicy, + quotePolicy: profile.quotePolicy, }); return wrapBotImpl(bot); } diff --git a/packages/botkit/src/quote-impl.ts b/packages/botkit/src/quote-impl.ts index afe5ab4..cfabb3a 100644 --- a/packages/botkit/src/quote-impl.ts +++ b/packages/botkit/src/quote-impl.ts @@ -83,6 +83,11 @@ export class QuoteRequestImpl } signal?.throwIfAborted(); const authorization = existing ?? await this.#createAuthorization(); + if (authorization.interactionTargetId?.href !== this.target.id.href) { + throw new TypeError( + "The quote authorization does not belong to this message.", + ); + } await this.session.context.sendActivity( this.session.bot, this.actor, From 22fd90275ac484737a82cc9656e352ceb275f8d8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 10:52:53 +0900 Subject: [PATCH 10/25] Constrain quote authorization scope Inbound quote requests now prove that the supplied quote post actually points at the requested target before authorization is considered. The target audience check also verifies concrete recipient sets, so a quote cannot reuse a coarse followers or direct visibility label while addressing a wider collection than the original message allowed. https://github.com/fedify-dev/botkit/pull/31#discussion_r3525939707 https://github.com/fedify-dev/botkit/pull/31#discussion_r3525943996 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 98 +++++++++++++++++++++++++++- packages/botkit/src/bot-impl.ts | 51 +++++++++++++-- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 575625e..b2b451b 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3535,6 +3535,99 @@ test("BotImpl.onQuoteRequested() rejects wider-audience quotes", async () => { ); }); +test("BotImpl.onQuoteRequested() rejects unrelated quote instruments", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + const otherTarget = await session.publish(text`Not this`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/unrelated-quote"), + attribution: actor.id, + quoteUrl: otherTarget.id, + content: "Quoted.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/unrelated"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + +test("BotImpl.onQuoteRequested() rejects non-subset quote audiences", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers only`, { + visibility: "followers", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + followers: new URL("https://remote.example/users/alice/followers"), + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + actor, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/own-followers-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted to my followers.", + to: actor.followersId, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/own-followers"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Reject); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + test("BotImpl.onQuoteRequested() revokes widened quote stamps", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ @@ -3564,8 +3657,9 @@ test("BotImpl.onQuoteRequested() revokes widened quote stamps", async () => { id: quoteId, attribution: actor.id, quoteUrl: target.id, - content: "Quoted to followers.", - to: actor.followersId, + content: "Quoted directly.", + to: actor.id, + tags: [new Mention({ href: actor.id })], }); await bot.onQuoteRequested( diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 59cabca..bb0e010 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -706,7 +706,7 @@ export class BotImpl implements Bot { ); if (!(stored instanceof Create)) return; const targetObject = await stored.getObject(ctx); - if (!isMessageObject(targetObject)) return; + if (!isMessageObject(targetObject) || targetObject.id == null) return; const documentLoader = await ctx.getDocumentLoader(this); const instrument = await request.getInstrument({ contextLoader: ctx.contextLoader, @@ -714,6 +714,7 @@ export class BotImpl implements Bot { suppressError: true, }); if (!isMessageObject(instrument) || instrument.id == null) return; + if (instrument.quoteUrl?.href !== targetObject.id.href) return; const actor = await request.getActor({ contextLoader: ctx.contextLoader, documentLoader, @@ -769,7 +770,16 @@ export class BotImpl implements Bot { await rejectRequest(); return; } - if (this.#isQuoteAudienceWider(quote.visibility, target.visibility)) { + if ( + await this.#isQuoteAudienceWider( + ctx, + actor.id, + quoteObject, + targetObject, + quote.visibility, + target.visibility, + ) + ) { if (existingAuthorization != null) { await target.unauthorizeQuote(quote); } @@ -822,10 +832,14 @@ export class BotImpl implements Bot { await this.repository.hasFollower(actorId); } - #isQuoteAudienceWider( + async #isQuoteAudienceWider( + ctx: InboxContext, + actorId: URL, + quoteObject: Object, + targetObject: Object, quoteVisibility: MessageVisibility, targetVisibility: MessageVisibility, - ): boolean { + ): Promise { if (targetVisibility === "unknown") return quoteVisibility !== "direct"; if (quoteVisibility === "unknown") { return targetVisibility !== "public" && targetVisibility !== "unlisted"; @@ -837,7 +851,34 @@ export class BotImpl implements Bot { direct: 1, unknown: 0, }; - return ranks[quoteVisibility] > ranks[targetVisibility]; + if (ranks[quoteVisibility] > ranks[targetVisibility]) return true; + return !await this.#isQuoteAudienceSubset( + ctx, + actorId, + quoteObject, + targetObject, + ); + } + + async #isQuoteAudienceSubset( + ctx: InboxContext, + actorId: URL, + quoteObject: Object, + targetObject: Object, + ): Promise { + const targetRecipients = new Set( + [...targetObject.toIds, ...targetObject.ccIds].map((u) => u.href), + ); + if (targetRecipients.has(PUBLIC_COLLECTION.href)) return true; + const followerCollection = ctx.getFollowersUri(this.identifier).href; + const actorIsFollower = targetRecipients.has(followerCollection) && + await this.repository.hasFollower(actorId); + for (const recipient of [...quoteObject.toIds, ...quoteObject.ccIds]) { + if (targetRecipients.has(recipient.href)) continue; + if (actorIsFollower && recipient.href === actorId.href) continue; + return false; + } + return true; } async #matchesQuoteAcceptance( From 9141de1a24b7636d2b7e0c64b83265237a59b0f8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 11:16:57 +0900 Subject: [PATCH 11/25] Allow quote stamps for target authors Quote audience subset checks now include the local target author as an allowed recipient, matching the rule that a bot can always see its own messages even when the target was followers-only or direct. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526093344 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 53 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 1 + 2 files changed, 54 insertions(+) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index b2b451b..7f008d0 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3628,6 +3628,59 @@ test("BotImpl.onQuoteRequested() rejects non-subset quote audiences", async () = ); }); +test("BotImpl.onQuoteRequested() accepts quotes to the target author", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers only`, { + visibility: "followers", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow"), + actor, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/author-only-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted to the original author.", + to: session.actorId, + tags: [new Mention({ href: session.actorId })], + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/author-only"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Accept); + const authorization = await repository.findQuoteAuthorization( + "bot", + quote.id!, + ); + assert.deepStrictEqual(authorization?.interactionTargetId, target.id); +}); + test("BotImpl.onQuoteRequested() revokes widened quote stamps", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index bb0e010..ece2b27 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -869,6 +869,7 @@ export class BotImpl implements Bot { const targetRecipients = new Set( [...targetObject.toIds, ...targetObject.ccIds].map((u) => u.href), ); + targetRecipients.add(ctx.getActorUri(this.identifier).href); if (targetRecipients.has(PUBLIC_COLLECTION.href)) return true; const followerCollection = ctx.getFollowersUri(this.identifier).href; const actorIsFollower = targetRecipients.has(followerCollection) && From 6afdd9f2e36b6bc4d2298184946d97b7780d6a15 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 11:32:01 +0900 Subject: [PATCH 12/25] Honor FEP quote targets Inbound quote requests now prefer the FEP quote property when checking that the supplied quote post targets the requested object, while keeping the legacy quoteUrl fallback for compatibility. SQLite quote authorization removal now deletes the row and returns the stored JSON in one statement, so corrupted stored data cannot keep the row around after revocation. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526122997 https://github.com/fedify-dev/botkit/pull/31#discussion_r3526134496 Assisted-by: Codex:gpt-5.5 --- packages/botkit-sqlite/src/mod.test.ts | 43 ++++++++++++++++++++++++ packages/botkit-sqlite/src/mod.ts | 11 ++++--- packages/botkit/src/bot-impl.test.ts | 45 ++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 3 +- 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/packages/botkit-sqlite/src/mod.test.ts b/packages/botkit-sqlite/src/mod.test.ts index 0038051..d27f651 100644 --- a/packages/botkit-sqlite/src/mod.test.ts +++ b/packages/botkit-sqlite/src/mod.test.ts @@ -370,6 +370,49 @@ describe("SqliteRepository", () => { } }); + test("removes quote authorization rows before parsing", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "botkit_sqlite_test_")); + const dbPath = `${tempDir}/test.db`; + const repo = createSqliteRepository({ path: dbPath }); + repo.close(); + const db = new DatabaseSync(dbPath); + try { + db.prepare(` + INSERT INTO quote_authorizations + (bot_id, id, interacting_object, authorization_json) + VALUES (?, ?, ?, ?) + `).run( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + "https://remote.example/notes/quote", + "{", + ); + } finally { + db.close(); + } + + const reopened = createSqliteRepository({ path: dbPath }); + try { + assert.deepStrictEqual( + await reopened.removeQuoteAuthorization( + "bot", + "01942976-3400-7f34-872e-2cbf0f9eeac4", + ), + undefined, + ); + assert.deepStrictEqual( + await reopened.findQuoteAuthorization( + "bot", + new URL("https://remote.example/notes/quote"), + ), + undefined, + ); + } finally { + reopened.close(); + await rm(tempDir, { recursive: true, force: true }); + } + }); + test("file-based database persistence", async () => { const tempDir = await mkdtemp(join(tmpdir(), "botkit_sqlite_test_")); const dbPath = `${tempDir}/test.db`; diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index 63b9600..ee7083f 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -1059,13 +1059,14 @@ export class SqliteRepository implements Repository, Disposable { identifier: string, id: Uuid, ): Promise { - const authorization = await this.getQuoteAuthorization(identifier, id); - if (authorization == null) return undefined; const stmt = this.db.prepare( - "DELETE FROM quote_authorizations WHERE bot_id = ? AND id = ?", + "DELETE FROM quote_authorizations WHERE bot_id = ? AND id = ? " + + "RETURNING authorization_json", ); - stmt.run(identifier, id); - return authorization; + const row = stmt.get(identifier, id) as + | { authorization_json: string } + | undefined; + return await parseQuoteAuthorizationJson(row?.authorization_json); } vote( diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 7f008d0..60a7b61 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3388,6 +3388,51 @@ test("BotImpl.onQuoteRequested() accepts public quote requests", async () => { assert.deepStrictEqual(ctx.sentActivities[1].recipients, "followers"); }); +test("BotImpl.onQuoteRequested() accepts FEP quote IDs", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/fep-quote"), + attribution: actor.id, + quote: target.id, + content: "Quoted with FEP-044f.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/fep"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Accept); + const authorization = await repository.findQuoteAuthorization( + "bot", + quote.id!, + ); + assert.deepStrictEqual(authorization?.interactingObjectId, quote.id); + assert.deepStrictEqual(authorization?.interactionTargetId, target.id); +}); + test("BotImpl.onQuoteRequested() rejects disallowed quote requests", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index ece2b27..aa1277a 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -714,7 +714,8 @@ export class BotImpl implements Bot { suppressError: true, }); if (!isMessageObject(instrument) || instrument.id == null) return; - if (instrument.quoteUrl?.href !== targetObject.id.href) return; + const quotedObjectId = instrument.quoteId ?? instrument.quoteUrl; + if (quotedObjectId?.href !== targetObject.id.href) return; const actor = await request.getActor({ contextLoader: ctx.contextLoader, documentLoader, From f14bdf453d63d5e79f14c6b37ba228448395e283 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 11:48:07 +0900 Subject: [PATCH 13/25] Revoke stale quote stamps Quote request rejection now removes an existing authorization for the same quote and target before sending Reject, so a policy change cannot leave a still-served approval stamp behind. The local URI class check now avoids an unsafe any cast while preserving the same runtime membership check. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526156255 https://github.com/fedify-dev/botkit/pull/31#discussion_r3526165455 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 67 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 13 ++++-- 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 60a7b61..059f085 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3802,6 +3802,73 @@ test("BotImpl.onQuoteRequested() revokes widened quote stamps", async () => { assert.ok(ctx.sentActivities[2].activity instanceof Reject); }); +test("BotImpl.onQuoteRequested() revokes disallowed quote stamps", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "public", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Quote me`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quoteId = new URL("https://remote.example/notes/stale-quote"); + const quote = new Note({ + id: quoteId, + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted directly.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/old-policy"), + actor, + object: target.id, + instrument: quote, + }), + ); + + const authorization = await repository.findQuoteAuthorization( + "bot", + quoteId, + ); + assert.ok(authorization != null); + await target.update(text`Quote me`, { quotePolicy: "nobody" }); + ctx.sentActivities = []; + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/new-policy"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quoteId), + undefined, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 3); + assert.ok(ctx.sentActivities[0].activity instanceof Delete); + assert.deepStrictEqual( + ctx.sentActivities[0].activity.objectId, + authorization.id, + ); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, "followers"); + assert.ok(ctx.sentActivities[2].activity instanceof Reject); +}); + test("BotImpl.onQuoteRequested() does not accept a raced stamp", async () => { class RacedQuoteAuthorizationRepository extends MemoryRepository { readonly racedAuthorization: QuoteAuthorization; diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index aa1277a..9ec7100 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -697,8 +697,7 @@ export class BotImpl implements Bot { ); if ( parsedObj?.type !== "object" || - // deno-lint-ignore no-explicit-any - !messageClasses.includes(parsedObj.class as any) || + !(messageClasses as readonly unknown[]).includes(parsedObj.class) || parsedObj.values.identifier !== this.identifier ) return; const stored = await this.repository.getMessage( @@ -794,6 +793,12 @@ export class BotImpl implements Bot { quote, target, ); + const revokeAndRejectQuoteRequest = async () => { + if (existingAuthorization != null) { + await target.unauthorizeQuote(quote); + } + await quoteRequest.reject(); + }; const rule = targetObject.interactionPolicy?.canQuote; if (rule == null) { const policy = normalizeQuotePolicy(this.quotePolicy); @@ -804,7 +809,7 @@ export class BotImpl implements Bot { ) { // Leave pending for the event handler below. } else { - await quoteRequest.reject(); + await revokeAndRejectQuoteRequest(); } } else if ( await this.#matchesQuoteApprovals(ctx, actor.id, rule.automaticApprovals) @@ -815,7 +820,7 @@ export class BotImpl implements Bot { ) { // Leave pending for the event handler below. } else { - await quoteRequest.reject(); + await revokeAndRejectQuoteRequest(); } await this.onQuoteRequest?.(session, quoteRequest); } From a563113e2788cf3d0ad039ae18dd190e3f4de3e8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 12:05:49 +0900 Subject: [PATCH 14/25] Remove KV quote stamps before parsing KV-backed quote authorization removal now deletes the stored row and its interacting-object index before attempting to parse the JSON-LD payload. This keeps corrupted stored data from leaving stale quote stamp indexes behind. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526198641 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/repository.test.ts | 28 +++++++++++++ packages/botkit/src/repository.ts | 57 +++++++++++++++++--------- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 03bedde..7ab9e7d 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -212,6 +212,34 @@ test("KvRepository serializes concurrent quote authorization inserts", async () assert.deepStrictEqual(indexed?.id?.href, stored[0].id?.href); }); +test("KvRepository removes quote authorization indexes before parsing", async () => { + const kv = new MemoryKvStore(); + const repository = new KvRepository(kv); + const id = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + const interactingObject = new URL("https://remote.example/notes/quote"); + const authorizationKey = scopedKvKey("quoteAuthorizations", id); + const indexKey = scopedKvKey( + "quoteAuthorizationsByInteractingObject", + interactingObject.href, + ); + await kv.set(authorizationKey, { + "@context": "https://www.w3.org/ns/activitystreams", + type: "QuoteAuthorization", + interactingObject: interactingObject.href, + }); + await kv.set(indexKey, id); + + const removed = await repository.removeQuoteAuthorization("bot", id); + + assert.ok(removed?.interactingObjectId == null); + assert.deepStrictEqual(await kv.get(authorizationKey), undefined); + assert.deepStrictEqual(await kv.get(indexKey), undefined); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", interactingObject), + undefined, + ); +}); + function scopedKvKey(...rest: readonly string[]): KvKey { return ["_botkit", "bots", "bot", ...rest]; } diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index a34fcb4..1ab0fa1 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -52,6 +52,20 @@ function isLegacyKvLock(value: unknown): value is string { return typeof value === "string" && !uuidPattern.test(value); } +function getRawQuoteAuthorizationInteractingObject( + json: unknown, +): URL | undefined { + if (typeof json !== "object" || json == null) return undefined; + if (!("interactingObject" in json)) return undefined; + const interactingObject = json.interactingObject; + if (typeof interactingObject !== "string") return undefined; + try { + return new URL(interactingObject); + } catch { + return undefined; + } +} + /** * A UUID (universally unique identifier). * @since 0.3.0 @@ -1638,28 +1652,31 @@ export class KvRepository implements Repository { identifier: string, id: Uuid, ): Promise { - const authorization = await this.getQuoteAuthorization(identifier, id); - if (authorization == null) return undefined; - const interactingObject = authorization.interactingObjectId; + const key = this.#key(identifier, "quoteAuthorizations", id); + const json = await this.kv.get(key); + if (json == null) return undefined; + const interactingObject = getRawQuoteAuthorizationInteractingObject(json); if (interactingObject == null) { - await this.kv.delete(this.#key(identifier, "quoteAuthorizations", id)); - return authorization; + await this.kv.delete(key); + } else { + const indexKey = this.#quoteAuthorizationIndexKey( + identifier, + interactingObject, + ); + await this.#withKvLock( + this.#quoteAuthorizationLockKey(identifier, interactingObject), + async () => { + await this.kv.delete(key); + const indexedId = await this.kv.get(indexKey); + if (indexedId === id) await this.kv.delete(indexKey); + }, + ); + } + try { + return await QuoteAuthorization.fromJsonLd(json); + } catch { + return undefined; } - await this.#withKvLock( - this.#quoteAuthorizationLockKey(identifier, interactingObject), - async () => { - await this.kv.delete(this.#key(identifier, "quoteAuthorizations", id)); - const indexedId = await this.kv.get( - this.#quoteAuthorizationIndexKey(identifier, interactingObject), - ); - if (indexedId === id) { - await this.kv.delete( - this.#quoteAuthorizationIndexKey(identifier, interactingObject), - ); - } - }, - ); - return authorization; } async #addToFolloweeIndex( From 44cd2151266cfe4459d0f15a69b59e02771266e1 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 12:31:32 +0900 Subject: [PATCH 15/25] Preserve FEP quote handling Quote materialization and Create routing now use the FEP quote property before the legacy quoteUrl fallback, so accepted quote requests and later Create deliveries expose the same quote target. Duplicate manually approved quote requests now replay the existing Accept instead of surfacing another pending moderation request for the same stored authorization. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526241765 https://github.com/fedify-dev/botkit/pull/31#discussion_r3526241767 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 122 +++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 12 ++- packages/botkit/src/message-impl.ts | 2 +- 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 059f085..729bd99 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -2068,6 +2068,55 @@ test("BotImpl.onCreated()", async (t) => { ctx.forwardedRecipients = []; }); + await t.test("on FEP quote", async () => { + const create = new Create({ + id: new URL( + "https://example.com/ap/create/9cfd7129-4cf0-4505-90d8-3cac2dc42435", + ), + actor: new URL("https://example.com/ap/actor/john"), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + object: new Note({ + id: new URL( + "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42435", + ), + attribution: new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + content: "It's a FEP quote!", + quote: new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", + ), + }), + }); + let quoted: [Session, Message][] = []; + bot.onQuote = (session, msg) => void (quoted.push([session, msg])); + + await bot.onCreated(ctx, create); + + assert.deepStrictEqual(quoted.length, 1); + const [, msg] = quoted[0]; + assert.ok(msg.quoteTarget != null); + assert.deepStrictEqual( + msg.quoteTarget.id, + new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", + ), + ); + assert.deepStrictEqual(replied, []); + assert.deepStrictEqual(mentioned, []); + assert.deepStrictEqual(messaged, quoted); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + + quoted = []; + messaged = []; + ctx.forwardedRecipients = []; + }); + await t.test("on message", async () => { const create = new Create({ id: new URL( @@ -3395,6 +3444,10 @@ test("BotImpl.onQuoteRequested() accepts FEP quote IDs", async () => { repository, username: "bot", }); + let quoteMessage: Message | undefined; + bot.onQuoteRequest = (_session, request) => { + quoteMessage = request.quote; + }; const ctx = createMockInboxContext(bot, "https://example.com", "bot"); const session = new SessionImpl(bot, ctx); const target = await session.publish(text`Quote me`); @@ -3431,6 +3484,75 @@ test("BotImpl.onQuoteRequested() accepts FEP quote IDs", async () => { ); assert.deepStrictEqual(authorization?.interactingObjectId, quote.id); assert.deepStrictEqual(authorization?.interactionTargetId, target.id); + assert.deepStrictEqual(quoteMessage?.quoteTarget?.id, target.id); +}); + +test("BotImpl.onQuoteRequested() replays manual quote approvals", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "manual", + }); + let handled = 0; + bot.onQuoteRequest = async (_session, request) => { + handled++; + await request.accept(); + }; + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Review quotes`); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const quote = new Note({ + id: new URL("https://remote.example/notes/manual-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted after review.", + to: PUBLIC_COLLECTION, + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/manual"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(handled, 1); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); + const authorization = await repository.findQuoteAuthorization( + "bot", + quote.id!, + ); + assert.ok(authorization?.id != null); + ctx.sentActivities = []; + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/manual-again"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(handled, 1); + assert.deepStrictEqual(ctx.sentActivities.length, 1); + assert.ok(ctx.sentActivities[0].activity instanceof Accept); + assert.deepStrictEqual( + ctx.sentActivities[0].activity.resultId?.href, + authorization.id.href, + ); }); test("BotImpl.onQuoteRequested() rejects disallowed quote requests", async () => { diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 9ec7100..bf5b4c8 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -807,7 +807,10 @@ export class BotImpl implements Bot { } else if ( await this.#matchesQuoteAcceptance(ctx, actor.id, policy.manual) ) { - // Leave pending for the event handler below. + if (existingAuthorization != null) { + await quoteRequest.accept(); + return; + } } else { await revokeAndRejectQuoteRequest(); } @@ -818,7 +821,10 @@ export class BotImpl implements Bot { } else if ( await this.#matchesQuoteApprovals(ctx, actor.id, rule.manualApprovals) ) { - // Leave pending for the event handler below. + if (existingAuthorization != null) { + await quoteRequest.accept(); + return; + } } else { await revokeAndRejectQuoteRequest(); } @@ -1098,7 +1104,7 @@ export class BotImpl implements Bot { break; } } - if (quoteUrl == null) quoteUrl = object.quoteUrl; + if (quoteUrl == null) quoteUrl = object.quoteId ?? object.quoteUrl; const quoteTarget = parseLocalUri( ctx, quoteUrl, diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index bd91560..33b56b1 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -817,7 +817,7 @@ export async function createMessage( quoteUrl = quoteLink.href; break; } - if (quoteUrl == null) quoteUrl = raw.quoteUrl; + if (quoteUrl == null) quoteUrl = raw.quoteId ?? raw.quoteUrl; let qt: Object | null = null; const parsed = parseLocalUri( session.context, From 559b978cb57d40a3fff760ca37e15463f98b14b6 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 12:52:37 +0900 Subject: [PATCH 16/25] Check direct quote recipients Direct quote requests against unknown-visibility targets now still run the recipient subset check. This keeps a requester who can see the target from getting a stamp for a direct quote addressed outside the target audience. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526313480 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 66 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 10 ++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 729bd99..70ba256 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4120,6 +4120,72 @@ test("BotImpl.onQuoteRequested() rejects unknown-audience quotes", async () => { ); }); +test("BotImpl.onQuoteRequested() checks direct quote recipients for unknown targets", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "public", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const unrelatedActor = new Person({ + id: new URL("https://remote.example/users/bob"), + preferredUsername: "bob", + }); + const targetId = new URL( + "https://example.com/ap/note/01950000-0000-7000-8000-000000000101", + ); + await repository.addMessage( + "bot", + "01950000-0000-7000-8000-000000000101" as Uuid, + new Create({ + id: new URL( + "https://example.com/ap/create/01950000-0000-7000-8000-000000000101", + ), + actor: session.actorId, + object: new Note({ + id: targetId, + attribution: session.actorId, + content: "A target with an unrecognized audience.", + to: actor.id, + }), + }), + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/direct-quote-outside-target"), + attribution: actor.id, + quoteUrl: targetId, + content: "Quoted directly to someone else.", + to: unrelatedActor.id, + tags: [new Mention({ href: unrelatedActor.id })], + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/direct-outside"), + actor, + object: targetId, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Reject); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + test("AuthorizedMessage.unauthorizeQuote() checks the target message", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index bf5b4c8..ccb2195 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -852,7 +852,15 @@ export class BotImpl implements Bot { quoteVisibility: MessageVisibility, targetVisibility: MessageVisibility, ): Promise { - if (targetVisibility === "unknown") return quoteVisibility !== "direct"; + if (targetVisibility === "unknown") { + return quoteVisibility !== "direct" || + !await this.#isQuoteAudienceSubset( + ctx, + actorId, + quoteObject, + targetObject, + ); + } if (quoteVisibility === "unknown") { return targetVisibility !== "public" && targetVisibility !== "unlisted"; } From 0851c4fc737151f7ed7a093743f22eebcea0ace0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 13:08:41 +0900 Subject: [PATCH 17/25] Permit follower quote recipients Followers-only targets now treat each quoted recipient covered by the bot's followers collection as part of the target audience. This keeps quote subset checks aligned with the fetch permission model instead of only allowing the requesting actor through that collection. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526342706 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 62 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 13 +++--- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 70ba256..e5adcd2 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3848,6 +3848,68 @@ test("BotImpl.onQuoteRequested() accepts quotes to the target author", async () assert.deepStrictEqual(authorization?.interactionTargetId, target.id); }); +test("BotImpl.onQuoteRequested() accepts quotes to target followers", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + quotePolicy: "followers", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, ctx); + const target = await session.publish(text`Followers only`, { + visibility: "followers", + }); + ctx.sentActivities = []; + const actor = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const otherFollower = new Person({ + id: new URL("https://remote.example/users/bob"), + preferredUsername: "bob", + }); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow/alice"), + actor, + ); + await repository.addFollower( + "bot", + new URL("https://remote.example/activities/follow/bob"), + otherFollower, + ); + const quote = new Note({ + id: new URL("https://remote.example/notes/follower-only-quote"), + attribution: actor.id, + quoteUrl: target.id, + content: "Quoted to another follower.", + to: otherFollower.id, + tags: [new Mention({ href: otherFollower.id })], + }); + + await bot.onQuoteRequested( + ctx, + new QuoteRequest({ + id: new URL("https://remote.example/quote-requests/other-follower"), + actor, + object: target.id, + instrument: quote, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 1); + const { recipients, activity } = ctx.sentActivities[0]; + assert.deepStrictEqual(recipients, [actor]); + assert.ok(activity instanceof Accept); + const authorization = await repository.findQuoteAuthorization( + "bot", + quote.id!, + ); + assert.deepStrictEqual(authorization?.interactionTargetId, target.id); +}); + test("BotImpl.onQuoteRequested() revokes widened quote stamps", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index ccb2195..43ad804 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -773,7 +773,6 @@ export class BotImpl implements Bot { if ( await this.#isQuoteAudienceWider( ctx, - actor.id, quoteObject, targetObject, quote.visibility, @@ -846,7 +845,6 @@ export class BotImpl implements Bot { async #isQuoteAudienceWider( ctx: InboxContext, - actorId: URL, quoteObject: Object, targetObject: Object, quoteVisibility: MessageVisibility, @@ -856,7 +854,6 @@ export class BotImpl implements Bot { return quoteVisibility !== "direct" || !await this.#isQuoteAudienceSubset( ctx, - actorId, quoteObject, targetObject, ); @@ -874,7 +871,6 @@ export class BotImpl implements Bot { if (ranks[quoteVisibility] > ranks[targetVisibility]) return true; return !await this.#isQuoteAudienceSubset( ctx, - actorId, quoteObject, targetObject, ); @@ -882,7 +878,6 @@ export class BotImpl implements Bot { async #isQuoteAudienceSubset( ctx: InboxContext, - actorId: URL, quoteObject: Object, targetObject: Object, ): Promise { @@ -892,11 +887,13 @@ export class BotImpl implements Bot { targetRecipients.add(ctx.getActorUri(this.identifier).href); if (targetRecipients.has(PUBLIC_COLLECTION.href)) return true; const followerCollection = ctx.getFollowersUri(this.identifier).href; - const actorIsFollower = targetRecipients.has(followerCollection) && - await this.repository.hasFollower(actorId); + const targetIncludesFollowers = targetRecipients.has(followerCollection); for (const recipient of [...quoteObject.toIds, ...quoteObject.ccIds]) { if (targetRecipients.has(recipient.href)) continue; - if (actorIsFollower && recipient.href === actorId.href) continue; + if ( + targetIncludesFollowers && + await this.repository.hasFollower(recipient) + ) continue; return false; } return true; From b9213cdb1fa3855d6d7a9f95444ed8cccf4511db Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 13:29:36 +0900 Subject: [PATCH 18/25] Validate inbound quote stamps Inbound FEP quote creates now have to present a local quote authorization that matches both the quote object and the quoted target before BotKit fires onQuote or forwards the activity as a local quote. Legacy quote links still follow the existing path. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526391527 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 65 ++++++++++++++++++++++++++-- packages/botkit/src/bot-impl.ts | 40 +++++++++++++++-- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index e5adcd2..a69bb4c 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -2068,7 +2068,7 @@ test("BotImpl.onCreated()", async (t) => { ctx.forwardedRecipients = []; }); - await t.test("on FEP quote", async () => { + await t.test("on unauthorized FEP quote", async () => { const create = new Create({ id: new URL( "https://example.com/ap/create/9cfd7129-4cf0-4505-90d8-3cac2dc42435", @@ -2097,14 +2097,71 @@ test("BotImpl.onCreated()", async (t) => { await bot.onCreated(ctx, create); + assert.deepStrictEqual(quoted, []); + assert.deepStrictEqual(replied, []); + assert.deepStrictEqual(mentioned, []); + assert.deepStrictEqual(messaged.length, 1); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + + quoted = []; + messaged = []; + ctx.forwardedRecipients = []; + }); + + await t.test("on authorized FEP quote", async () => { + const quoteId = new URL( + "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42435", + ); + const targetId = new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", + ); + const authorizationId = "01950000-0000-7000-8000-000000000201" as Uuid; + const authorizationUrl = new URL( + "https://example.com/ap/actor/bot/quote-authorization/" + + authorizationId, + ); + await repository.addQuoteAuthorization( + "bot", + authorizationId, + new QuoteAuthorization({ + id: authorizationUrl, + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quoteId, + interactionTarget: targetId, + }), + ); + const create = new Create({ + id: new URL( + "https://example.com/ap/create/9cfd7129-4cf0-4505-90d8-3cac2dc42435", + ), + actor: new URL("https://example.com/ap/actor/john"), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + object: new Note({ + id: quoteId, + attribution: new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + content: "It's a FEP quote!", + quote: targetId, + quoteAuthorization: authorizationUrl, + }), + }); + let quoted: [Session, Message][] = []; + bot.onQuote = (session, msg) => void (quoted.push([session, msg])); + + await bot.onCreated(ctx, create); + assert.deepStrictEqual(quoted.length, 1); const [, msg] = quoted[0]; assert.ok(msg.quoteTarget != null); assert.deepStrictEqual( msg.quoteTarget.id, - new URL( - "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", - ), + targetId, ); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 43ad804..5133891 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -47,7 +47,7 @@ import { PropertyValue, PUBLIC_COLLECTION, Question, - type QuoteAuthorization, + QuoteAuthorization, type QuoteRequest as RawQuoteRequest, type Recipient, Reject, @@ -934,6 +934,35 @@ export class BotImpl implements Bot { return false; } + async #hasValidQuoteAuthorization( + ctx: InboxContext, + object: Object, + targetId: URL, + ): Promise { + if ( + object.id == null || + !("quoteAuthorizationId" in object) || + !(object.quoteAuthorizationId instanceof URL) + ) return false; + const parsed = parseLocalUri( + ctx, + object.quoteAuthorizationId, + this.legacyObjectUrisIdentifier, + ); + if ( + parsed?.type !== "object" || + parsed.class !== QuoteAuthorization || + parsed.values.identifier !== this.identifier + ) return false; + const authorization = await this.repository.getQuoteAuthorization( + parsed.values.id as Uuid, + ); + return authorization?.attributionId?.href === + ctx.getActorUri(this.identifier).href && + authorization.interactingObjectId?.href === object.id.href && + authorization.interactionTargetId?.href === targetId.href; + } + async onCreated( ctx: InboxContext, create: Create, @@ -1109,7 +1138,9 @@ export class BotImpl implements Bot { break; } } - if (quoteUrl == null) quoteUrl = object.quoteId ?? object.quoteUrl; + const fepQuoteUrl = object.quoteId; + const requiresQuoteAuthorization = quoteUrl == null && fepQuoteUrl != null; + if (quoteUrl == null) quoteUrl = fepQuoteUrl ?? object.quoteUrl; const quoteTarget = parseLocalUri( ctx, quoteUrl, @@ -1120,7 +1151,10 @@ export class BotImpl implements Bot { quoteTarget?.type === "object" && // @ts-ignore: quoteTarget.class satisfies (typeof messageClasses)[number] messageClasses.includes(quoteTarget.class) && - quoteTarget.values.identifier === this.identifier + quoteTarget.values.identifier === this.identifier && + (!requiresQuoteAuthorization || + (fepQuoteUrl != null && + await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl))) ) { const message = await getMessage(); if ( From 7ac74aabf2fcdfb027d7feafadfb1f2dcb1caed2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 13:48:03 +0900 Subject: [PATCH 19/25] Require stamps for FEP fallback quotes FEP quote creates now require a matching local quote authorization even when they also include a legacy quote link for compatibility. The fallback link can still identify the local quote target, but it no longer bypasses the consent check attached to the FEP quote property. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526440552 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 49 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 2 +- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index a69bb4c..93392f5 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -29,6 +29,7 @@ import { Follow, Image, Like as RawLike, + Link, Mention, Note, Person, @@ -2109,6 +2110,54 @@ test("BotImpl.onCreated()", async (t) => { ctx.forwardedRecipients = []; }); + await t.test("on unauthorized FEP quote with fallback link", async () => { + const targetId = new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", + ); + const create = new Create({ + id: new URL( + "https://example.com/ap/create/9cfd7129-4cf0-4505-90d8-3cac2dc42436", + ), + actor: new URL("https://example.com/ap/actor/john"), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + object: new Note({ + id: new URL( + "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42436", + ), + attribution: new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + content: "It's a FEP quote with a fallback link!", + quote: targetId, + tags: [ + new Link({ + href: targetId, + mediaType: "application/activity+json", + }), + ], + }), + }); + let quoted: [Session, Message][] = []; + bot.onQuote = (session, msg) => void (quoted.push([session, msg])); + + await bot.onCreated(ctx, create); + + assert.deepStrictEqual(quoted, []); + assert.deepStrictEqual(replied, []); + assert.deepStrictEqual(mentioned, []); + assert.deepStrictEqual(messaged.length, 1); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + + quoted = []; + messaged = []; + ctx.forwardedRecipients = []; + }); + await t.test("on authorized FEP quote", async () => { const quoteId = new URL( "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42435", diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 5133891..f771763 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -1139,7 +1139,7 @@ export class BotImpl implements Bot { } } const fepQuoteUrl = object.quoteId; - const requiresQuoteAuthorization = quoteUrl == null && fepQuoteUrl != null; + const requiresQuoteAuthorization = fepQuoteUrl != null; if (quoteUrl == null) quoteUrl = fepQuoteUrl ?? object.quoteUrl; const quoteTarget = parseLocalUri( ctx, From 8735b9865ce6748f55a40ac1aca34a9122a135ab Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 14:10:35 +0900 Subject: [PATCH 20/25] Route FEP quote creates Shared-inbox Create routing now treats FEP quote targets like the existing legacy quote targets, so a pure FEP quote can reach the bot that owns the quoted message before quote authorization is checked. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526480941 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/instance-impl.ts | 1 + packages/botkit/src/instance-routing.test.ts | 51 ++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index e58bfc6..f0541f1 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -728,6 +728,7 @@ export class InstanceImpl addLocalObject(tag.href); } } + addLocalObject(object.quoteId); addLocalObject(object.quoteUrl); // Bots whose message is replied to: addLocalObject(object.replyTargetId); diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index 88a0180..47f2401 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -23,6 +23,7 @@ import { Note, Person, PUBLIC_COLLECTION, + QuoteAuthorization, Undo, } from "@fedify/vocab"; import assert from "node:assert"; @@ -343,6 +344,56 @@ describe("shared inbox routing", () => { assert.deepStrictEqual(events, ["mention:beta"]); }); + test("routes pure FEP quote Creates to the quoted bot", async () => { + const { instance, repository, alpha, beta, ctx } = createHarness(); + const events: string[] = []; + alpha.onQuote = (session) => + void (events.push(`quote:${session.bot.identifier}`)); + beta.onQuote = (session) => + void (events.push(`quote:${session.bot.identifier}`)); + + const quoteId = new URL("https://remote.example/notes/fep-quote"); + const targetId = new URL( + "https://example.com/ap/actor/alpha/note/01950000-0000-7000-8000-000000000301", + ); + const authorizationId = "01950000-0000-7000-8000-000000000302" as Uuid; + const authorizationUrl = new URL( + `https://example.com/ap/actor/alpha/quote-authorization/${authorizationId}`, + ); + await repository.addQuoteAuthorization( + "alpha", + authorizationId, + new QuoteAuthorization({ + id: authorizationUrl, + attribution: new URL("https://example.com/ap/actor/alpha"), + interactingObject: quoteId, + interactionTarget: targetId, + }), + ); + + const author = new Person({ + id: new URL("https://remote.example/actors/john"), + preferredUsername: "john", + }); + await instance.onCreated( + ctx, + new Create({ + id: new URL("https://remote.example/creates/fep-quote"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: quoteId, + attribution: author, + to: PUBLIC_COLLECTION, + content: "A pure FEP quote", + quote: targetId, + quoteAuthorization: authorizationUrl, + }), + }), + ); + assert.deepStrictEqual(events, ["quote:alpha"]); + }); + test("routes Create to followers of the author", async () => { const { instance, repository, alpha, beta, ctx } = createHarness(); const events: string[] = []; From 59e1e31ff44b7925dcae531702e6fcadf9a64b24 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 14:24:06 +0900 Subject: [PATCH 21/25] Prefer FEP quote targets Inbound quote handling now selects the FEP quote target before any legacy fallback link. That keeps authorization checks, event routing, and the materialized quote target aligned when a Create carries both forms but they disagree. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526560272 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 93 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 2 +- packages/botkit/src/message-impl.ts | 14 +++-- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 93392f5..a1ab9b2 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -2223,6 +2223,99 @@ test("BotImpl.onCreated()", async (t) => { ctx.forwardedRecipients = []; }); + await t.test("on authorized FEP quote with mismatched fallback link", async () => { + const quoteId = new URL( + "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42437", + ); + const fallbackTargetId = new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", + ); + const fepTargetId = new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168df", + ); + await repository.addMessage( + "bot", + "a6358f1b-c978-49d3-8065-37a1df6168df", + new Create({ + id: new URL( + "https://example.com/ap/create/a6358f1b-c978-49d3-8065-37a1df6168df", + ), + actor: new URL("https://example.com/ap/actor/bot"), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/bot/followers"), + object: new Note({ + id: fepTargetId, + attribution: new URL("https://example.com/ap/actor/bot"), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/bot/followers"), + content: "Another local post", + }), + }), + ); + const authorizationId = "01950000-0000-7000-8000-000000000202" as Uuid; + const authorizationUrl = new URL( + "https://example.com/ap/actor/bot/quote-authorization/" + + authorizationId, + ); + await repository.addQuoteAuthorization( + "bot", + authorizationId, + new QuoteAuthorization({ + id: authorizationUrl, + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quoteId, + interactionTarget: fepTargetId, + }), + ); + const create = new Create({ + id: new URL( + "https://example.com/ap/create/9cfd7129-4cf0-4505-90d8-3cac2dc42437", + ), + actor: new URL("https://example.com/ap/actor/john"), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + object: new Note({ + id: quoteId, + attribution: new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + content: "It's a FEP quote with a mismatched fallback link!", + quote: fepTargetId, + quoteAuthorization: authorizationUrl, + tags: [ + new Link({ + href: fallbackTargetId, + mediaType: "application/activity+json", + }), + ], + }), + }); + let quoted: [Session, Message][] = []; + bot.onQuote = (session, msg) => void (quoted.push([session, msg])); + + await bot.onCreated(ctx, create); + + assert.deepStrictEqual(quoted.length, 1); + const [, msg] = quoted[0]; + assert.ok(msg.quoteTarget != null); + assert.deepStrictEqual( + msg.quoteTarget.id, + fepTargetId, + ); + assert.deepStrictEqual(replied, []); + assert.deepStrictEqual(mentioned, []); + assert.deepStrictEqual(messaged, quoted); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + + quoted = []; + messaged = []; + ctx.forwardedRecipients = []; + }); + await t.test("on message", async () => { const create = new Create({ id: new URL( diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index f771763..6d10c48 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -1140,7 +1140,7 @@ export class BotImpl implements Bot { } const fepQuoteUrl = object.quoteId; const requiresQuoteAuthorization = fepQuoteUrl != null; - if (quoteUrl == null) quoteUrl = fepQuoteUrl ?? object.quoteUrl; + quoteUrl = fepQuoteUrl ?? quoteUrl ?? object.quoteUrl; const quoteTarget = parseLocalUri( ctx, quoteUrl, diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 33b56b1..0d149b9 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -811,13 +811,15 @@ export async function createMessage( } } if (quoteTarget == null) { - let quoteUrl: URL | null = null; - for (const quoteLink of quoteLinks) { - if (quoteLink.href == null) continue; - quoteUrl = quoteLink.href; - break; + let quoteUrl = raw.quoteId; + if (quoteUrl == null) { + for (const quoteLink of quoteLinks) { + if (quoteLink.href == null) continue; + quoteUrl = quoteLink.href; + break; + } } - if (quoteUrl == null) quoteUrl = raw.quoteId ?? raw.quoteUrl; + if (quoteUrl == null) quoteUrl = raw.quoteUrl; let qt: Object | null = null; const parsed = parseLocalUri( session.context, From 6858ba4bac8d351f762a86547b8eaccad591bdb0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 15:08:15 +0900 Subject: [PATCH 22/25] Recheck quote delivery audiences Inbound FEP quote delivery now re-runs the same audience widening check used for quote requests before dispatching onQuote. This keeps a reused stamp from authorizing a Create whose audience is broader than the quoted target, and drops the stale stamp when that happens. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526669444 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 76 ++++++++++++++++++ packages/botkit/src/bot-impl.ts | 84 +++++++++++++++++++- packages/botkit/src/instance-routing.test.ts | 18 +++++ 3 files changed, 174 insertions(+), 4 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index a1ab9b2..1a41646 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -2316,6 +2316,82 @@ test("BotImpl.onCreated()", async (t) => { ctx.forwardedRecipients = []; }); + await t.test("on authorized FEP quote with widened audience", async () => { + const quoteId = new URL( + "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42438", + ); + const targetId = new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168e0", + ); + await repository.addMessage( + "bot", + "a6358f1b-c978-49d3-8065-37a1df6168e0", + new Create({ + id: new URL( + "https://example.com/ap/create/a6358f1b-c978-49d3-8065-37a1df6168e0", + ), + actor: new URL("https://example.com/ap/actor/bot"), + to: new URL("https://example.com/ap/actor/bot/followers"), + object: new Note({ + id: targetId, + attribution: new URL("https://example.com/ap/actor/bot"), + to: new URL("https://example.com/ap/actor/bot/followers"), + content: "Followers only", + }), + }), + ); + const authorizationId = "01950000-0000-7000-8000-000000000203" as Uuid; + const authorizationUrl = new URL( + "https://example.com/ap/actor/bot/quote-authorization/" + + authorizationId, + ); + await repository.addQuoteAuthorization( + "bot", + authorizationId, + new QuoteAuthorization({ + id: authorizationUrl, + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quoteId, + interactionTarget: targetId, + }), + ); + const actor = new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + followers: new URL("https://example.com/ap/actor/john/followers"), + }); + const create = new Create({ + id: new URL( + "https://example.com/ap/create/9cfd7129-4cf0-4505-90d8-3cac2dc42438", + ), + actor, + to: PUBLIC_COLLECTION, + object: new Note({ + id: quoteId, + attribution: actor, + to: PUBLIC_COLLECTION, + content: "It's a public FEP quote of a followers-only post!", + quote: targetId, + quoteAuthorization: authorizationUrl, + }), + }); + let quoted: [Session, Message][] = []; + bot.onQuote = (session, msg) => void (quoted.push([session, msg])); + + await bot.onCreated(ctx, create); + + assert.deepStrictEqual(quoted, []); + assert.deepStrictEqual(replied, []); + assert.deepStrictEqual(mentioned, []); + assert.deepStrictEqual(messaged.length, 1); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + + quoted = []; + messaged = []; + ctx.forwardedRecipients = []; + }); + await t.test("on message", async () => { const create = new Create({ id: new URL( diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 6d10c48..98ed114 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -899,6 +899,40 @@ export class BotImpl implements Bot { return true; } + async #getMessageVisibility( + ctx: InboxContext, + object: Object, + ): Promise { + const documentLoader = await ctx.getDocumentLoader(this); + const actor = object.attributionId?.href === + ctx.getActorUri(this.identifier).href + ? await this.getSession(ctx).getActor() + : await object.getAttribution({ + contextLoader: ctx.contextLoader, + documentLoader, + suppressError: true, + }); + if (!isActor(actor)) return null; + const mentionedActorIds = new Set(); + for await ( + const tag of object.getTags({ + contextLoader: ctx.contextLoader, + documentLoader, + suppressError: true, + }) + ) { + if (tag instanceof Mention && tag.href != null) { + mentionedActorIds.add(tag.href.href); + } + } + return getMessageVisibility( + object.toIds, + object.ccIds, + actor, + mentionedActorIds, + ); + } + async #matchesQuoteAcceptance( ctx: InboxContext, actorId: URL, @@ -957,10 +991,52 @@ export class BotImpl implements Bot { const authorization = await this.repository.getQuoteAuthorization( parsed.values.id as Uuid, ); - return authorization?.attributionId?.href === - ctx.getActorUri(this.identifier).href && - authorization.interactingObjectId?.href === object.id.href && - authorization.interactionTargetId?.href === targetId.href; + if ( + authorization?.attributionId?.href !== + ctx.getActorUri(this.identifier).href || + authorization.interactingObjectId?.href !== object.id.href || + authorization.interactionTargetId?.href !== targetId.href + ) { + return false; + } + const parsedTarget = parseLocalUri( + ctx, + targetId, + this.legacyObjectUrisIdentifier, + ); + if ( + parsedTarget?.type !== "object" || + parsedTarget.values.identifier !== this.identifier + ) return false; + const stored = await this.repository.getMessage( + parsedTarget.values.id as Uuid, + ); + if (!(stored instanceof Create)) return false; + const targetObject = await stored.getObject(ctx); + if ( + !isMessageObject(targetObject) || targetObject.id?.href !== targetId.href + ) { + return false; + } + const quoteVisibility = await this.#getMessageVisibility(ctx, object); + const targetVisibility = await this.#getMessageVisibility( + ctx, + targetObject, + ); + if (quoteVisibility == null || targetVisibility == null) return false; + if ( + await this.#isQuoteAudienceWider( + ctx, + object, + targetObject, + quoteVisibility, + targetVisibility, + ) + ) { + await this.repository.removeQuoteAuthorization(parsed.values.id as Uuid); + return false; + } + return true; } async onCreated( diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index 47f2401..3d4d6cb 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -356,6 +356,24 @@ describe("shared inbox routing", () => { const targetId = new URL( "https://example.com/ap/actor/alpha/note/01950000-0000-7000-8000-000000000301", ); + const targetMessageId = "01950000-0000-7000-8000-000000000301" as Uuid; + await repository.addMessage( + "alpha", + targetMessageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/alpha/create/${targetMessageId}`, + ), + actor: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: targetId, + attribution: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + content: "Alpha's quoteable post", + }), + }), + ); const authorizationId = "01950000-0000-7000-8000-000000000302" as Uuid; const authorizationUrl = new URL( `https://example.com/ap/actor/alpha/quote-authorization/${authorizationId}`, From ecd35ca19fda74b0b346afccf978d0b2b316aee7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 16:23:25 +0900 Subject: [PATCH 23/25] Recover stale quote stamp indexes KV quote authorization inserts now verify that an existing quote index still resolves before treating it as authoritative. If the indexed stamp disappeared first, the stale index is cleared and the new stamp is stored so Accept.result can reference a dispatchable authorization. https://github.com/fedify-dev/botkit/pull/31#discussion_r3526769709 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/repository.test.ts | 30 ++++++++++++++++++++++++++ packages/botkit/src/repository.ts | 11 +++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 7ab9e7d..2760781 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -212,6 +212,36 @@ test("KvRepository serializes concurrent quote authorization inserts", async () assert.deepStrictEqual(indexed?.id?.href, stored[0].id?.href); }); +test("KvRepository replaces stale quote authorization indexes", async () => { + const kv = new MemoryKvStore(); + const repository = new KvRepository(kv); + const staleId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + const id = "01942976-3400-7f34-872e-2cbf0f9eeac5" as Uuid; + const interactingObject = new URL("https://remote.example/notes/quote"); + const authorization = new QuoteAuthorization({ + id: new URL( + `https://example.com/ap/actor/bot/quote-authorization/${id}`, + ), + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject, + interactionTarget: new URL("https://example.com/ap/note/1"), + }); + const indexKey = scopedKvKey( + "quoteAuthorizationsByInteractingObject", + interactingObject.href, + ); + await kv.set(indexKey, staleId); + + await repository.addQuoteAuthorization("bot", id, authorization); + + assert.deepStrictEqual(await kv.get(indexKey), id); + assert.deepStrictEqual( + (await repository.findQuoteAuthorization("bot", interactingObject))?.id + ?.href, + authorization.id?.href, + ); +}); + test("KvRepository removes quote authorization indexes before parsing", async () => { const kv = new MemoryKvStore(); const repository = new KvRepository(kv); diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index 1ab0fa1..569047e 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -1603,7 +1603,16 @@ export class KvRepository implements Repository { const existing = await this.kv.get( this.#quoteAuthorizationIndexKey(identifier, interactingObject), ); - if (existing != null) return; + if (existing != null) { + const authorization = await this.getQuoteAuthorization( + identifier, + existing, + ); + if (authorization != null) return; + await this.kv.delete( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + } await this.kv.set( this.#key(identifier, "quoteAuthorizations", id), await authorization.toJsonLd({ format: "compact" }), From 3aba8619f2b8032cd01dc05f2ed99226071be67b Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 16:39:50 +0900 Subject: [PATCH 24/25] Block unauthorized FEP quote delivery Inbound FEP quote Creates for local targets now stop before generic message delivery when their QuoteAuthorization is missing or invalid. This keeps pure FEP quote routing from exposing an unauthorized quote to onMessage after onQuote correctly declines it. https://github.com/fedify-dev/botkit/pull/31#discussion_r3527108974 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 6 +++--- packages/botkit/src/bot-impl.ts | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 1a41646..33d338f 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -2101,7 +2101,7 @@ test("BotImpl.onCreated()", async (t) => { assert.deepStrictEqual(quoted, []); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged.length, 1); + assert.deepStrictEqual(messaged, []); assert.deepStrictEqual(ctx.sentActivities, []); assert.deepStrictEqual(ctx.forwardedRecipients, []); @@ -2149,7 +2149,7 @@ test("BotImpl.onCreated()", async (t) => { assert.deepStrictEqual(quoted, []); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged.length, 1); + assert.deepStrictEqual(messaged, []); assert.deepStrictEqual(ctx.sentActivities, []); assert.deepStrictEqual(ctx.forwardedRecipients, []); @@ -2383,7 +2383,7 @@ test("BotImpl.onCreated()", async (t) => { assert.deepStrictEqual(quoted, []); assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); - assert.deepStrictEqual(messaged.length, 1); + assert.deepStrictEqual(messaged, []); assert.deepStrictEqual(ctx.sentActivities, []); assert.deepStrictEqual(ctx.forwardedRecipients, []); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 98ed114..9cb49ed 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -1222,15 +1222,20 @@ export class BotImpl implements Bot { quoteUrl, this.legacyObjectUrisIdentifier, ); - if ( - this.onQuote != null && - quoteTarget?.type === "object" && + const isLocalQuoteTarget = quoteTarget?.type === "object" && // @ts-ignore: quoteTarget.class satisfies (typeof messageClasses)[number] messageClasses.includes(quoteTarget.class) && - quoteTarget.values.identifier === this.identifier && - (!requiresQuoteAuthorization || - (fepQuoteUrl != null && - await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl))) + quoteTarget.values.identifier === this.identifier; + const hasValidQuoteAuthorization = !requiresQuoteAuthorization || + (fepQuoteUrl != null && isLocalQuoteTarget && + await this.#hasValidQuoteAuthorization(ctx, object, fepQuoteUrl)); + if (requiresQuoteAuthorization && isLocalQuoteTarget) { + if (!hasValidQuoteAuthorization) return; + } + if ( + this.onQuote != null && + isLocalQuoteTarget && + hasValidQuoteAuthorization ) { const message = await getMessage(); if ( From 05031e09047f5e91ce9cc128382536c39767fc17 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 16:56:45 +0900 Subject: [PATCH 25/25] Honor quote approvals on updates Inbound Update activities that add a FEP quote authorization now route to the quoted bot and reuse the same validation path as Create delivery. The validation path also revokes widened quote stamps through the public revocation flow so Delete activities are sent, and MemoryRepository now keeps reverse indexes unless they still point at the removed stamp. https://github.com/fedify-dev/botkit/pull/31#discussion_r3527202780 https://github.com/fedify-dev/botkit/pull/31#discussion_r3527202783 https://github.com/fedify-dev/botkit/pull/31#discussion_r3527204207 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 75 +++++++++++++++++++- packages/botkit/src/bot-impl.ts | 28 +++++++- packages/botkit/src/instance-impl.ts | 28 ++++++++ packages/botkit/src/instance-routing.test.ts | 67 +++++++++++++++++ packages/botkit/src/repository.ts | 7 +- 5 files changed, 201 insertions(+), 4 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 33d338f..70441e4 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -2223,6 +2223,68 @@ test("BotImpl.onCreated()", async (t) => { ctx.forwardedRecipients = []; }); + await t.test("on authorized FEP quote update", async () => { + const quoteId = new URL( + "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42439", + ); + const targetId = new URL( + "https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de", + ); + const authorizationId = "01950000-0000-7000-8000-000000000204" as Uuid; + const authorizationUrl = new URL( + "https://example.com/ap/actor/bot/quote-authorization/" + + authorizationId, + ); + await repository.addQuoteAuthorization( + "bot", + authorizationId, + new QuoteAuthorization({ + id: authorizationUrl, + attribution: new URL("https://example.com/ap/actor/bot"), + interactingObject: quoteId, + interactionTarget: targetId, + }), + ); + const update = new Update({ + id: new URL( + "https://example.com/ap/update/9cfd7129-4cf0-4505-90d8-3cac2dc42439", + ), + actor: new URL("https://example.com/ap/actor/john"), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + object: new Note({ + id: quoteId, + attribution: new Person({ + id: new URL("https://example.com/ap/actor/john"), + preferredUsername: "john", + }), + to: PUBLIC_COLLECTION, + cc: new URL("https://example.com/ap/actor/john/followers"), + content: "It's an approved FEP quote update!", + quote: targetId, + quoteAuthorization: authorizationUrl, + }), + }); + let quoted: [Session, Message][] = []; + bot.onQuote = (session, msg) => void (quoted.push([session, msg])); + + await bot.onUpdated(ctx, update); + + assert.deepStrictEqual(quoted.length, 1); + const [, msg] = quoted[0]; + assert.ok(msg.quoteTarget != null); + assert.deepStrictEqual(msg.quoteTarget.id, targetId); + assert.deepStrictEqual(replied, []); + assert.deepStrictEqual(mentioned, []); + assert.deepStrictEqual(messaged, quoted); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, ["followers"]); + + quoted = []; + messaged = []; + ctx.forwardedRecipients = []; + }); + await t.test("on authorized FEP quote with mismatched fallback link", async () => { const quoteId = new URL( "https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42437", @@ -2384,11 +2446,22 @@ test("BotImpl.onCreated()", async (t) => { assert.deepStrictEqual(replied, []); assert.deepStrictEqual(mentioned, []); assert.deepStrictEqual(messaged, []); - assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.sentActivities.length, 2); + const [actorDelete, followersDelete] = ctx.sentActivities; + assert.deepStrictEqual( + actorDelete.recipients, + [actor], + ); + assert.ok(actorDelete.activity instanceof Delete); + assert.deepStrictEqual(actorDelete.activity.objectId, authorizationUrl); + assert.deepStrictEqual(followersDelete.recipients, "followers"); + assert.ok(followersDelete.activity instanceof Delete); + assert.deepStrictEqual(followersDelete.activity.objectId, authorizationUrl); assert.deepStrictEqual(ctx.forwardedRecipients, []); quoted = []; messaged = []; + ctx.sentActivities = []; ctx.forwardedRecipients = []; }); diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 9cb49ed..b59fe6c 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -970,7 +970,7 @@ export class BotImpl implements Bot { async #hasValidQuoteAuthorization( ctx: InboxContext, - object: Object, + object: MessageClass, targetId: URL, ): Promise { if ( @@ -1033,7 +1033,17 @@ export class BotImpl implements Bot { targetVisibility, ) ) { - await this.repository.removeQuoteAuthorization(parsed.values.id as Uuid); + const session = this.getSession(ctx); + const target = await createMessage( + targetObject, + session, + {}, + undefined, + undefined, + true, + ); + const quote = await createMessage(object, session, {}); + await target.unauthorizeQuote(quote); return false; } return true; @@ -1042,6 +1052,20 @@ export class BotImpl implements Bot { async onCreated( ctx: InboxContext, create: Create, + ): Promise { + await this.#onCreatedOrUpdated(ctx, create); + } + + async onUpdated( + ctx: InboxContext, + update: Update, + ): Promise { + await this.#onCreatedOrUpdated(ctx, update); + } + + async #onCreatedOrUpdated( + ctx: InboxContext, + create: Create | Update, ): Promise { const object = await create.getObject(ctx); if ( diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index f0541f1..1f2b364 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -47,6 +47,7 @@ import { QuoteRequest, Reject, Undo, + Update, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import mimeDb from "mime-db"; @@ -322,6 +323,7 @@ export class InstanceImpl .on(Accept, (ctx, accept) => this.onFollowAccepted(ctx, accept)) .on(Reject, (ctx, reject) => this.onFollowRejected(ctx, reject)) .on(Create, (ctx, create) => this.onCreated(ctx, create)) + .on(Update, (ctx, update) => this.onUpdated(ctx, update)) .on(Announce, (ctx, announce) => this.onAnnounced(ctx, announce)) .on(RawLike, (ctx, like) => this.onLiked(ctx, like)) .setSharedKeyDispatcher((ctx) => this.dispatchSharedKey(ctx)); @@ -738,6 +740,32 @@ export class InstanceImpl for (const bot of bots) await bot.onCreated(ctx, create); } + async onUpdated( + ctx: InboxContext, + update: Update, + ): Promise { + const bots = await this.#resolveTargets(ctx, async () => { + const targets = new Set(); + const addLocalObject = (uri: URL | null) => { + const parsed = parseLocalUri( + ctx, + uri, + this.legacyObjectUrisIdentifier, + ); + if ( + parsed?.type === "object" && + typeof parsed.values.identifier === "string" + ) { + targets.add(parsed.values.identifier); + } + }; + const object = await update.getObject(ctx); + if (isMessageObject(object)) addLocalObject(object.quoteId); + return targets; + }); + for (const bot of bots) await bot.onUpdated(ctx, update); + } + async onAnnounced( ctx: InboxContext, announce: Announce, diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index 3d4d6cb..93f17d3 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -25,6 +25,7 @@ import { PUBLIC_COLLECTION, QuoteAuthorization, Undo, + Update, } from "@fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; @@ -412,6 +413,72 @@ describe("shared inbox routing", () => { assert.deepStrictEqual(events, ["quote:alpha"]); }); + test("routes pure FEP quote Updates to the quoted bot", async () => { + const { instance, repository, alpha, ctx } = createHarness(); + const events: string[] = []; + alpha.onQuote = (session) => + void (events.push(`quote:${session.bot.identifier}`)); + + const targetMessageId = "01950000-0000-7000-8000-000000000303" as Uuid; + const targetId = new URL( + `https://example.com/ap/actor/alpha/note/${targetMessageId}`, + ); + const quoteId = new URL("https://remote.example/notes/fep-quote-update"); + await repository.addMessage( + "alpha", + targetMessageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/alpha/create/${targetMessageId}`, + ), + actor: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: targetId, + attribution: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + content: "Alpha's quoteable post", + }), + }), + ); + const authorizationId = "01950000-0000-7000-8000-000000000304" as Uuid; + const authorizationUrl = new URL( + `https://example.com/ap/actor/alpha/quote-authorization/${authorizationId}`, + ); + await repository.addQuoteAuthorization( + "alpha", + authorizationId, + new QuoteAuthorization({ + id: authorizationUrl, + attribution: new URL("https://example.com/ap/actor/alpha"), + interactingObject: quoteId, + interactionTarget: targetId, + }), + ); + + const author = new Person({ + id: new URL("https://remote.example/actors/john"), + preferredUsername: "john", + }); + await instance.onUpdated( + ctx, + new Update({ + id: new URL("https://remote.example/updates/fep-quote"), + actor: author, + to: PUBLIC_COLLECTION, + object: new Note({ + id: quoteId, + attribution: author, + to: PUBLIC_COLLECTION, + content: "A pure FEP quote update", + quote: targetId, + quoteAuthorization: authorizationUrl, + }), + }), + ); + assert.deepStrictEqual(events, ["quote:alpha"]); + }); + test("routes Create to followers of the author", async () => { const { instance, repository, alpha, beta, ctx } = createHarness(); const events: string[] = []; diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index 569047e..dca9d04 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -2153,9 +2153,14 @@ export class MemoryRepository implements Repository { data.quoteAuthorizations.delete(id); const interactingObject = authorization?.interactingObjectId; if (interactingObject != null) { - data.quoteAuthorizationsByInteractingObject.delete( + const indexedId = data.quoteAuthorizationsByInteractingObject.get( interactingObject.href, ); + if (indexedId === id) { + data.quoteAuthorizationsByInteractingObject.delete( + interactingObject.href, + ); + } } return Promise.resolve(authorization); }