diff --git a/CHANGES.md b/CHANGES.md index 0741f05..fd3e1d6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -47,8 +47,8 @@ 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]] + - Added support for consent-respecting quote posts using [FEP-044f]. + [[#27], [#28], [#29], [#31], [#32]] BotKit now serializes quote policies on outgoing messages, handles incoming `QuoteRequest` activities, automatically accepts or rejects them @@ -58,18 +58,29 @@ To be released. `Session.publish()` or `AuthorizedMessage.update()`, and moderate pending requests with the new `Bot.onQuoteRequest` event handler. + When publishing a quote, BotKit now sets the FEP-044f `quote` property, + sends a `QuoteRequest` to the quoted message's author, applies accepted + `QuoteAuthorization` stamps to the stored message, and strips rejected + quote targets from the stored message before delivering an `Update`. + - Added `QuotePolicy`, `QuotePolicyOption`, `QuoteRequest`, and - `QuoteRequestEventHandler` types. [[#27]] [[#28]] [[#31]] - - Added `Bot.onQuoteRequest` event handler. [[#27]] [[#28]] [[#31]] + `QuoteRequestEventHandler` types. [[#27], [#28], [#31]] + - Added `Bot.onQuoteRequest` event handler. [[#27], [#28], [#31]] + - Added `QuoteAcceptedEventHandler` and `QuoteRejectedEventHandler` + types. [[#27], [#29], [#32]] + - Added `Bot.onQuoteAccepted` and `Bot.onQuoteRejected` event handlers. + [[#27], [#29], [#32]] - Added `ReadonlyBot.quotePolicy`, `CreateBotOptions.quotePolicy`, and - `BotProfile.quotePolicy` properties. [[#27]] [[#28]] [[#31]] + `BotProfile.quotePolicy` properties. [[#27], [#28], [#31]] - Added `SessionPublishOptions.quotePolicy` and - `AuthorizedMessageUpdateOptions.quotePolicy` options. [[#27]] - [[#28]] [[#31]] + `AuthorizedMessageUpdateOptions.quotePolicy` options. + [[#27], [#28], [#31]] + - Added `Message.quotePolicy` and `AuthorizedMessage.quoteApprovalState` + properties. [[#27], [#29], [#32]] - 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]] + [[#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 @@ -85,6 +96,11 @@ To be released. `Repository.getQuoteAuthorization()`, `Repository.findQuoteAuthorization()`, and `Repository.removeQuoteAuthorization()`. + - Added quote authorization reference methods: + `Repository.addQuoteAuthorizationReference()`, + `Repository.findQuoteAuthorizationReference()`, and + `Repository.removeQuoteAuthorizationReference()`. + [[#27], [#29], [#32]] - Added optional `Repository.migrate()` method for adopting data stored by BotKit 0.4 or earlier. - Added `Repository.forIdentifier()` method and `ActorScopedRepository` @@ -119,12 +135,18 @@ To be released. [#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 +[#29]: https://github.com/fedify-dev/botkit/issues/29 [#31]: https://github.com/fedify-dev/botkit/pull/31 +[#32]: https://github.com/fedify-dev/botkit/pull/32 ### @fedify/botkit-sqlite - Added a `quote_authorizations` table for [FEP-044f] quote authorization - stamps. [[#27]] [[#28]] [[#31]] + stamps. [[#27], [#28], [#31]] + + - Added a `quote_authorization_refs` table for received [FEP-044f] quote + authorization stamps that have been applied to local quote posts. + [[#27], [#29], [#32]] - 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 @@ -138,7 +160,11 @@ To be released. ### @fedify/botkit-postgres - Added a `quote_authorizations` table for [FEP-044f] quote authorization - stamps. [[#27]] [[#28]] [[#31]] + stamps. [[#27], [#28], [#31]] + + - Added a `quote_authorization_refs` table for received [FEP-044f] quote + authorization stamps that have been applied to local quote posts. + [[#27], [#29], [#32]] - All tables now have a `bot_id` column and composite primary keys, so a single schema stores the data of multiple bots. Initializing a schema diff --git a/deno.lock b/deno.lock index 59444f5..630e96c 100644 --- a/deno.lock +++ b/deno.lock @@ -10,6 +10,7 @@ "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/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", "jsr:@hono/hono@^4.12.27": "4.12.27", diff --git a/docs/concepts/events.md b/docs/concepts/events.md index c7c8215..0034ac9 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -313,6 +313,45 @@ the *Message* concept document. [FEP-044f]: https://w3id.org/fep/044f +Quote accepted and rejected +--------------------------- + +*This API is available since BotKit 0.5.0.* + +The `~Bot.onQuoteAccepted` and `~Bot.onQuoteRejected` event handlers are called +when a remote author responds to a quote request that your bot sent for one of +its own quote posts. + +When the request is accepted, BotKit verifies the returned +`QuoteAuthorization` stamp, applies it to the stored message, sends an `Update` +activity, and then calls `~Bot.onQuoteAccepted`: + +~~~~ typescript twoslash +import { type Bot } from "@fedify/botkit"; +const bot = {} as unknown as Bot; +// ---cut-before--- +bot.onQuoteAccepted = (_session, message, approver) => { + console.log(`${approver.id} approved ${message.id}`); + console.log(message.quoteApprovalState); // "accepted" +}; +~~~~ + +When the request is rejected, or when a previously accepted +`QuoteAuthorization` stamp is later deleted by the quoted author, BotKit +removes the quote target and fallback quote link from the stored message, +sends an `Update` activity, and then calls `~Bot.onQuoteRejected`: + +~~~~ typescript twoslash +import { type Bot } from "@fedify/botkit"; +const bot = {} as unknown as Bot; +// ---cut-before--- +bot.onQuoteRejected = (_session, message, rejecter) => { + console.log(`${rejecter.id} rejected ${message.id}`); + console.log(message.quoteTarget); // undefined +}; +~~~~ + + Message ------- diff --git a/docs/concepts/message.md b/docs/concepts/message.md index 72da1a4..03463c4 100644 --- a/docs/concepts/message.md +++ b/docs/concepts/message.md @@ -324,6 +324,28 @@ bot.onMention = async (session, message) => { > while others like Mastodon might implement quotes differently or not support > them at all. +When the quoted message supports [FEP-044f], BotKit also sends a quote request +to the quoted message's author. Until that author accepts the request, +the returned `AuthorizedMessage` reports a pending approval state: + +~~~~ typescript twoslash +import { type Message, type MessageClass, type Session, text } from "@fedify/botkit"; +declare const session: Session; +declare const quoted: Message; +// ---cut-before--- +const message = await session.publish(text`This message quotes another one.`, { + quoteTarget: quoted, +}); + +console.log(message.quoteApprovalState); // "pending" +~~~~ + +If the author accepts the quote request, BotKit stores the received +authorization stamp on the message and sends an `Update` activity. If the +author rejects it, BotKit removes the quote target and the fallback quote link +from the stored message and sends an `Update` activity with the stripped +content. + ### Polls *This API is available since BotKit 0.3.0.* @@ -597,6 +619,12 @@ You can get the message that is quoted in the message through the `~Message.quoteTarget` property. It is either another `Message` object or `undefined` if the message is not a quote. +For authorized messages created by your bot, +`~AuthorizedMessage.quoteApprovalState` describes whether the quote target +still awaits FEP-044f approval. It is `"pending"` for remote quote targets +that have not sent an authorization stamp, `"accepted"` after a valid stamp has +been received, and `"notRequired"` for self-quotes. + Since the quoted message itself can be a quote, you can traverse the conversation by following the `~Message.quoteTarget` property recursively: diff --git a/docs/concepts/repository.md b/docs/concepts/repository.md index 1abf080..6a73d87 100644 --- a/docs/concepts/repository.md +++ b/docs/concepts/repository.md @@ -34,6 +34,14 @@ incoming messages to the right bots), and the optional for a bot actor identifier. The built-in repositories migrate legacy data automatically when the bot is created through `createBot()`. +[FEP-044f] quote support also adds quote authorization storage methods and quote +authorization reference methods. The reference methods map a received +`QuoteAuthorization` stamp URI back to the local quote message that depends +on it, so BotKit can update that message when the remote author later changes +the quote's authorization state. + +[FEP-044f]: https://w3id.org/fep/044f + `KvRepository` -------------- diff --git a/packages/botkit-postgres/src/mod.test.ts b/packages/botkit-postgres/src/mod.test.ts index 7890c0b..2d64fa4 100644 --- a/packages/botkit-postgres/src/mod.test.ts +++ b/packages/botkit-postgres/src/mod.test.ts @@ -136,6 +136,7 @@ if (postgresUrl == null) { "key_pairs", "messages", "poll_votes", + "quote_authorization_refs", "quote_authorizations", "sent_follows", ], @@ -1083,6 +1084,51 @@ if (postgresUrl == null) { await harness.cleanup(); } }); + + test("stores quote authorization references", async () => { + const harness = createHarness(); + try { + const repo = harness.repository; + const authorization = new URL("https://remote.example/stamps/1"); + const firstMessageId = "01942976-3400-7f34-872e-2cbf0f9eeac4"; + const secondMessageId = "01942976-3400-7f34-872e-2cbf0f9eeac5"; + + await repo.addQuoteAuthorizationReference( + "bot", + authorization, + firstMessageId, + ); + + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("other", authorization), + undefined, + ); + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("bot", authorization), + firstMessageId, + ); + + await repo.addQuoteAuthorizationReference( + "bot", + authorization, + secondMessageId, + ); + + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("bot", authorization), + secondMessageId, + ); + + await repo.removeQuoteAuthorizationReference("bot", authorization); + + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("bot", authorization), + undefined, + ); + } finally { + await harness.cleanup(); + } + }); }); } diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index 9bcdea1..2914bfb 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -285,6 +285,25 @@ async function initializePostgresRepositorySchemaInTransaction( [], prepare, ); + await execute( + sql, + `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."quote_authorization_refs" ( + bot_id TEXT NOT NULL, + authorization_uri TEXT NOT NULL, + message_id TEXT NOT NULL, + attribution_uri TEXT, + PRIMARY KEY (bot_id, authorization_uri) + )`, + [], + prepare, + ); + await execute( + sql, + `ALTER TABLE "${validatedSchema}"."quote_authorization_refs" + ADD COLUMN IF NOT EXISTS attribution_uri TEXT`, + [], + prepare, + ); await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."poll_votes" ( @@ -1094,6 +1113,74 @@ export class PostgresRepository implements Repository, AsyncDisposable { return await parseQuoteAuthorization(rows[0]?.authorization_json); } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + await this.ensureReady(); + await this.query( + this.sql, + `INSERT INTO ${this.table("quote_authorization_refs")} + (bot_id, authorization_uri, message_id, attribution_uri) + VALUES ($1, $2, $3, $4) + ON CONFLICT (bot_id, authorization_uri) DO UPDATE + SET message_id = EXCLUDED.message_id, + attribution_uri = EXCLUDED.attribution_uri`, + [identifier, authorization.href, messageId, attribution?.href ?? null], + ); + } + + async findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.ensureReady(); + const rows = await this.query<{ readonly message_id: Uuid }>( + this.sql, + `SELECT message_id + FROM ${this.table("quote_authorization_refs")} + WHERE bot_id = $1 AND authorization_uri = $2`, + [identifier, authorization.href], + ); + return rows[0]?.message_id; + } + + async findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + await this.ensureReady(); + const rows = await this.query<{ readonly attribution_uri: string | null }>( + this.sql, + `SELECT attribution_uri + FROM ${this.table("quote_authorization_refs")} + WHERE bot_id = $1 AND authorization_uri = $2`, + [identifier, authorization.href], + ); + const attribution = rows[0]?.attribution_uri; + if (attribution == null) return undefined; + try { + return new URL(attribution); + } catch { + return undefined; + } + } + + async removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.ensureReady(); + await this.query( + this.sql, + `DELETE FROM ${this.table("quote_authorization_refs")} + WHERE bot_id = $1 AND authorization_uri = $2`, + [identifier, authorization.href], + ); + } + async vote( identifier: string, messageId: Uuid, diff --git a/packages/botkit-sqlite/src/mod.test.ts b/packages/botkit-sqlite/src/mod.test.ts index d27f651..038ee44 100644 --- a/packages/botkit-sqlite/src/mod.test.ts +++ b/packages/botkit-sqlite/src/mod.test.ts @@ -370,6 +370,50 @@ describe("SqliteRepository", () => { } }); + test("stores quote authorization references", async () => { + const repo = createSqliteRepository(); + try { + const authorization = new URL("https://remote.example/stamps/1"); + const firstMessageId = "01942976-3400-7f34-872e-2cbf0f9eeac4"; + const secondMessageId = "01942976-3400-7f34-872e-2cbf0f9eeac5"; + + await repo.addQuoteAuthorizationReference( + "bot", + authorization, + firstMessageId, + ); + + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("other", authorization), + undefined, + ); + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("bot", authorization), + firstMessageId, + ); + + await repo.addQuoteAuthorizationReference( + "bot", + authorization, + secondMessageId, + ); + + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("bot", authorization), + secondMessageId, + ); + + await repo.removeQuoteAuthorizationReference("bot", authorization); + + assert.deepStrictEqual( + await repo.findQuoteAuthorizationReference("bot", authorization), + 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`; diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index ee7083f..e723b20 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -109,6 +109,15 @@ export class SqliteRepository implements Repository, Disposable { return row.count > 0; } + private hasColumn(table: string, column: string): boolean { + const stmt = this.db.prepare(` + SELECT COUNT(*) AS count FROM pragma_table_info(?) + WHERE name = ? + `); + const row = stmt.get(table, column) as { count: number }; + return row.count > 0; + } + /** * Rebuilds tables created by \@fedify/botkit-sqlite 0.4 or earlier, which * had no `bot_id` column, into the bot-scoped schema. Existing rows get @@ -439,6 +448,21 @@ export class SqliteRepository implements Repository, Disposable { ) `); + this.db.exec(` + CREATE TABLE IF NOT EXISTS quote_authorization_refs ( + bot_id TEXT NOT NULL, + authorization TEXT NOT NULL, + message_id TEXT NOT NULL, + attribution TEXT, + PRIMARY KEY (bot_id, authorization) + ) + `); + if (!this.hasColumn("quote_authorization_refs", "attribution")) { + this.db.exec(` + ALTER TABLE quote_authorization_refs ADD COLUMN attribution TEXT + `); + } + // Poll votes table this.db.exec(` CREATE TABLE IF NOT EXISTS poll_votes ( @@ -1069,6 +1093,71 @@ export class SqliteRepository implements Repository, Disposable { return await parseQuoteAuthorizationJson(row?.authorization_json); } + addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO quote_authorization_refs + (bot_id, authorization, message_id, attribution) + VALUES (?, ?, ?, ?) + `); + stmt.run( + identifier, + authorization.href, + messageId, + attribution?.href ?? null, + ); + return Promise.resolve(); + } + + findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + const stmt = this.db.prepare(` + SELECT message_id FROM quote_authorization_refs + WHERE bot_id = ? AND authorization = ? + `); + const row = stmt.get(identifier, authorization.href) as + | { message_id: Uuid } + | undefined; + return Promise.resolve(row?.message_id); + } + + findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + const stmt = this.db.prepare(` + SELECT attribution FROM quote_authorization_refs + WHERE bot_id = ? AND authorization = ? + `); + const row = stmt.get(identifier, authorization.href) as + | { attribution: string | null } + | undefined; + if (row?.attribution == null) return Promise.resolve(undefined); + try { + return Promise.resolve(new URL(row.attribution)); + } catch { + return Promise.resolve(undefined); + } + } + + removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + const stmt = this.db.prepare(` + DELETE FROM quote_authorization_refs + WHERE bot_id = ? AND authorization = ? + `); + stmt.run(identifier, authorization.href); + return Promise.resolve(); + } + vote( identifier: string, messageId: Uuid, diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 70441e4..4de21ee 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -50,6 +50,7 @@ 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 { createMessage, isQuoteLink } from "./message-impl.ts"; import type { AuthorizedMessage, Message, @@ -3604,6 +3605,1320 @@ test("BotImpl.fetch() redirects legacy object URIs", async (t) => { }); }); +test("BotImpl.onFollowAccepted() accepts quote approvals", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const targetUrl = new URL("https://remote.example/@alice/notes/original"); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + url: targetUrl, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + let accepted: AuthorizedMessage | undefined; + let approver: Actor | undefined; + bot.onQuoteAccepted = (_session, message, actor) => { + accepted = message; + approver = actor; + }; + ctx.sentActivities = []; + + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + messageId, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.ok(ctx.sentActivities[0].activity instanceof Update); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [author]); + assert.ok(ctx.sentActivities[1].activity instanceof Update); + assert.deepStrictEqual(accepted?.id, quote.id); + assert.deepStrictEqual(accepted?.quoteApprovalState, "accepted"); + assert.deepStrictEqual(approver?.id, author.id); +}); + +test("BotImpl.onFollowAccepted() validates quote approvals with signed fetch", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/followers-only"), + attribution: author, + content: "Followers only.", + to: author.followersId, + }); + const defaultDocumentLoader = ctx.documentLoader; + const signedDocumentLoader: Awaited< + ReturnType + > = (url, options) => defaultDocumentLoader(url, options); + Object.defineProperty(ctx, "getDocumentLoader", { + value: () => Promise.resolve(signedDocumentLoader), + }); + let usedSignedFetch = false; + Object.defineProperty(ctx, "lookupObject", { + value: ( + id: URL, + options?: Parameters[1], + ) => { + if ( + id.href === target.id?.href && + options?.documentLoader === signedDocumentLoader + ) { + usedSignedFetch = true; + return Promise.resolve(target); + } + return Promise.resolve(null); + }, + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.ok(usedSignedFetch); + assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); +}); + +test("BotImpl.onFollowAccepted() sends quote updates to reply targets", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const replyAuthor = new Person({ + id: new URL("https://reply.example/users/bob"), + preferredUsername: "bob", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + const replyTarget = new Note({ + id: new URL("https://reply.example/notes/thread"), + attribution: replyAuthor, + content: "Thread starter.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve( + id.href === target.id?.href + ? target + : id.href === replyTarget.id?.href + ? replyTarget + : null, + ), + }); + const targetMessage = await createMessage(target, session, {}); + const replyMessage = await createMessage(replyTarget, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + replyTarget: replyMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + ctx.sentActivities = []; + + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 3); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.ok(ctx.sentActivities[0].activity instanceof Update); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [replyAuthor]); + assert.ok(ctx.sentActivities[1].activity instanceof Update); + assert.deepStrictEqual(ctx.sentActivities[2].recipients, [author]); + assert.ok(ctx.sentActivities[2].activity instanceof Update); +}); + +test("BotImpl.onFollowAccepted() deduplicates quote update recipients", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + const replyTarget = new Note({ + id: new URL("https://remote.example/notes/thread"), + attribution: author, + content: "Thread starter.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve( + id.href === target.id?.href + ? target + : id.href === replyTarget.id?.href + ? replyTarget + : id.href === author.id?.href + ? author + : null, + ), + }); + const targetMessage = await createMessage(target, session, { + [author.id!.href]: author, + }); + const replyMessage = await createMessage(replyTarget, session, { + [author.id!.href]: author, + }); + const quote = await session.publish(text`Please approve this, ${author}.`, { + quoteTarget: targetMessage, + replyTarget: replyMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + ctx.sentActivities = []; + + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + + assert.deepStrictEqual(ctx.sentActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.ok(ctx.sentActivities[0].activity instanceof Update); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [author]); + assert.ok(ctx.sentActivities[1].activity instanceof Update); +}); + +test("BotImpl.onFollowAccepted() preserves concurrent quote updates", async () => { + class ConcurrentUpdateRepository extends MemoryRepository { + override async updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => + | Create + | Announce + | undefined + | Promise, + ): Promise { + if (identifier === "bot") { + const existing = await this.getMessage(identifier, id); + if (existing instanceof Create) { + const object = await existing.getObject(); + if (object instanceof Note) { + await super.updateMessage( + identifier, + id, + (current) => + current instanceof Create + ? current.clone({ + object: object.clone({ + content: "Edited while approval was in flight.", + }), + }) + : current, + ); + } + } + } + return await super.updateMessage(identifier, id, updater); + } + } + const repository = new ConcurrentUpdateRepository(); + 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual( + object.content, + "Edited while approval was in flight.", + ); + assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); +}); + +test("BotImpl.onFollowAccepted() cleans references after update failures", async () => { + class FailingUpdateRepository extends MemoryRepository { + override updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => + | Create + | Announce + | undefined + | Promise, + ): Promise { + if (identifier === "bot") { + return Promise.reject(new TypeError("Message update failed.")); + } + return super.updateMessage(identifier, id, updater); + } + } + const repository = new FailingUpdateRepository(); + 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + ctx.sentActivities = []; + + await assert.rejects( + () => + bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ), + TypeError, + "Message update failed.", + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + undefined, + ); + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.deepStrictEqual(ctx.sentActivities.length, 0); +}); + +test("BotImpl.onFollowAccepted() cleans references after missing updates", async () => { + class MissingUpdateRepository extends MemoryRepository { + override updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => + | Create + | Announce + | undefined + | Promise, + ): Promise { + if (identifier === "bot") return Promise.resolve(false); + return super.updateMessage(identifier, id, updater); + } + } + const repository = new MissingUpdateRepository(); + 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + let accepted: AuthorizedMessage | undefined; + bot.onQuoteAccepted = (_session, message) => { + accepted = message; + }; + ctx.sentActivities = []; + + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + undefined, + ); + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.deepStrictEqual(ctx.sentActivities.length, 0); + assert.deepStrictEqual(accepted, undefined); +}); + +test("BotImpl.onFollowRejected() strips rejected quotes", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const targetUrl = new URL("https://remote.example/@alice/notes/original"); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + url: targetUrl, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + let rejected: AuthorizedMessage | undefined; + let rejecter: Actor | undefined; + bot.onQuoteRejected = (_session, message, actor) => { + rejected = message; + rejecter = actor; + }; + ctx.sentActivities = []; + + await bot.onFollowRejected( + ctx, + new Reject({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteId, null); + assert.deepStrictEqual(object.quoteUrl, null); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.ok(!object.content?.toString().includes("quote-inline")); + assert.ok(!object.content?.toString().includes(targetUrl.href)); + const tags = await Array.fromAsync(object.getTags(ctx)); + assert.ok(!tags.some((tag) => tag instanceof Link && isQuoteLink(tag))); + assert.deepStrictEqual(ctx.sentActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.ok(ctx.sentActivities[0].activity instanceof Update); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [author]); + assert.ok(ctx.sentActivities[1].activity instanceof Update); + assert.deepStrictEqual(rejected?.id, quote.id); + assert.deepStrictEqual(rejected?.quoteTarget, undefined); + assert.deepStrictEqual(rejecter?.id, author.id); +}); + +test("BotImpl.onFollowRejected() preserves concurrent quote updates", async () => { + class ConcurrentUpdateRepository extends MemoryRepository { + override async updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => + | Create + | Announce + | undefined + | Promise, + ): Promise { + if (identifier === "bot") { + const existing = await this.getMessage(identifier, id); + if (existing instanceof Create) { + const object = await existing.getObject(); + if (object instanceof Note) { + await super.updateMessage( + identifier, + id, + (current) => + current instanceof Create + ? current.clone({ + object: object.clone({ + content: "Edited while rejection was in flight.", + }), + }) + : current, + ); + } + } + } + return await super.updateMessage(identifier, id, updater); + } + } + const repository = new ConcurrentUpdateRepository(); + 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + + await bot.onFollowRejected( + ctx, + new Reject({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual( + object.content, + "Edited while rejection was in flight.", + ); + assert.deepStrictEqual(object.quoteId, null); + assert.deepStrictEqual(object.quoteUrl, null); +}); + +test("BotImpl.onDeleted() strips revoked quote authorizations", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const targetUrl = new URL("https://remote.example/@alice/notes/original"); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + url: targetUrl, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + messageId, + ); + let rejected: AuthorizedMessage | undefined; + let rejecter: Actor | undefined; + bot.onQuoteRejected = (_session, message, actor) => { + rejected = message; + rejecter = actor; + }; + ctx.sentActivities = []; + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization.id, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteId, null); + assert.deepStrictEqual(object.quoteUrl, null); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.ok(!object.content?.toString().includes("quote-inline")); + assert.ok(!object.content?.toString().includes(targetUrl.href)); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + undefined, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.ok(ctx.sentActivities[0].activity instanceof Update); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [author]); + assert.ok(ctx.sentActivities[1].activity instanceof Update); + assert.deepStrictEqual(rejected?.id, quote.id); + assert.deepStrictEqual(rejected?.quoteTarget, undefined); + assert.deepStrictEqual(rejecter?.id, author.id); +}); + +test("BotImpl.onDeleted() ignores quote revocations from other actors", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const otherActor = new Person({ + id: new URL("https://remote.example/users/bob"), + preferredUsername: "bob", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + ctx.sentActivities = []; + + await bot.onDeleted( + ctx, + new Delete({ + actor: otherActor, + object: authorization.id, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + messageId, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 0); +}); + +test("BotImpl.onDeleted() strips revoked quotes with missing targets", 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const targetUrl = new URL("https://remote.example/@alice/notes/original"); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + url: targetUrl, + }); + let targetVisible = true; + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve( + targetVisible && id.href === target.id?.href ? target : null, + ), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + targetVisible = false; + ctx.sentActivities = []; + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization.id, + }), + ); + + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteId, null); + assert.deepStrictEqual(object.quoteUrl, null); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.ok(!object.content?.toString().includes(targetUrl.href)); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + undefined, + ); + assert.deepStrictEqual(ctx.sentActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities[0].recipients, "followers"); + assert.ok(ctx.sentActivities[0].activity instanceof Update); + assert.deepStrictEqual(ctx.sentActivities[1].recipients, [author]); + assert.ok(ctx.sentActivities[1].activity instanceof Update); +}); + +test("BotImpl.onDeleted() keeps references after update failures", async () => { + class FailingUpdateRepository extends MemoryRepository { + #updateCount = 0; + + override updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => + | Create + | Announce + | undefined + | Promise, + ): Promise { + this.#updateCount++; + if (identifier === "bot" && this.#updateCount > 1) { + return Promise.reject(new TypeError("Message update failed.")); + } + return super.updateMessage(identifier, id, updater); + } + } + const repository = new FailingUpdateRepository(); + 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + ctx.sentActivities = []; + + await assert.rejects( + () => + bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization.id, + }), + ), + TypeError, + "Message update failed.", + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + messageId, + ); + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); + assert.deepStrictEqual(ctx.sentActivities.length, 0); +}); + +test("BotImpl.onDeleted() removes references after missing updates", async () => { + class MissingUpdateRepository extends MemoryRepository { + #updateCount = 0; + + override updateMessage( + identifier: string, + id: Uuid, + updater: ( + existing: Create | Announce, + ) => + | Create + | Announce + | undefined + | Promise, + ): Promise { + this.#updateCount++; + if (identifier === "bot" && this.#updateCount > 1) { + return Promise.resolve(false); + } + return super.updateMessage(identifier, id, updater); + } + } + const repository = new MissingUpdateRepository(); + 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 author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + }); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const messageId = parsed.values.id as Uuid; + const authorization = new QuoteAuthorization({ + id: new URL("https://remote.example/stamps/1"), + attribution: author.id, + interactingObject: quote.id, + interactionTarget: target.id, + }); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: author, + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: messageId, + }), + result: authorization, + }), + ); + let rejected: AuthorizedMessage | undefined; + bot.onQuoteRejected = (_session, message) => { + rejected = message; + }; + ctx.sentActivities = []; + + await bot.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization.id, + }), + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference( + "bot", + authorization.id!, + ), + undefined, + ); + const stored = await repository.getMessage("bot", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteAuthorizationId, authorization.id); + assert.deepStrictEqual(ctx.sentActivities.length, 0); + assert.deepStrictEqual(rejected, undefined); +}); + +test("BotImpl ignores malformed quote request IDs", async (t) => { + class ThrowingMessageRepository extends MemoryRepository { + override getMessage( + identifier: string, + id: Uuid, + ): Promise { + if (identifier === "bot" && id === ("not-a-uuid" as Uuid)) { + return Promise.reject(new TypeError("Malformed UUID reached storage.")); + } + return super.getMessage(identifier, id); + } + } + + await t.test("dispatchQuoteRequest()", async () => { + const repository = new ThrowingMessageRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = bot.federation.createContext( + new Request("https://example.com/"), + undefined, + ); + + assert.deepStrictEqual( + await bot.dispatchQuoteRequest(ctx, { + identifier: "bot", + id: "not-a-uuid", + }), + null, + ); + }); + + await t.test("onFollowAccepted()", async () => { + const repository = new ThrowingMessageRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + await bot.onFollowAccepted( + ctx, + new Accept({ + actor: new URL("https://remote.example/users/alice"), + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: "not-a-uuid", + }), + }), + ); + }); + + await t.test("onFollowRejected()", async () => { + const repository = new ThrowingMessageRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + await bot.onFollowRejected( + ctx, + new Reject({ + actor: new URL("https://remote.example/users/alice"), + object: ctx.getObjectUri(QuoteRequest, { + identifier: bot.identifier, + id: "not-a-uuid", + }), + }), + ); + }); +}); + +test("BotImpl.dispatchQuoteRequest() checks message visibility", async () => { + const repository = new MemoryRepository(); + const bot = new BotImpl({ + kv: new MemoryKvStore(), + repository, + username: "bot", + }); + const sessionCtx = createMockInboxContext(bot, "https://example.com", "bot"); + const session = new SessionImpl(bot, sessionCtx); + const author = new Person({ + id: new URL("https://remote.example/users/alice"), + preferredUsername: "alice", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(sessionCtx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + }); + const targetMessage = await createMessage(target, session, {}); + const quote = await session.publish(text`Please approve this.`, { + quoteTarget: targetMessage, + visibility: "followers", + }); + const parsed = sessionCtx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const requestCtx = bot.federation.createContext( + new Request("https://example.com/"), + undefined, + ); + + assert.deepStrictEqual( + await bot.dispatchQuoteRequest(requestCtx, { + identifier: "bot", + id: parsed.values.id, + }), + null, + ); +}); + test("BotImpl.onFollowAccepted() with canonical follow URIs", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index b59fe6c..593e16d 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -33,6 +33,7 @@ import { Article, ChatMessage, Create, + type Delete, Emoji as APEmoji, EmojiReact, Endpoints, @@ -48,6 +49,7 @@ import { PUBLIC_COLLECTION, Question, QuoteAuthorization, + QuoteRequest, type QuoteRequest as RawQuoteRequest, type Recipient, Reject, @@ -55,6 +57,7 @@ import { type Undo, Update, } from "@fedify/vocab"; +import { getLogger } from "@logtape/logtape"; import type { Bot, CreateBotOptions, PagesOptions } from "./bot.ts"; import type { BotDispatcher, @@ -74,7 +77,9 @@ import type { LikeEventHandler, MentionEventHandler, MessageEventHandler, + QuoteAcceptedEventHandler, QuoteEventHandler, + QuoteRejectedEventHandler, QuoteRequestEventHandler, ReactionEventHandler, RejectEventHandler, @@ -89,12 +94,14 @@ import { FollowRequestImpl } from "./follow-impl.ts"; import { InstanceImpl } from "./instance-impl.ts"; import { createMessage, + getMessageClass, getMessageVisibility, isMessageObject, isQuoteLink, messageClasses, } from "./message-impl.ts"; import type { + AuthorizedMessage, Message, MessageClass, MessageVisibility, @@ -117,6 +124,14 @@ import { SessionImpl } from "./session-impl.ts"; import type { Session } from "./session.ts"; import type { Text } from "./text.ts"; +const logger = getLogger(["botkit", "bot"]); +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function isUuid(value: string): value is Uuid { + return uuidPattern.test(value); +} + export interface BotImplOptions extends CreateBotOptions { collectionWindow?: number; @@ -148,6 +163,8 @@ export const botEventHandlerNames = [ "onReply", "onQuote", "onQuoteRequest", + "onQuoteAccepted", + "onQuoteRejected", "onMessage", "onSharedMessage", "onLike", @@ -221,6 +238,8 @@ export class BotImpl implements Bot { onReply?: ReplyEventHandler; onQuote?: QuoteEventHandler; onQuoteRequest?: QuoteRequestEventHandler; + onQuoteAccepted?: QuoteAcceptedEventHandler; + onQuoteRejected?: QuoteRejectedEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; @@ -554,6 +573,33 @@ export class BotImpl implements Bot { null; } + async dispatchQuoteRequest( + ctx: RequestContext, + values: { identifier: string; id: string }, + ): Promise { + if (values.identifier !== this.identifier) return null; + if (!isUuid(values.id)) return null; + const stored = await this.repository.getMessage(values.id as Uuid); + if (!(stored instanceof Create)) return null; + const isVisible = await this.getPermissionChecker(ctx); + if (!isVisible(stored)) return null; + const object = await stored.getObject(ctx); + if ( + !isMessageObject(object) || object.id == null || object.quoteId == null + ) { + return null; + } + return new QuoteRequest({ + id: ctx.getObjectUri(QuoteRequest, { + identifier: this.identifier, + id: values.id, + }), + actor: ctx.getActorUri(this.identifier), + object: object.quoteId, + instrument: object.id, + }); + } + dispatchEmoji( ctx: Context, values: { name: string }, @@ -628,6 +674,14 @@ export class BotImpl implements Bot { accept.objectId, this.legacyObjectUrisIdentifier, ); + if ( + parsedObj?.type === "object" && parsedObj.class === QuoteRequest && + parsedObj.values.identifier === this.identifier && + isUuid(parsedObj.values.id) + ) { + await this.#onQuoteAccepted(ctx, accept, parsedObj.values.id); + return; + } if ( parsedObj?.type !== "object" || parsedObj.class !== Follow || parsedObj.values.identifier !== this.identifier @@ -661,6 +715,14 @@ export class BotImpl implements Bot { reject.objectId, this.legacyObjectUrisIdentifier, ); + if ( + parsedObj?.type === "object" && parsedObj.class === QuoteRequest && + parsedObj.values.identifier === this.identifier && + isUuid(parsedObj.values.id) + ) { + await this.#onQuoteRejected(ctx, reject, parsedObj.values.id); + return; + } if ( parsedObj?.type !== "object" || parsedObj.class !== Follow || parsedObj.values.identifier !== this.identifier @@ -684,6 +746,355 @@ export class BotImpl implements Bot { } } + async #onQuoteAccepted( + ctx: InboxContext, + accept: Accept, + id: Uuid, + ): Promise { + const stored = await this.repository.getMessage(id); + if (!(stored instanceof Create)) return; + const object = await stored.getObject(ctx); + if ( + !isMessageObject(object) || object.id == null || object.quoteId == null + ) { + return; + } + const quoteId = object.quoteId; + const approval = await this.#validateQuoteApproval(ctx, accept, object); + if (approval == null) return; + await this.repository.addQuoteAuthorizationReference( + approval.authorization.id!, + id, + approval.actor.id!, + ); + let updatedObject: MessageClass | undefined; + const removeReference = async () => { + try { + await this.repository.removeQuoteAuthorizationReference( + approval.authorization.id!, + ); + } catch (cleanupError) { + logger.warn( + "Failed to remove quote authorization reference after message update failure: {error}", + { error: cleanupError }, + ); + } + }; + try { + const wasUpdated = await this.repository.updateMessage( + id, + async (existing) => { + if (!(existing instanceof Create)) return; + const existingObject = await existing.getObject(ctx); + if ( + !isMessageObject(existingObject) || existingObject.id == null || + existingObject.quoteId?.href !== quoteId.href + ) { + return; + } + updatedObject = existingObject.clone({ + quoteAuthorization: approval.authorization.id, + updated: Temporal.Now.instant(), + }); + return existing.clone({ object: updatedObject }); + }, + ); + if (!wasUpdated || updatedObject == null) { + await removeReference(); + return; + } + } catch (error) { + await removeReference(); + throw error; + } + await this.#sendQuoteUpdate(ctx, updatedObject, approval.actor); + if (this.onQuoteAccepted != null) { + const session = this.getSession(ctx); + const message = await createMessage( + updatedObject, + session, + { [approval.actor.id!.href]: approval.actor }, + undefined, + undefined, + true, + ) as AuthorizedMessage; + await this.onQuoteAccepted(session, message, approval.actor); + } + } + + async #onQuoteRejected( + ctx: InboxContext, + reject: Reject, + id: Uuid, + ): Promise { + const stored = await this.repository.getMessage(id); + if (!(stored instanceof Create)) return; + const object = await stored.getObject(ctx); + if ( + !isMessageObject(object) || object.id == null || object.quoteId == null + ) { + return; + } + const rejecter = await this.#validateQuoteRejection(ctx, reject, object); + if (rejecter == null) return; + await this.#stripRejectedQuote(ctx, id, object, rejecter); + } + + async onDeleted( + ctx: InboxContext, + del: Delete, + ): Promise { + if (del.objectId == null) return; + const id = await this.repository.findQuoteAuthorizationReference( + del.objectId, + ); + if (id == null) return; + const stored = await this.repository.getMessage(id); + if (!(stored instanceof Create)) { + await this.repository.removeQuoteAuthorizationReference(del.objectId); + return; + } + const object = await stored.getObject(ctx); + if ( + !isMessageObject(object) || object.id == null || + object.quoteAuthorizationId?.href !== del.objectId.href + ) { + await this.repository.removeQuoteAuthorizationReference(del.objectId); + return; + } + const actor = await this.#validateQuoteAuthorizationDeletion( + ctx, + del, + object, + ); + if (actor == null) return; + await this.#stripRejectedQuote(ctx, id, object, actor); + } + + async #stripRejectedQuote( + ctx: InboxContext, + id: Uuid, + object: MessageClass, + actor: Actor, + ): Promise { + const quoteId = object.quoteId; + let strippedObject: MessageClass | undefined; + const wasUpdated = await this.repository.updateMessage( + id, + async (existing) => { + if (!(existing instanceof Create)) return; + const existingObject = await existing.getObject(ctx); + if ( + !isMessageObject(existingObject) || existingObject.id == null || + (quoteId != null && existingObject.quoteId?.href !== quoteId.href) + ) { + return; + } + strippedObject = (await stripQuoteObject(existingObject)).clone({ + updated: Temporal.Now.instant(), + }); + return existing.clone({ object: strippedObject }); + }, + ); + if (object.quoteAuthorizationId != null) { + await this.repository.removeQuoteAuthorizationReference( + object.quoteAuthorizationId, + ); + } + if (!wasUpdated || strippedObject == null) return; + await this.#sendQuoteUpdate(ctx, strippedObject, actor); + if (this.onQuoteRejected != null) { + const session = this.getSession(ctx); + const message = await createMessage( + strippedObject, + session, + { [actor.id!.href]: actor }, + undefined, + undefined, + true, + ) as AuthorizedMessage; + await this.onQuoteRejected(session, message, actor); + } + } + + async #validateQuoteApproval( + ctx: InboxContext, + accept: Accept, + object: MessageClass, + ): Promise< + | { + readonly actor: Actor; + readonly authorization: QuoteAuthorization; + } + | undefined + > { + if (accept.actorId == null || accept.resultId == null) return undefined; + const actor = await accept.getActor({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }); + if ( + !isActor(actor) || actor.id == null || + actor.id.href !== accept.actorId.href + ) return undefined; + const target = await lookupObjectSafely(this, ctx, object.quoteId!); + if ( + !isMessageObject(target) || + target.attributionId?.href !== actor.id.href + ) return undefined; + let authorization = await accept.getResult({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }); + if (authorization == null) { + authorization = await lookupObjectSafely(this, ctx, accept.resultId); + } + if ( + !(authorization instanceof QuoteAuthorization) || + authorization.id == null || + authorization.id.href !== accept.resultId.href || + authorization.id.origin !== actor.id.origin || + authorization.attributionId?.href !== actor.id.href || + authorization.interactingObjectId?.href !== object.id!.href || + authorization.interactionTargetId?.href !== object.quoteId!.href + ) return undefined; + return { actor, authorization }; + } + + async #validateQuoteRejection( + ctx: InboxContext, + reject: Reject, + object: MessageClass, + ): Promise { + if (reject.actorId == null) return undefined; + const actor = await reject.getActor({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }); + if ( + !isActor(actor) || actor.id == null || + actor.id.href !== reject.actorId.href + ) return undefined; + const target = await lookupObjectSafely(this, ctx, object.quoteId!); + if ( + !isMessageObject(target) || + target.attributionId?.href !== actor.id.href + ) return undefined; + return actor; + } + + async #validateQuoteAuthorizationDeletion( + ctx: InboxContext, + del: Delete, + object: MessageClass, + ): Promise { + if (del.actorId == null || object.quoteAuthorizationId == null) { + return undefined; + } + const actor = await del.getActor({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }); + if ( + !isActor(actor) || actor.id == null || + actor.id.href !== del.actorId.href + ) { + return undefined; + } + const attribution = await this.repository + .findQuoteAuthorizationReferenceAttribution( + object.quoteAuthorizationId, + ); + if (attribution?.href !== actor.id.href) return undefined; + return actor; + } + + async #sendQuoteUpdate( + ctx: InboxContext, + object: MessageClass, + quoteActor: Actor, + ): Promise { + const update = new Update({ + id: new URL(`#update/${crypto.randomUUID()}`, object.id ?? ctx.origin), + actor: ctx.getActorUri(this.identifier), + tos: object.toIds, + ccs: object.ccIds, + object, + }); + const visibility = await this.#getMessageVisibility(ctx, object); + const excludeBaseUris = [new URL(ctx.origin)]; + const preferSharedInbox = visibility === "public" || + visibility === "unlisted" || visibility === "followers"; + if (preferSharedInbox) { + await ctx.sendActivity( + this, + "followers", + update, + { preferSharedInbox, excludeBaseUris }, + ); + } + const mentionUris: URL[] = []; + for await ( + const tag of object.getTags({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }) + ) { + if (!(tag instanceof Mention) || tag.href == null) continue; + mentionUris.push(tag.href); + } + const mentionedActors = (await Promise.all( + mentionUris.map((uri) => lookupObjectSafely(this, ctx, uri)), + )).filter(isActor); + const sentActorIds = new Set(); + const sendActorOnce = async (actor: Actor) => { + if (actor.id == null || sentActorIds.has(actor.id.href)) return; + sentActorIds.add(actor.id.href); + await ctx.sendActivity( + this, + actor, + update, + { preferSharedInbox, excludeBaseUris, fanout: "skip" }, + ); + }; + if (mentionedActors.length > 0) { + const unsentMentionedActors = mentionedActors.filter((actor) => { + if (actor.id == null || sentActorIds.has(actor.id.href)) return false; + sentActorIds.add(actor.id.href); + return true; + }); + if (unsentMentionedActors.length > 0) { + await ctx.sendActivity( + this, + unsentMentionedActors, + update, + { preferSharedInbox, excludeBaseUris }, + ); + } + } + if (object.replyTargetId != null) { + const replyTarget = await lookupObjectSafely( + this, + ctx, + object.replyTargetId, + ); + if (isMessageObject(replyTarget)) { + const replyActor = await replyTarget.getAttribution({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }); + if (isActor(replyActor)) await sendActorOnce(replyActor); + } + } + await sendActorOnce(quoteActor); + } + async onQuoteRequested( ctx: InboxContext, request: RawQuoteRequest, @@ -1545,6 +1956,79 @@ export class BotImpl implements Bot { } } +async function stripQuoteObject(object: MessageClass): Promise { + const json = await object.toJsonLd({ format: "compact" }); + if (!isRecord(json)) return object; + delete json.quote; + delete json.quoteUrl; + delete json.quoteUri; + delete json._misskey_quote; + delete json.quoteAuthorization; + json.content = stripQuoteFallback(json.content); + json.tag = stripQuoteTags(json.tag, object.quoteId ?? object.quoteUrl); + const cls = getMessageClass(object); + return await cls.fromJsonLd(json) as MessageClass; +} + +function stripQuoteFallback(value: unknown): unknown { + if (typeof value === "string") { + return value.replace( + /\n\n


RE: \1<\/a><\/p>$/, + "", + ); + } + if (Array.isArray(value)) { + return value.map((item) => stripQuoteFallback(item)); + } + if (isRecord(value)) { + const stripped: Record = {}; + for (const [key, item] of globalThis.Object.entries(value)) { + stripped[key] = stripQuoteFallback(item); + } + return stripped; + } + return value; +} + +function stripQuoteTags(value: unknown, quoteUrl: URL | null): unknown { + if (Array.isArray(value)) { + return value + .filter((item) => !isMisskeyQuoteTag(item, quoteUrl)) + .map((item) => stripQuoteTags(item, quoteUrl)); + } + if (isMisskeyQuoteTag(value, quoteUrl)) return undefined; + return value; +} + +function isMisskeyQuoteTag(value: unknown, quoteUrl: URL | null): boolean { + if (!isRecord(value)) return false; + if ( + value.rel === "https://misskey-hub.net/ns#_misskey_quote" || + value.rel === "misskey:_misskey_quote" + ) return true; + return quoteUrl != null && value.href === quoteUrl.href; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value != null && !Array.isArray(value); +} + +async function lookupObjectSafely( + bot: BotImpl, + ctx: InboxContext, + id: URL, +): Promise { + try { + const documentLoader = await ctx.getDocumentLoader(bot); + return await ctx.lookupObject(id, { + contextLoader: ctx.contextLoader, + documentLoader, + }); + } catch { + return null; + } +} + /** * Wraps a {@link BotImpl} instance with a plain object implementing * the {@link Bot} interface. Since `deno serve` does not recognize a class @@ -1625,6 +2109,18 @@ export function wrapBotImpl( set onQuoteRequest(value) { bot.onQuoteRequest = value; }, + get onQuoteAccepted() { + return bot.onQuoteAccepted; + }, + set onQuoteAccepted(value) { + bot.onQuoteAccepted = value; + }, + get onQuoteRejected() { + return bot.onQuoteRejected; + }, + set onQuoteRejected(value) { + bot.onQuoteRejected = value; + }, get onMessage() { return bot.onMessage; }, @@ -1887,6 +2383,54 @@ export class MigrationGatedRepository implements Repository { return await this.#repository.removeQuoteAuthorization(identifier, id); } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + await this.#migration; + return await this.#repository.addQuoteAuthorizationReference( + identifier, + authorization, + messageId, + attribution, + ); + } + + async findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.#migration; + return await this.#repository.findQuoteAuthorizationReference( + identifier, + authorization, + ); + } + + async findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + await this.#migration; + return await this.#repository.findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ); + } + + async removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.#migration; + return await this.#repository.removeQuoteAuthorizationReference( + identifier, + authorization, + ); + } + async vote( identifier: string, messageId: Uuid, @@ -1941,6 +2485,8 @@ export class BotGroupImpl implements BotGroup { onReply?: ReplyEventHandler; onQuote?: QuoteEventHandler; onQuoteRequest?: QuoteRequestEventHandler; + onQuoteAccepted?: QuoteAcceptedEventHandler; + onQuoteRejected?: QuoteRejectedEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; diff --git a/packages/botkit/src/bot.test.ts b/packages/botkit/src/bot.test.ts index 76a811a..64d4583 100644 --- a/packages/botkit/src/bot.test.ts +++ b/packages/botkit/src/bot.test.ts @@ -21,7 +21,13 @@ import type { BotImpl } from "./bot-impl.ts"; import { MemoryRepository } from "./repository.ts"; import { createBot, type ReadonlyBot } from "./bot.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 { QuoteRequest } from "./quote.ts"; import type { Like } from "./reaction.ts"; import type { Session } from "./session.ts"; @@ -54,6 +60,32 @@ test("createBot()", async () => { assert.strictEqual(bot.onRejectFollow, onRejectFollow); assert.strictEqual(impl.onRejectFollow, onRejectFollow); + function onQuoteRequest( + _session: Session, + _request: QuoteRequest, + ) {} + bot.onQuoteRequest = onQuoteRequest; + assert.strictEqual(bot.onQuoteRequest, onQuoteRequest); + assert.strictEqual(impl.onQuoteRequest, onQuoteRequest); + + function onQuoteAccepted( + _session: Session, + _message: AuthorizedMessage, + _approver: Actor, + ) {} + bot.onQuoteAccepted = onQuoteAccepted; + assert.strictEqual(bot.onQuoteAccepted, onQuoteAccepted); + assert.strictEqual(impl.onQuoteAccepted, onQuoteAccepted); + + function onQuoteRejected( + _session: Session, + _message: AuthorizedMessage, + _rejecter: Actor, + ) {} + bot.onQuoteRejected = onQuoteRejected; + assert.strictEqual(bot.onQuoteRejected, onQuoteRejected); + assert.strictEqual(impl.onQuoteRejected, onQuoteRejected); + function onMention( _session: Session, _message: Message, diff --git a/packages/botkit/src/bot.ts b/packages/botkit/src/bot.ts index 03edb43..d9eb77d 100644 --- a/packages/botkit/src/bot.ts +++ b/packages/botkit/src/bot.ts @@ -29,7 +29,9 @@ import type { LikeEventHandler, MentionEventHandler, MessageEventHandler, + QuoteAcceptedEventHandler, QuoteEventHandler, + QuoteRejectedEventHandler, QuoteRequestEventHandler, ReactionEventHandler, RejectEventHandler, @@ -96,6 +98,18 @@ export interface BotEventHandlers { */ onQuoteRequest?: QuoteRequestEventHandler; + /** + * An event handler invoked when a quote request the bot sent is accepted. + * @since 0.5.0 + */ + onQuoteAccepted?: QuoteAcceptedEventHandler; + + /** + * An event handler invoked when a quote request the bot sent is rejected. + * @since 0.5.0 + */ + onQuoteRejected?: QuoteRejectedEventHandler; + /** * 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/events.ts b/packages/botkit/src/events.ts index 032463f..4c0b9bd 100644 --- a/packages/botkit/src/events.ts +++ b/packages/botkit/src/events.ts @@ -15,7 +15,12 @@ // along with this program. If not, see . import type { Actor } from "@fedify/vocab"; 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 { QuoteRequest } from "./quote.ts"; import type { Like, Reaction } from "./reaction.ts"; @@ -111,6 +116,34 @@ export type QuoteRequestEventHandler = ( quoteRequest: QuoteRequest, ) => void | Promise; +/** + * An event handler invoked when a quote request the bot sent is accepted. + * @typeParam TContextData The type of the context data. + * @param session The session of the bot. + * @param message The bot's quote message with the authorization stamp applied. + * @param approver The actor who approved the quote. + * @since 0.5.0 + */ +export type QuoteAcceptedEventHandler = ( + session: Session, + message: AuthorizedMessage, + approver: Actor, +) => void | Promise; + +/** + * An event handler invoked when a quote request the bot sent is rejected. + * @typeParam TContextData The type of the context data. + * @param session The session of the bot. + * @param message The bot's quote message after the quote target was removed. + * @param rejecter The actor who rejected the quote. + * @since 0.5.0 + */ +export type QuoteRejectedEventHandler = ( + session: Session, + message: AuthorizedMessage, + rejecter: Actor, +) => void | Promise; + /** * An event handler for a message shown to the bot's timeline. To listen to * this event, your bot needs to follow others first. diff --git a/packages/botkit/src/instance-impl.ts b/packages/botkit/src/instance-impl.ts index 1f2b364..634b94e 100644 --- a/packages/botkit/src/instance-impl.ts +++ b/packages/botkit/src/instance-impl.ts @@ -307,6 +307,14 @@ export class InstanceImpl return await bot?.dispatchQuoteAuthorization(ctx, values) ?? null; }, ); + this.federation.setObjectDispatcher( + QuoteRequest, + "/ap/actor/{identifier}/quote-request/{id}", + async (ctx, values) => { + const bot = await this.resolveBot(ctx, values.identifier); + return await bot?.dispatchQuoteRequest(ctx, values) ?? null; + }, + ); this.federation.setObjectDispatcher( APEmoji, "/ap/emoji/{name}", @@ -324,6 +332,7 @@ export class InstanceImpl .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(Delete, (ctx, del) => this.onDeleted(ctx, del)) .on(Announce, (ctx, announce) => this.onAnnounced(ctx, announce)) .on(RawLike, (ctx, like) => this.onLiked(ctx, like)) .setSharedKeyDispatcher((ctx) => this.dispatchSharedKey(ctx)); @@ -667,6 +676,23 @@ export class InstanceImpl for (const bot of bots) await bot.onFollowRejected(ctx, reject); } + async onDeleted( + ctx: InboxContext, + del: Delete, + ): Promise { + const bots = await this.#resolveTargets(ctx, () => { + const targets = new Set(); + for (const uri of [...del.toIds, ...del.ccIds]) { + const parsed = ctx.parseUri(uri); + if (parsed?.type === "actor" || parsed?.type === "followers") { + if (parsed.identifier != null) targets.add(parsed.identifier); + } + } + return targets; + }); + for (const bot of bots) await bot.onDeleted(ctx, del); + } + async onLiked( ctx: InboxContext, like: RawLike, diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index 93f17d3..ffb8fe5 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -17,6 +17,7 @@ import { type InboxContext, MemoryKvStore } from "@fedify/fedify/federation"; import { Accept, Create, + Delete, Follow, Like as RawLike, Mention, @@ -479,6 +480,84 @@ describe("shared inbox routing", () => { assert.deepStrictEqual(events, ["quote:alpha"]); }); + test("routes quote authorization Deletes to addressed bots", async () => { + const { instance, repository, alpha, beta, ctx } = createHarness(); + const events: string[] = []; + alpha.onQuoteRejected = (session) => + void (events.push(`rejected:${session.bot.identifier}`)); + beta.onQuoteRejected = (session) => + void (events.push(`rejected:${session.bot.identifier}`)); + const author = new Person({ + id: new URL("https://remote.example/actors/john"), + preferredUsername: "john", + }); + const target = new Note({ + id: new URL("https://remote.example/notes/original"), + attribution: author, + content: "Original.", + to: PUBLIC_COLLECTION, + }); + Object.defineProperty(ctx, "lookupObject", { + value: (id: URL) => + Promise.resolve(id.href === target.id?.href ? target : null), + configurable: true, + }); + const messageId = "01950000-0000-7000-8000-000000000305" as Uuid; + const quoteId = new URL( + `https://example.com/ap/actor/alpha/note/${messageId}`, + ); + const authorization = new URL("https://remote.example/stamps/1"); + await repository.addMessage( + "alpha", + messageId, + new Create({ + id: new URL( + `https://example.com/ap/actor/alpha/create/${messageId}`, + ), + actor: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + object: new Note({ + id: quoteId, + attribution: new URL("https://example.com/ap/actor/alpha"), + to: PUBLIC_COLLECTION, + content: `

Quote.

\n\n


RE: ${target.id!.href}

`, + quote: target.id, + quoteUrl: target.id, + quoteAuthorization: authorization, + }), + }), + ); + await repository.addQuoteAuthorizationReference( + "alpha", + authorization, + messageId, + author.id!, + ); + + await instance.onDeleted( + ctx, + new Delete({ + actor: author, + object: authorization, + to: new URL("https://example.com/ap/actor/alpha"), + }), + ); + + assert.deepStrictEqual(events, ["rejected:alpha"]); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("alpha", authorization), + undefined, + ); + const stored = await repository.getMessage("alpha", messageId); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.deepStrictEqual(object.quoteAuthorizationId, null); + assert.deepStrictEqual(object.quoteId, null); + }); + 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/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index 9c04cbe..3926c16 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -44,7 +44,7 @@ import { getMessageVisibility, isMessageObject, } from "./message-impl.ts"; -import { MemoryRepository } from "./repository.ts"; +import { MemoryRepository, type Uuid } from "./repository.ts"; import { InstanceImpl } from "./instance-impl.ts"; import { createMockContext } from "./session-impl.test.ts"; import { SessionImpl } from "./session-impl.ts"; @@ -240,6 +240,63 @@ test("AuthorizedMessageImpl.delete()", async () => { assert.deepStrictEqual(tombstone.id, note.id); }); +test("AuthorizedMessageImpl.delete() clears quote authorization references", 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 messageId = "01950000-0000-7000-8000-000000000501" as Uuid; + const authorization = new URL("https://remote.example/stamps/1"); + const note = new Note({ + id: ctx.getObjectUri(Note, { identifier: bot.identifier, id: messageId }), + content: "

Hello, world!

", + attribution: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + quote: new URL("https://remote.example/notes/original"), + quoteAuthorization: authorization, + }); + const msg = await createMessage( + note, + session, + {}, + undefined, + undefined, + true, + ); + await repository.addMessage( + "bot", + messageId, + new Create({ + id: ctx.getObjectUri(Create, { + identifier: bot.identifier, + id: messageId, + }), + actor: ctx.getActorUri(bot.identifier), + to: PUBLIC_COLLECTION, + cc: ctx.getFollowersUri(bot.identifier), + object: note, + }), + ); + await repository.addQuoteAuthorizationReference( + "bot", + authorization, + messageId, + ); + + await msg.delete(); + + assert.deepStrictEqual(await repository.countMessages("bot"), 0); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + undefined, + ); +}); + test("MessageImpl.reply()", async () => { const repository = new MemoryRepository(); const bot = new BotImpl({ @@ -606,7 +663,15 @@ test("AuthorizedMessage.update()", async (t) => { const ctx = createMockContext(bot, "https://example.com"); const session = new SessionImpl(bot, ctx); const msg = await session.publish(text`Hello`); + assert.deepStrictEqual(msg.quotePolicy, { + automatic: "public", + manual: undefined, + }); await msg.update(text`Hello again`, { quotePolicy: "nobody" }); + assert.deepStrictEqual(msg.quotePolicy, { + automatic: "nobody", + manual: undefined, + }); const [create] = await Array.fromAsync(repository.getMessages("bot")); assert.ok(create instanceof Create); const object = await create.getObject(ctx); diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 0d149b9..4404e69 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -54,7 +54,11 @@ import type { } from "./message.ts"; import type { AuthorizedLike, AuthorizedReaction } from "./reaction.ts"; import type { Uuid } from "./repository.ts"; -import { serializeQuotePolicy } from "./quote.ts"; +import { + parseQuotePolicy, + type QuotePolicy, + serializeQuotePolicy, +} from "./quote.ts"; import type { SessionImpl } from "./session-impl.ts"; import type { SessionPublishOptions, @@ -94,6 +98,7 @@ export class MessageImpl html: string; readonly replyTarget?: Message | undefined; readonly quoteTarget?: Message | undefined; + quotePolicy?: QuotePolicy | undefined; mentions: readonly Actor[]; hashtags: readonly Hashtag[]; readonly attachments: readonly Document[]; @@ -102,10 +107,14 @@ export class MessageImpl constructor( session: SessionImpl, - message: Omit< - Message, - "delete" | "reply" | "share" | "like" | "react" - >, + message: + & Omit< + Message, + "delete" | "reply" | "share" | "like" | "react" + > + & { + readonly quoteApprovalState?: "pending" | "accepted" | "notRequired"; + }, ) { this.session = session; this.raw = message.raw; @@ -117,6 +126,7 @@ export class MessageImpl this.html = message.html; this.replyTarget = message.replyTarget; this.quoteTarget = message.quoteTarget; + this.quotePolicy = message.quotePolicy; this.mentions = message.mentions; this.hashtags = message.hashtags; this.attachments = message.attachments; @@ -379,6 +389,25 @@ export class MessageImpl export class AuthorizedMessageImpl extends MessageImpl implements AuthorizedMessage { + readonly quoteApprovalState?: "pending" | "accepted" | "notRequired"; + + constructor( + session: SessionImpl, + message: Omit< + AuthorizedMessage, + | "delete" + | "reply" + | "share" + | "like" + | "react" + | "update" + | "unauthorizeQuote" + >, + ) { + super(session, message); + this.quoteApprovalState = message.quoteApprovalState; + } + async update( text: Text<"block", TContextData>, options: AuthorizedMessageUpdateOptions = {}, @@ -444,6 +473,13 @@ export class AuthorizedMessageImpl this.hashtags = hashtags; const updated = Temporal.Now.instant(); this.updated = updated; + const quoteTargetActorId = this.quoteTarget?.actor.id; + const privateQuoteAudienceIds = quoteTargetActorId != null && + quoteTargetActorId.href !== this.session.actorId.href && + (this.visibility === "followers" || this.visibility === "direct") && + !mentionedActorIds.some((id) => id.href === quoteTargetActorId.href) + ? [quoteTargetActorId] + : []; const newMessage = message.clone({ contents: this.language == null ? [contentHtml] @@ -455,8 +491,9 @@ export class AuthorizedMessageImpl ? [ this.session.context.getFollowersUri(this.session.bot.identifier), ...mentionedActorIds, + ...privateQuoteAudienceIds, ] - : mentionedActorIds, + : [...mentionedActorIds, ...privateQuoteAudienceIds], ccs: this.visibility === "public" ? [ this.session.context.getFollowersUri(this.session.bot.identifier), @@ -476,6 +513,11 @@ export class AuthorizedMessageImpl ), }); this.raw = newMessage as T; + this.quotePolicy = parseQuotePolicy( + newMessage.interactionPolicy?.canQuote, + this.session.actorId, + this.session.context.getFollowersUri(this.session.bot.identifier), + ); create = create.clone({ object: newMessage, updated }); const to = create.toIds.map((url) => url.href); for (const url of newMessage.toIds) { @@ -556,6 +598,14 @@ export class AuthorizedMessageImpl if (create == null) return; const message = await create.getObject(this.session.context); if (message == null) return; + if ( + isMessageObject(message) && + message.quoteAuthorizationId != null + ) { + await this.session.bot.repository.removeQuoteAuthorizationReference( + message.quoteAuthorizationId, + ); + } const mentionedActorIds: Set = new Set(); for await (const tag of message.getTags(this.session.context)) { if (tag instanceof Mention && tag.href != null) { @@ -849,6 +899,24 @@ export async function createMessage( quoteTarget = await createMessage(qt, session, cachedObjects); } } + const quotePolicy = actor.id == null && raw.attributionId == null + ? undefined + : parseQuotePolicy( + raw.interactionPolicy?.canQuote, + actor.id ?? raw.attributionId!, + actor.followersId, + ); + const quoteApprovalState = !authorized || quoteTarget == null + ? undefined + : quoteTarget.actor.id?.href === actor.id?.href + ? "notRequired" + : raw.quoteAuthorizationId == null + ? "pending" + : "accepted"; + const directRecipientIds = new Set(mentionedActorIds); + if (quoteTarget?.actor.id != null) { + directRecipientIds.add(quoteTarget.actor.id.href); + } return new (authorized ? AuthorizedMessageImpl : MessageImpl)(session, { raw, id: raw.id, @@ -857,7 +925,7 @@ export async function createMessage( raw.toIds, raw.ccIds, actor, - mentionedActorIds, + directRecipientIds, ), language: raw.content instanceof LanguageString ? raw.content.locale @@ -866,6 +934,8 @@ export async function createMessage( html, replyTarget, quoteTarget, + quotePolicy, + quoteApprovalState, mentions, hashtags, attachments, diff --git a/packages/botkit/src/message.ts b/packages/botkit/src/message.ts index 646ee37..d193674 100644 --- a/packages/botkit/src/message.ts +++ b/packages/botkit/src/message.ts @@ -30,7 +30,7 @@ import type { SessionPublishOptions, SessionPublishOptionsWithClass, } from "./session.ts"; -import type { QuotePolicyOption } from "./quote.ts"; +import type { QuotePolicy, QuotePolicyOption } from "./quote.ts"; import type { Text } from "./text.ts"; export { Article, @@ -151,6 +151,12 @@ export interface Message { */ readonly quoteTarget?: Message; + /** + * The quote policy advertised by this message, if present. + * @since 0.5.0 + */ + readonly quotePolicy?: QuotePolicy; + /** * The published time of the message. */ @@ -225,6 +231,13 @@ export interface Message { */ export interface AuthorizedMessage extends Message { + /** + * The approval state for this message's quote target. It is only present + * when this message quotes another message. + * @since 0.5.0 + */ + readonly quoteApprovalState?: "pending" | "accepted" | "notRequired"; + /** * Updates the message with new content. * @param text The new content of the message. diff --git a/packages/botkit/src/quote.test.ts b/packages/botkit/src/quote.test.ts index 739703f..e37343e 100644 --- a/packages/botkit/src/quote.test.ts +++ b/packages/botkit/src/quote.test.ts @@ -13,10 +13,14 @@ // // 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 { InteractionRule, PUBLIC_COLLECTION } from "@fedify/vocab"; import assert from "node:assert/strict"; import { describe, test } from "node:test"; -import { normalizeQuotePolicy, serializeQuotePolicy } from "./quote.ts"; +import { + normalizeQuotePolicy, + parseQuotePolicy, + serializeQuotePolicy, +} from "./quote.ts"; describe("normalizeQuotePolicy()", () => { test("normalizes string policies", () => { @@ -75,3 +79,18 @@ describe("serializeQuotePolicy()", () => { assert.deepStrictEqual(rule?.manualApprovals, [followers]); }); }); + +describe("parseQuotePolicy()", () => { + const actor = new URL("https://example.com/ap/actor/bot"); + const followers = new URL("https://example.com/ap/actor/bot/followers"); + + test("treats nullish approval lists as absent", () => { + const rule = new InteractionRule({}); + Object.defineProperty(rule, "automaticApprovals", { value: null }); + Object.defineProperty(rule, "manualApprovals", { value: undefined }); + assert.deepStrictEqual(parseQuotePolicy(rule, actor, followers), { + automatic: undefined, + manual: undefined, + }); + }); +}); diff --git a/packages/botkit/src/quote.ts b/packages/botkit/src/quote.ts index 3cfccb2..8d68bba 100644 --- a/packages/botkit/src/quote.ts +++ b/packages/botkit/src/quote.ts @@ -151,6 +151,35 @@ export function serializeQuotePolicy( }); } +/** + * Parses a serialized interaction policy rule into BotKit's quote policy + * shape. + * @param rule The interaction policy rule to parse. + * @param actorUri The URI of the actor that published the message. + * @param followersUri The URI of the actor's followers collection. + * @returns The parsed quote policy. + * @since 0.5.0 + */ +export function parseQuotePolicy( + rule: InteractionRule | null | undefined, + actorUri: URL, + followersUri?: URL | null, +): QuotePolicy | undefined { + if (rule == null) return undefined; + return { + automatic: parseQuoteAcceptance( + rule.automaticApprovals, + actorUri, + followersUri, + ), + manual: parseQuoteAcceptance( + rule.manualApprovals, + actorUri, + followersUri, + ), + }; +} + function serializeQuoteAcceptance( acceptance: QuoteAcceptance | undefined, actorUri: URL, @@ -168,3 +197,27 @@ function serializeQuoteAcceptance( return []; } } + +function parseQuoteAcceptance( + approvals: readonly URL[] | null | undefined, + actorUri: URL, + followersUri?: URL | null, +): QuoteAcceptance | undefined { + if (approvals == null) return undefined; + if (approvals.some((approval) => approval.href === PUBLIC_COLLECTION.href)) { + return "public"; + } + if ( + followersUri != null && + approvals.some((approval) => approval.href === followersUri.href) + ) { + return "followers"; + } + if ( + approvals.length > 0 && + approvals.every((approval) => approval.href === actorUri.href) + ) { + return "nobody"; + } + return undefined; +} diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index 2760781..ee3920b 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -132,6 +132,46 @@ for (const [name, factory] of Object.entries(factories)) { undefined, ); }); + + test(`${name} stores quote authorization references`, async () => { + const repository = factory(); + const authorization = new URL("https://remote.example/stamps/1"); + const firstMessageId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + const secondMessageId = "01942976-3400-7f34-872e-2cbf0f9eeac5" as Uuid; + + await repository.addQuoteAuthorizationReference( + "bot", + authorization, + firstMessageId, + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("other", authorization), + undefined, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + firstMessageId, + ); + + await repository.addQuoteAuthorizationReference( + "bot", + authorization, + secondMessageId, + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + secondMessageId, + ); + + await repository.removeQuoteAuthorizationReference("bot", authorization); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + undefined, + ); + }); } test("MemoryCachedRepository keeps duplicate quote authorizations out of cache", async () => { @@ -172,6 +212,73 @@ test("MemoryCachedRepository keeps duplicate quote authorizations out of cache", ); }); +test("MemoryCachedRepository does not cache failed quote authorization references", async () => { + class FailingQuoteAuthorizationReferenceRepository extends MemoryRepository { + override addQuoteAuthorizationReference( + _identifier: string, + _authorization: URL, + _messageId: Uuid, + ): Promise { + return Promise.reject(new TypeError("Durable write failed.")); + } + } + const underlying = new FailingQuoteAuthorizationReferenceRepository(); + const repository = new MemoryCachedRepository(underlying); + const authorization = new URL("https://remote.example/stamps/1"); + const messageId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + + await assert.rejects( + () => + repository.addQuoteAuthorizationReference( + "bot", + authorization, + messageId, + ), + TypeError, + "Durable write failed.", + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + undefined, + ); +}); + +test("MemoryCachedRepository keeps quote authorization reference cache on remove failures", async () => { + class FailingQuoteAuthorizationReferenceRepository extends MemoryRepository { + override removeQuoteAuthorizationReference( + _identifier: string, + _authorization: URL, + ): Promise { + return Promise.reject(new TypeError("Durable delete failed.")); + } + } + const underlying = new FailingQuoteAuthorizationReferenceRepository(); + const repository = new MemoryCachedRepository(underlying); + const authorization = new URL("https://remote.example/stamps/1"); + const messageId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; + await underlying.addQuoteAuthorizationReference( + "bot", + authorization, + messageId, + ); + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + messageId, + ); + + await assert.rejects( + () => repository.removeQuoteAuthorizationReference("bot", authorization), + TypeError, + "Durable delete failed.", + ); + + assert.deepStrictEqual( + await repository.findQuoteAuthorizationReference("bot", authorization), + messageId, + ); +}); + test("KvRepository serializes concurrent quote authorization inserts", async () => { const repository = new KvRepository(new RacingQuoteAuthorizationKvStore()); const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4" as Uuid; diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index dca9d04..ab500ec 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -72,6 +72,11 @@ function getRawQuoteAuthorizationInteractingObject( */ export type Uuid = ReturnType; +interface QuoteAuthorizationReferenceData { + readonly messageId: Uuid; + readonly attribution?: string; +} + /** * A repository for storing bot data. * @@ -361,6 +366,58 @@ export interface Repository { id: Uuid, ): Promise; + /** + * Adds a reference to a quote authorization stamp received for one of + * the bot's own quote posts. + * @param identifier The identifier of the bot actor that owns the message. + * @param authorization The URI of the received quote authorization stamp. + * @param messageId The UUID of the bot's quote message. + * @param attribution The actor that issued the authorization stamp. + * @since 0.5.0 + */ + addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise; + + /** + * Finds the bot's quote message that depends on a received quote + * authorization stamp. + * @param identifier The identifier of the bot actor that owns the message. + * @param authorization The URI of the received quote authorization stamp. + * @returns The UUID of the bot's quote message, or `undefined`. + * @since 0.5.0 + */ + findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise; + + /** + * Finds the actor that issued a received quote authorization stamp. + * @param identifier The identifier of the bot actor that owns the message. + * @param authorization The URI of the received quote authorization stamp. + * @returns The URI of the actor that issued the stamp, or `undefined`. + * @since 0.5.0 + */ + findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise; + + /** + * Removes the reference to a received quote authorization stamp. + * @param identifier The identifier of the bot actor that owns the message. + * @param authorization The URI of the received quote authorization stamp. + * @since 0.5.0 + */ + removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): 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. @@ -737,6 +794,68 @@ export class ActorScopedRepository { ): Promise { return this.repository.removeQuoteAuthorization(this.identifier, id); } + + /** + * Adds a received quote authorization reference. + * @param authorization The URI of the received quote authorization stamp. + * @param messageId The UUID of the bot's quote message. + * @param attribution The actor that issued the authorization stamp. + * @since 0.5.0 + */ + addQuoteAuthorizationReference( + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + return this.repository.addQuoteAuthorizationReference( + this.identifier, + authorization, + messageId, + attribution, + ); + } + + /** + * Finds a message by received quote authorization stamp URI. + * @param authorization The URI of the received quote authorization stamp. + * @returns The UUID of the bot's quote message, or `undefined`. + * @since 0.5.0 + */ + findQuoteAuthorizationReference( + authorization: URL, + ): Promise { + return this.repository.findQuoteAuthorizationReference( + this.identifier, + authorization, + ); + } + + /** + * Finds the actor that issued a received quote authorization stamp. + * @param authorization The URI of the received quote authorization stamp. + * @returns The URI of the actor that issued the stamp, or `undefined`. + * @since 0.5.0 + */ + findQuoteAuthorizationReferenceAttribution( + authorization: URL, + ): Promise { + return this.repository.findQuoteAuthorizationReferenceAttribution( + this.identifier, + authorization, + ); + } + + /** + * Removes a received quote authorization reference. + * @param authorization The URI of the received quote authorization stamp. + * @since 0.5.0 + */ + removeQuoteAuthorizationReference(authorization: URL): Promise { + return this.repository.removeQuoteAuthorizationReference( + this.identifier, + authorization, + ); + } } /** @@ -861,6 +980,17 @@ export class KvRepository implements Repository { ); } + #quoteAuthorizationReferenceKey( + identifier: string, + authorization: URL, + ): KvKey { + return this.#key( + identifier, + "quoteAuthorizationRefs", + authorization.href, + ); + } + /** * Migrates data stored by BotKit 0.4 or earlier, which was not scoped by * bot actor identifiers, so that it belongs to the given identifier. @@ -910,6 +1040,7 @@ export class KvRepository implements Repository { "follows", "quoteAuthorizations", "quoteAuthorizationsByInteractingObject", + "quoteAuthorizationRefs", "polls", ] as const; for (const category of categories) { @@ -1688,6 +1819,56 @@ export class KvRepository implements Repository { } } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + await this.kv.set( + this.#quoteAuthorizationReferenceKey(identifier, authorization), + attribution == null + ? messageId + : { messageId, attribution: attribution.href }, + ); + } + + async findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + const value = await this.kv.get( + this.#quoteAuthorizationReferenceKey(identifier, authorization), + ); + if (typeof value === "string") return value as Uuid; + return value?.messageId; + } + + async findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + const value = await this.kv.get( + this.#quoteAuthorizationReferenceKey(identifier, authorization), + ); + if (typeof value !== "object" || value == null) return undefined; + if (value.attribution == null) return undefined; + try { + return new URL(value.attribution); + } catch { + return undefined; + } + } + + async removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.kv.delete( + this.#quoteAuthorizationReferenceKey(identifier, authorization), + ); + } + async #addToFolloweeIndex( identifier: string, followeeId: URL, @@ -1856,6 +2037,8 @@ interface MemoryActorData { followees: Record; quoteAuthorizations: Map; quoteAuthorizationsByInteractingObject: Map; + quoteAuthorizationRefs: Map; + quoteAuthorizationRefAttributions: Map; polls: Record>>; } @@ -1877,6 +2060,8 @@ export class MemoryRepository implements Repository { followees: {}, quoteAuthorizations: new Map(), quoteAuthorizationsByInteractingObject: new Map(), + quoteAuthorizationRefs: new Map(), + quoteAuthorizationRefAttributions: new Map(), polls: {}, }; this.#data.set(identifier, data); @@ -2165,6 +2350,57 @@ export class MemoryRepository implements Repository { return Promise.resolve(authorization); } + addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + const bucket = this.#bucket(identifier); + bucket.quoteAuthorizationRefs.set(authorization.href, messageId); + if (attribution == null) { + bucket.quoteAuthorizationRefAttributions.delete(authorization.href); + } else { + bucket.quoteAuthorizationRefAttributions.set( + authorization.href, + attribution, + ); + } + return Promise.resolve(); + } + + findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + return Promise.resolve( + this.#data.get(identifier)?.quoteAuthorizationRefs.get( + authorization.href, + ), + ); + } + + findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + return Promise.resolve( + this.#data.get(identifier)?.quoteAuthorizationRefAttributions.get( + authorization.href, + ), + ); + } + + removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + const data = this.#data.get(identifier); + data?.quoteAuthorizationRefs.delete(authorization.href); + data?.quoteAuthorizationRefAttributions.delete(authorization.href); + return Promise.resolve(); + } + vote( identifier: string, messageId: Uuid, @@ -2504,6 +2740,99 @@ export class MemoryCachedRepository implements Repository { return removed; } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + attribution?: URL, + ): Promise { + await this.underlying.addQuoteAuthorizationReference( + identifier, + authorization, + messageId, + attribution, + ); + await this.cache.addQuoteAuthorizationReference( + identifier, + authorization, + messageId, + attribution, + ); + } + + async findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + const cached = await this.cache.findQuoteAuthorizationReference( + identifier, + authorization, + ); + if (cached != null) return cached; + const found = await this.underlying.findQuoteAuthorizationReference( + identifier, + authorization, + ); + if (found != null) { + const attribution = await this.underlying + .findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ); + await this.cache.addQuoteAuthorizationReference( + identifier, + authorization, + found, + attribution, + ); + } + return found; + } + + async findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + const cached = await this.cache.findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ); + if (cached != null) return cached; + const found = await this.underlying.findQuoteAuthorizationReference( + identifier, + authorization, + ); + if (found == null) return undefined; + const attribution = await this.underlying + .findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ); + if (attribution != null) { + await this.cache.addQuoteAuthorizationReference( + identifier, + authorization, + found, + attribution, + ); + } + return attribution; + } + + async removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.underlying.removeQuoteAuthorizationReference( + identifier, + authorization, + ); + await this.cache.removeQuoteAuthorizationReference( + identifier, + authorization, + ); + } + 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 ab26408..e6b91f8 100644 --- a/packages/botkit/src/session-impl.test.ts +++ b/packages/botkit/src/session-impl.test.ts @@ -22,6 +22,7 @@ import { Person, PUBLIC_COLLECTION, Question, + QuoteRequest, type Recipient, Undo, Update, @@ -311,7 +312,8 @@ test("SessionImpl.republishProfile()", async () => { test("SessionImpl.publish()", async (t) => { const kv = new MemoryKvStore(); - const bot = new BotImpl({ kv, username: "bot" }); + const repository = new MemoryRepository(); + const bot = new BotImpl({ kv, repository, username: "bot" }); const ctx = createMockContext(bot, "https://example.com"); const session = new SessionImpl(bot, ctx); @@ -493,7 +495,7 @@ test("SessionImpl.publish()", async (t) => { const quote = await session.publish(text`Check this out!`, { quoteTarget: originalMsg, }); - assert.deepStrictEqual(ctx.sentActivities.length, 2); + assert.deepStrictEqual(ctx.sentActivities.length, 3); const { recipients, activity } = ctx.sentActivities[0]; assert.deepStrictEqual(recipients, "followers"); assert.ok(activity instanceof Create); @@ -525,7 +527,19 @@ test("SessionImpl.publish()", async (t) => {


RE: ${originalMsg.id.href}

`, ); + assert.deepStrictEqual(object.quoteId, originalMsg.id); assert.deepStrictEqual(object.quoteUrl, originalMsg.id); + const { recipients: requestRecipients, activity: requestActivity } = + ctx.sentActivities[2]; + assert.deepStrictEqual(requestRecipients, [originalAuthor]); + assert.ok(requestActivity instanceof QuoteRequest); + assert.deepStrictEqual(requestActivity.actorId, session.actorId); + assert.deepStrictEqual(requestActivity.objectId, originalMsg.id); + assert.deepStrictEqual(requestActivity.instrumentId, quote.id); + const parsedRequest = ctx.parseUri(requestActivity.id); + assert.deepStrictEqual(parsedRequest?.type, "object"); + assert.ok(parsedRequest?.type === "object"); + assert.deepStrictEqual(parsedRequest.class, QuoteRequest); assert.deepStrictEqual(quote.id, object.id); assert.deepStrictEqual( quote.text, @@ -541,6 +555,101 @@ test("SessionImpl.publish()", async (t) => { assert.deepStrictEqual(quote.quoteTarget?.id, originalMsg.id); }); + await t.test("private quote includes quoted author in audience", async () => { + const originalAuthorId = new URL("https://remote.example/ap/actor/john"); + const originalAuthor = new Person({ + id: originalAuthorId, + preferredUsername: "john", + }); + const originalPost = new Note({ + id: new URL("https://remote.example/ap/note/private"), + content: "

Private post

", + attribution: originalAuthor, + to: new URL("https://remote.example/ap/actor/john/followers"), + }); + const originalMsg = await createMessage( + originalPost, + session, + {}, + ); + + for (const visibility of ["followers", "direct"] as const) { + ctx.sentActivities = []; + await session.publish(text`Private quote`, { + quoteTarget: originalMsg, + visibility, + }); + + const { activity } = ctx.sentActivities[0]; + assert.ok(activity instanceof Create); + const object = await activity.getObject(ctx); + assert.ok(object instanceof Note); + assert.ok(object.toIds.includes(originalAuthorId)); + } + }); + + await t.test("direct quote preserves quoted author on update", async () => { + const originalAuthorId = new URL("https://remote.example/ap/actor/john"); + const originalAuthor = new Person({ + id: originalAuthorId, + preferredUsername: "john", + }); + const originalPost = new Note({ + id: new URL("https://remote.example/ap/note/direct"), + content: "

Direct post

", + attribution: originalAuthor, + to: originalAuthorId, + }); + const originalMsg = await createMessage( + originalPost, + session, + {}, + ); + ctx.sentActivities = []; + const quote = await session.publish(text`Direct quote`, { + quoteTarget: originalMsg, + visibility: "direct", + }); + + assert.deepStrictEqual(quote.visibility, "direct"); + assert.ok(quote.raw.toIds.includes(originalAuthorId)); + ctx.sentActivities = []; + await quote.update(text`Updated direct quote`); + + assert.deepStrictEqual(quote.visibility, "direct"); + assert.ok(quote.raw.toIds.includes(originalAuthorId)); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const stored = await repository.getMessage("bot", parsed.values.id as Uuid); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.ok(object.toIds.includes(originalAuthorId)); + }); + + await t.test("self-quote update preserves audience", async () => { + ctx.sentActivities = []; + const original = await session.publish(text`Original`, { + visibility: "followers", + }); + const quote = await session.publish(text`Self quote`, { + quoteTarget: original, + visibility: "followers", + }); + + assert.ok(!quote.raw.toIds.some((id) => id.href === session.actorId.href)); + await quote.update(text`Updated self quote`); + + assert.ok(!quote.raw.toIds.some((id) => id.href === session.actorId.href)); + const parsed = ctx.parseUri(quote.id); + assert.ok(parsed?.type === "object"); + const stored = await repository.getMessage("bot", parsed.values.id as Uuid); + assert.ok(stored instanceof Create); + const object = await stored.getObject(ctx); + assert.ok(object instanceof Note); + assert.ok(!object.toIds.some((id) => id.href === session.actorId.href)); + }); + await t.test("poll single choice", async () => { ctx.sentActivities = []; const endTime = Temporal.Now.instant().add({ hours: 24 }); diff --git a/packages/botkit/src/session-impl.ts b/packages/botkit/src/session-impl.ts index a8dfcc0..abdf0c1 100644 --- a/packages/botkit/src/session-impl.ts +++ b/packages/botkit/src/session-impl.ts @@ -26,6 +26,7 @@ import { Note, type Object, PUBLIC_COLLECTION, + QuoteRequest, Undo, Update, } from "@fedify/vocab"; @@ -287,6 +288,14 @@ export class SessionImpl implements Session { }), ); } + const actorId = this.context.getActorUri(this.bot.identifier); + const quoteTargetActorId = options.quoteTarget?.actor.id; + const privateQuoteAudienceIds = quoteTargetActorId != null && + quoteTargetActorId.href !== actorId.href && + (visibility === "followers" || visibility === "direct") && + !mentionedActorIds.some((id) => id.href === quoteTargetActorId.href) + ? [quoteTargetActorId] + : []; let inclusiveOptions: Note[] = []; let exclusiveOptions: Note[] = []; let voters: number | null = null; @@ -321,14 +330,15 @@ export class SessionImpl implements Session { ? [contentHtml] : [new LanguageString(contentHtml, options.language), contentHtml], replyTarget: options.replyTarget?.id, + quote: options.quoteTarget?.id, quoteUrl: options.quoteTarget?.id, tags, interactionPolicy: serializeQuotePolicy( options.quotePolicy ?? this.bot.quotePolicy, - this.context.getActorUri(this.bot.identifier), + actorId, this.context.getFollowersUri(this.bot.identifier), ), - attribution: this.context.getActorUri(this.bot.identifier), + attribution: actorId, attachments: options.attachments ?? [], inclusiveOptions, exclusiveOptions, @@ -340,8 +350,9 @@ export class SessionImpl implements Session { ? [ this.context.getFollowersUri(this.bot.identifier), ...mentionedActorIds, + ...privateQuoteAudienceIds, ] - : mentionedActorIds, + : [...mentionedActorIds, ...privateQuoteAudienceIds], ccs: visibility === "public" ? [this.context.getFollowersUri(this.bot.identifier)] : visibility === "unlisted" @@ -419,6 +430,28 @@ export class SessionImpl implements Session { activity, { preferSharedInbox, excludeBaseUris, fanout: "skip" }, ); + if ( + options.quoteTarget.actor.id != null && + options.quoteTarget.actor.id.href !== + this.context.getActorUri(this.bot.identifier).href + ) { + const request = new QuoteRequest({ + id: this.context.getObjectUri(QuoteRequest, { + identifier: this.bot.identifier, + id, + }), + actor: this.context.getActorUri(this.bot.identifier), + object: options.quoteTarget.id, + instrument: msg.id, + to: options.quoteTarget.actor.id, + }); + await this.context.sendActivity( + this.bot, + options.quoteTarget.actor, + request, + { preferSharedInbox, excludeBaseUris, fanout: "skip" }, + ); + } } return await createMessage( msg,