diff --git a/CHANGES.md b/CHANGES.md index 8009484..0741f05 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -47,6 +47,30 @@ 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. [[#27]] [[#28]] [[#31]] + - Added `Bot.onQuoteRequest` event handler. [[#27]] [[#28]] [[#31]] + - Added `ReadonlyBot.quotePolicy`, `CreateBotOptions.quotePolicy`, and + `BotProfile.quotePolicy` properties. [[#27]] [[#28]] [[#31]] + - Added `SessionPublishOptions.quotePolicy` and + `AuthorizedMessageUpdateOptions.quotePolicy` options. [[#27]] + [[#28]] [[#31]] + - Added `AuthorizedMessage.unauthorizeQuote()` method for revoking an + existing quote authorization stamp by the quoted message or its URI. + [[#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 parameter, and data belonging to different identifiers are isolated from @@ -56,6 +80,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 +114,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 +137,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..7890c0b 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", ], ); @@ -211,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; @@ -1027,6 +1042,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..9bcdea1 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; /** @@ -128,11 +130,39 @@ 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)) { + throw new TypeError( + "The PostgreSQL client must support transactions.", + ); + } + await sql.begin(async (tx) => { + await initializePostgresRepositorySchemaInTransaction( + tx, + 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 +272,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 +313,7 @@ const upgradableTables = [ "follow_requests", "sent_follows", "followees", + "quote_authorizations", "poll_votes", ] as const; @@ -413,6 +457,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 +1022,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 +1284,11 @@ function validateSchemaName(schema: string): string { return schema; } +function hasTransaction(sql: Queryable): sql is TransactionalQueryable { + return sql != null && + typeof (sql as Partial).begin === "function"; +} + async function execute( sql: Queryable, query: string, @@ -1214,6 +1349,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..d27f651 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,89 @@ 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("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 1e9956e..ee7083f 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,73 @@ 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 stmt = this.db.prepare( + "DELETE FROM quote_authorizations WHERE bot_id = ? AND id = ? " + + "RETURNING authorization_json", + ); + const row = stmt.get(identifier, id) as + | { authorization_json: string } + | undefined; + return await parseQuoteAuthorizationJson(row?.authorization_json); + } + vote( identifier: string, messageId: Uuid, @@ -1018,3 +1121,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..70441e4 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, @@ -36,6 +37,8 @@ import { PropertyValue, PUBLIC_COLLECTION, Question, + QuoteAuthorization, + QuoteRequest, type Recipient, Reject, Service, @@ -47,10 +50,15 @@ 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"; +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"; @@ -2061,6 +2069,402 @@ test("BotImpl.onCreated()", async (t) => { ctx.forwardedRecipients = []; }); + 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", + ), + 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, []); + assert.deepStrictEqual(replied, []); + assert.deepStrictEqual(mentioned, []); + assert.deepStrictEqual(messaged, []); + assert.deepStrictEqual(ctx.sentActivities, []); + assert.deepStrictEqual(ctx.forwardedRecipients, []); + + quoted = []; + messaged = []; + 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, []); + 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, + 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 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", + ); + 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 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, []); + 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 = []; + }); + await t.test("on message", async () => { const create = new Create({ id: new URL( @@ -3290,3 +3694,1071 @@ 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() accepts FEP quote IDs", 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 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); + 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 () => { + 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("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, []); + assert.ok(!handled); + assert.deepStrictEqual( + await repository.findQuoteAuthorization("bot", quote.id!), + undefined, + ); +}); + +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("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() 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() 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({ + 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 directly.", + to: actor.id, + tags: [new Mention({ href: actor.id })], + }); + + 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() 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; + + 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({ + 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("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({ + 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..b59fe6c 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -47,8 +47,10 @@ import { PropertyValue, PUBLIC_COLLECTION, Question, + 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, @@ -91,8 +94,15 @@ 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"; import type { Like, Reaction } from "./reaction.ts"; import { ActorScopedRepository, @@ -137,6 +147,7 @@ export const botEventHandlerNames = [ "onMention", "onReply", "onQuote", + "onQuoteRequest", "onMessage", "onSharedMessage", "onLike", @@ -158,6 +169,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 +220,7 @@ export class BotImpl implements Bot { onMention?: MentionEventHandler; onReply?: ReplyEventHandler; onQuote?: QuoteEventHandler; + onQuoteRequest?: QuoteRequestEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; @@ -228,6 +241,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 +545,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,9 +684,388 @@ export class BotImpl implements Bot { } } + async onQuoteRequested( + ctx: InboxContext, + request: RawQuoteRequest, + ): Promise { + if (request.id == null || request.actorId == null) return; + const requestId = request.id; + const parsedObj = parseLocalUri( + ctx, + request.objectId, + this.legacyObjectUrisIdentifier, + ); + if ( + parsedObj?.type !== "object" || + !(messageClasses as readonly unknown[]).includes(parsedObj.class) || + 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) || targetObject.id == null) return; + const documentLoader = await ctx.getDocumentLoader(this); + const instrument = await request.getInstrument({ + contextLoader: ctx.contextLoader, + documentLoader, + suppressError: true, + }); + if (!isMessageObject(instrument) || instrument.id == null) return; + const quotedObjectId = instrument.quoteId ?? instrument.quoteUrl; + if (quotedObjectId?.href !== targetObject.id.href) 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 (!await this.#canActorSeeObject(ctx, actor.id, targetObject)) { + return; + } + const rejectRequest = async () => { + await session.context.sendActivity( + this, + actor, + new Reject({ + 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 + ? 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, + }); + if (quote.id == null || target.id == null) return; + const existingAuthorization = await this.repository.findQuoteAuthorization( + quote.id, + ); + if ( + existingAuthorization != null && + existingAuthorization.interactionTargetId?.href !== target.id.href + ) { + await rejectRequest(); + return; + } + if ( + await this.#isQuoteAudienceWider( + ctx, + quoteObject, + targetObject, + quote.visibility, + target.visibility, + ) + ) { + if (existingAuthorization != null) { + await target.unauthorizeQuote(quote); + } + await rejectRequest(); + return; + } + const quoteRequest = new QuoteRequestImpl( + session, + request, + actor, + 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); + if (await this.#matchesQuoteAcceptance(ctx, actor.id, policy.automatic)) { + await quoteRequest.accept(); + } else if ( + await this.#matchesQuoteAcceptance(ctx, actor.id, policy.manual) + ) { + if (existingAuthorization != null) { + await quoteRequest.accept(); + return; + } + } else { + await revokeAndRejectQuoteRequest(); + } + } else if ( + await this.#matchesQuoteApprovals(ctx, actor.id, rule.automaticApprovals) + ) { + await quoteRequest.accept(); + } else if ( + await this.#matchesQuoteApprovals(ctx, actor.id, rule.manualApprovals) + ) { + if (existingAuthorization != null) { + await quoteRequest.accept(); + return; + } + } else { + await revokeAndRejectQuoteRequest(); + } + 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 #isQuoteAudienceWider( + ctx: InboxContext, + quoteObject: Object, + targetObject: Object, + quoteVisibility: MessageVisibility, + targetVisibility: MessageVisibility, + ): Promise { + if (targetVisibility === "unknown") { + return quoteVisibility !== "direct" || + !await this.#isQuoteAudienceSubset( + ctx, + quoteObject, + targetObject, + ); + } + if (quoteVisibility === "unknown") { + return targetVisibility !== "public" && targetVisibility !== "unlisted"; + } + const ranks: Record = { + public: 4, + unlisted: 3, + followers: 2, + direct: 1, + unknown: 0, + }; + if (ranks[quoteVisibility] > ranks[targetVisibility]) return true; + return !await this.#isQuoteAudienceSubset( + ctx, + quoteObject, + targetObject, + ); + } + + async #isQuoteAudienceSubset( + ctx: InboxContext, + quoteObject: Object, + targetObject: Object, + ): Promise { + 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 targetIncludesFollowers = targetRecipients.has(followerCollection); + for (const recipient of [...quoteObject.toIds, ...quoteObject.ccIds]) { + if (targetRecipients.has(recipient.href)) continue; + if ( + targetIncludesFollowers && + await this.repository.hasFollower(recipient) + ) continue; + return false; + } + 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, + 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 #hasValidQuoteAuthorization( + ctx: InboxContext, + object: MessageClass, + 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, + ); + 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, + ) + ) { + 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; + } + 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 ( @@ -836,18 +1238,28 @@ export class BotImpl implements Bot { break; } } - if (quoteUrl == null) quoteUrl = object.quoteUrl; + const fepQuoteUrl = object.quoteId; + const requiresQuoteAuthorization = fepQuoteUrl != null; + quoteUrl = fepQuoteUrl ?? quoteUrl ?? object.quoteUrl; const quoteTarget = parseLocalUri( ctx, 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 + 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 ( @@ -1207,6 +1619,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 +1847,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 +1940,7 @@ export class BotGroupImpl implements BotGroup { onMention?: MentionEventHandler; onReply?: ReplyEventHandler; onQuote?: QuoteEventHandler; + onQuoteRequest?: QuoteRequestEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; @@ -1549,6 +2008,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.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 eef6ae0..1f2b364 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -43,8 +43,11 @@ import { Mention, Note, Question, + QuoteAuthorization, + QuoteRequest, Reject, Undo, + Update, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; import mimeDb from "mime-db"; @@ -296,6 +299,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,10 +318,12 @@ 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)) .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)); @@ -462,6 +475,7 @@ export class InstanceImpl image: profile.image, properties: profile.properties, followerPolicy: profile.followerPolicy, + quotePolicy: profile.quotePolicy, }); return wrapBotImpl(bot); } @@ -620,6 +634,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, @@ -705,6 +730,7 @@ export class InstanceImpl addLocalObject(tag.href); } } + addLocalObject(object.quoteId); addLocalObject(object.quoteUrl); // Bots whose message is replied to: addLocalObject(object.replyTargetId); @@ -714,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 88a0180..93f17d3 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -23,7 +23,9 @@ import { Note, Person, PUBLIC_COLLECTION, + QuoteAuthorization, Undo, + Update, } from "@fedify/vocab"; import assert from "node:assert"; import { describe, test } from "node:test"; @@ -343,6 +345,140 @@ 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 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}`, + ); + 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 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/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..0d149b9 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,87 @@ export class AuthorizedMessageImpl ); } } + + async unauthorizeQuote( + 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) { + 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 +743,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); @@ -710,11 +811,13 @@ 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.quoteUrl; let qt: Object | null = null; 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..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, @@ -74,6 +75,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..cfabb3a --- /dev/null +++ b/packages/botkit/src/quote-impl.ts @@ -0,0 +1,142 @@ +// 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."); + } else if (target.id == null) { + throw new TypeError("The target 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(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, + ); + 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(); + 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, + 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(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, + 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..3cfccb2 --- /dev/null +++ b/packages/botkit/src/quote.ts @@ -0,0 +1,170 @@ +// 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. + * @param signal An abort signal. + * @throws {TypeError} The quote request is not pending. + */ + accept(signal?: AbortSignal): Promise; + + /** + * Rejects the quote request. + * @param signal An abort signal. + * @throws {TypeError} The quote request is not pending. + */ + reject(signal?: AbortSignal): 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; +} + +/** + * 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, + 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..2760781 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,219 @@ 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, + ); +}); + +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); +}); + +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); + 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]; } @@ -105,6 +325,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 051cf08..dca9d04 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"; @@ -51,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 @@ -294,6 +309,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 +683,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 +838,29 @@ 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, + ); + } + + #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. @@ -764,6 +908,8 @@ export class KvRepository implements Repository { "followRequests", "followees", "follows", + "quoteAuthorizations", + "quoteAuthorizationsByInteractingObject", "polls", ] as const; for (const category of categories) { @@ -1440,6 +1586,108 @@ 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.", + ); + } + await this.#withKvLock( + this.#quoteAuthorizationLockKey(identifier, interactingObject), + async () => { + const existing = await this.kv.get( + this.#quoteAuthorizationIndexKey(identifier, interactingObject), + ); + 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" }), + ); + 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 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(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; + } + } + async #addToFolloweeIndex( identifier: string, followeeId: URL, @@ -1606,6 +1854,8 @@ interface MemoryActorData { followRequests: Record; sentFollows: Record; followees: Record; + quoteAuthorizations: Map; + quoteAuthorizationsByInteractingObject: Map; polls: Record>>; } @@ -1625,6 +1875,8 @@ export class MemoryRepository implements Repository { followRequests: {}, sentFollows: {}, followees: {}, + quoteAuthorizations: new Map(), + quoteAuthorizationsByInteractingObject: new Map(), polls: {}, }; this.#data.set(identifier, data); @@ -1847,6 +2099,72 @@ 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) { + const indexedId = data.quoteAuthorizationsByInteractingObject.get( + interactingObject.href, + ); + if (indexedId === id) { + data.quoteAuthorizationsByInteractingObject.delete( + interactingObject.href, + ); + } + } + return Promise.resolve(authorization); + } + vote( identifier: string, messageId: Uuid, @@ -2139,6 +2457,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),