From fe1db02e2d14beeddf6ca249734bef841fb0eab5 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 20:32:41 +0900 Subject: [PATCH 01/12] Implement quote request responses Send FEP-044f QuoteRequest activities for outbound quote posts and handle remote Accept, Reject, and Delete responses. Accepted requests store verified QuoteAuthorization stamps, while rejected or revoked requests strip the quote target and fallback link before sending an Update. Add repository reference storage for received authorization stamps so revocations can map back to the local quote message. Cover the memory, KV, SQLite, and PostgreSQL repositories, shared-inbox Delete routing, and the approval-state API exposed on authorized messages. Update the message, event, repository, and changelog documentation for the new outbound FEP-044f behavior expected in PR 32. Fixes https://github.com/fedify-dev/botkit/issues/29 https://github.com/fedify-dev/botkit/issues/27 https://github.com/fedify-dev/botkit/pull/32 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 48 +- deno.lock | 1 + docs/concepts/events.md | 38 ++ docs/concepts/message.md | 28 ++ docs/concepts/repository.md | 8 + packages/botkit-postgres/src/mod.test.ts | 46 ++ packages/botkit-postgres/src/mod.ts | 56 +++ packages/botkit-sqlite/src/mod.test.ts | 44 ++ packages/botkit-sqlite/src/mod.ts | 49 ++ packages/botkit/src/bot-impl.test.ts | 339 ++++++++++++++ packages/botkit/src/bot-impl.ts | 442 +++++++++++++++++++ packages/botkit/src/bot.test.ts | 34 +- packages/botkit/src/bot.ts | 14 + packages/botkit/src/events.ts | 35 +- packages/botkit/src/instance-impl.ts | 26 ++ packages/botkit/src/instance-routing.test.ts | 78 ++++ packages/botkit/src/message-impl.ts | 55 ++- packages/botkit/src/message.ts | 15 +- packages/botkit/src/quote.ts | 52 +++ packages/botkit/src/repository.test.ts | 72 +++ packages/botkit/src/repository.ts | 212 +++++++++ packages/botkit/src/session-impl.test.ts | 15 +- packages/botkit/src/session-impl.ts | 24 + 23 files changed, 1711 insertions(+), 20 deletions(-) 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..f62076b 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -313,6 +313,44 @@ 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, 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..aa036fc 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -285,6 +285,17 @@ 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, + PRIMARY KEY (bot_id, authorization_uri) + )`, + [], + prepare, + ); await execute( sql, `CREATE TABLE IF NOT EXISTS "${validatedSchema}"."poll_votes" ( @@ -1094,6 +1105,51 @@ export class PostgresRepository implements Repository, AsyncDisposable { return await parseQuoteAuthorization(rows[0]?.authorization_json); } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + ): Promise { + await this.ensureReady(); + await this.query( + this.sql, + `INSERT INTO ${this.table("quote_authorization_refs")} + (bot_id, authorization_uri, message_id) + VALUES ($1, $2, $3) + ON CONFLICT (bot_id, authorization_uri) DO UPDATE + SET message_id = EXCLUDED.message_id`, + [identifier, authorization.href, messageId], + ); + } + + 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 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..e57478d 100644 --- a/packages/botkit-sqlite/src/mod.ts +++ b/packages/botkit-sqlite/src/mod.ts @@ -439,6 +439,15 @@ 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, + PRIMARY KEY (bot_id, authorization) + ) + `); + // Poll votes table this.db.exec(` CREATE TABLE IF NOT EXISTS poll_votes ( @@ -1069,6 +1078,46 @@ export class SqliteRepository implements Repository, Disposable { return await parseQuoteAuthorizationJson(row?.authorization_json); } + addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + ): Promise { + const stmt = this.db.prepare(` + INSERT OR REPLACE INTO quote_authorization_refs + (bot_id, authorization, message_id) + VALUES (?, ?, ?) + `); + stmt.run(identifier, authorization.href, messageId); + 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); + } + + 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..df0117d 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,344 @@ 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() 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.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.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.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..0bfcc92 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, @@ -74,7 +76,9 @@ import type { LikeEventHandler, MentionEventHandler, MessageEventHandler, + QuoteAcceptedEventHandler, QuoteEventHandler, + QuoteRejectedEventHandler, QuoteRequestEventHandler, ReactionEventHandler, RejectEventHandler, @@ -89,12 +93,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, @@ -148,6 +154,8 @@ export const botEventHandlerNames = [ "onReply", "onQuote", "onQuoteRequest", + "onQuoteAccepted", + "onQuoteRejected", "onMessage", "onSharedMessage", "onLike", @@ -221,6 +229,8 @@ export class BotImpl implements Bot { onReply?: ReplyEventHandler; onQuote?: QuoteEventHandler; onQuoteRequest?: QuoteRequestEventHandler; + onQuoteAccepted?: QuoteAcceptedEventHandler; + onQuoteRejected?: QuoteRejectedEventHandler; onMessage?: MessageEventHandler; onSharedMessage?: SharedMessageEventHandler; onLike?: LikeEventHandler; @@ -554,6 +564,30 @@ export class BotImpl implements Bot { null; } + async dispatchQuoteRequest( + ctx: RequestContext, + values: { identifier: string; id: string }, + ): Promise { + if (values.identifier !== this.identifier) return null; + const stored = await this.repository.getMessage(values.id as Uuid); + if (!(stored instanceof Create)) 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 +662,13 @@ export class BotImpl implements Bot { accept.objectId, this.legacyObjectUrisIdentifier, ); + if ( + parsedObj?.type === "object" && parsedObj.class === QuoteRequest && + parsedObj.values.identifier === this.identifier + ) { + await this.#onQuoteAccepted(ctx, accept, parsedObj.values.id as Uuid); + return; + } if ( parsedObj?.type !== "object" || parsedObj.class !== Follow || parsedObj.values.identifier !== this.identifier @@ -661,6 +702,13 @@ export class BotImpl implements Bot { reject.objectId, this.legacyObjectUrisIdentifier, ); + if ( + parsedObj?.type === "object" && parsedObj.class === QuoteRequest && + parsedObj.values.identifier === this.identifier + ) { + await this.#onQuoteRejected(ctx, reject, parsedObj.values.id as Uuid); + return; + } if ( parsedObj?.type !== "object" || parsedObj.class !== Follow || parsedObj.values.identifier !== this.identifier @@ -684,6 +732,280 @@ 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 approval = await this.#validateQuoteApproval(ctx, accept, object); + if (approval == null) return; + const updatedObject = object.clone({ + quoteAuthorization: approval.authorization.id, + updated: Temporal.Now.instant(), + }); + const updated = stored.clone({ object: updatedObject }); + await this.repository.addQuoteAuthorizationReference( + approval.authorization.id!, + id, + ); + try { + await this.repository.updateMessage(id, () => Promise.resolve(updated)); + } catch (error) { + await this.repository.removeQuoteAuthorizationReference( + approval.authorization.id!, + ); + 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, stored, 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, stored, object, actor); + } + + async #stripRejectedQuote( + ctx: InboxContext, + id: Uuid, + stored: Create, + object: MessageClass, + actor: Actor, + ): Promise { + if (object.quoteAuthorizationId != null) { + await this.repository.removeQuoteAuthorizationReference( + object.quoteAuthorizationId, + ); + } + const strippedObject = (await stripQuoteObject(object)).clone({ + updated: Temporal.Now.instant(), + }); + const updated = stored.clone({ object: strippedObject }); + await this.repository.updateMessage(id, () => Promise.resolve(updated)); + 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(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(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(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.quoteId == 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 target = await lookupObjectSafely(ctx, object.quoteId); + if ( + !isMessageObject(target) || + target.attributionId?.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 mentionedActors: Actor[] = []; + for await ( + const tag of object.getTags({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }) + ) { + if (!(tag instanceof Mention) || tag.href == null) continue; + const mentioned = await lookupObjectSafely(ctx, tag.href); + if (isActor(mentioned)) mentionedActors.push(mentioned); + } + if (mentionedActors.length > 0) { + await ctx.sendActivity( + this, + mentionedActors, + update, + { preferSharedInbox, excludeBaseUris }, + ); + } + await ctx.sendActivity( + this, + quoteActor, + update, + { preferSharedInbox, excludeBaseUris, fanout: "skip" }, + ); + } + async onQuoteRequested( ctx: InboxContext, request: RawQuoteRequest, @@ -1545,6 +1867,77 @@ 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( + ctx: InboxContext, + id: URL, +): Promise { + try { + return await ctx.lookupObject(id, { + contextLoader: ctx.contextLoader, + documentLoader: ctx.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 +2018,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 +2292,41 @@ export class MigrationGatedRepository implements Repository { return await this.#repository.removeQuoteAuthorization(identifier, id); } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + ): Promise { + await this.#migration; + return await this.#repository.addQuoteAuthorizationReference( + identifier, + authorization, + messageId, + ); + } + + async findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.#migration; + return await this.#repository.findQuoteAuthorizationReference( + 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 +2381,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..a236ced 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,83 @@ 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, + ); + + 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.ts b/packages/botkit/src/message-impl.ts index 0d149b9..c687626 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; + readonly 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 = {}, @@ -849,6 +878,20 @@ 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"; return new (authorized ? AuthorizedMessageImpl : MessageImpl)(session, { raw, id: raw.id, @@ -866,6 +909,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.ts b/packages/botkit/src/quote.ts index 3cfccb2..9a74a09 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,26 @@ function serializeQuoteAcceptance( return []; } } + +function parseQuoteAcceptance( + approvals: readonly URL[], + actorUri: URL, + followersUri?: URL | null, +): QuoteAcceptance | 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..e80c12f 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,38 @@ 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("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..7e49cf5 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -361,6 +361,44 @@ 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. + * @since 0.5.0 + */ + addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + ): 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; + + /** + * 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 +775,50 @@ 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. + * @since 0.5.0 + */ + addQuoteAuthorizationReference( + authorization: URL, + messageId: Uuid, + ): Promise { + return this.repository.addQuoteAuthorizationReference( + this.identifier, + authorization, + messageId, + ); + } + + /** + * 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, + ); + } + + /** + * 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 +943,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 +1003,7 @@ export class KvRepository implements Repository { "follows", "quoteAuthorizations", "quoteAuthorizationsByInteractingObject", + "quoteAuthorizationRefs", "polls", ] as const; for (const category of categories) { @@ -1688,6 +1782,35 @@ export class KvRepository implements Repository { } } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + ): Promise { + await this.kv.set( + this.#quoteAuthorizationReferenceKey(identifier, authorization), + messageId, + ); + } + + async findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + return await this.kv.get( + this.#quoteAuthorizationReferenceKey(identifier, authorization), + ); + } + + async removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.kv.delete( + this.#quoteAuthorizationReferenceKey(identifier, authorization), + ); + } + async #addToFolloweeIndex( identifier: string, followeeId: URL, @@ -1856,6 +1979,7 @@ interface MemoryActorData { followees: Record; quoteAuthorizations: Map; quoteAuthorizationsByInteractingObject: Map; + quoteAuthorizationRefs: Map; polls: Record>>; } @@ -1877,6 +2001,7 @@ export class MemoryRepository implements Repository { followees: {}, quoteAuthorizations: new Map(), quoteAuthorizationsByInteractingObject: new Map(), + quoteAuthorizationRefs: new Map(), polls: {}, }; this.#data.set(identifier, data); @@ -2165,6 +2290,39 @@ export class MemoryRepository implements Repository { return Promise.resolve(authorization); } + addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + ): Promise { + this.#bucket(identifier).quoteAuthorizationRefs.set( + authorization.href, + messageId, + ); + return Promise.resolve(); + } + + findQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + return Promise.resolve( + this.#data.get(identifier)?.quoteAuthorizationRefs.get( + authorization.href, + ), + ); + } + + removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + this.#data.get(identifier)?.quoteAuthorizationRefs.delete( + authorization.href, + ); + return Promise.resolve(); + } + vote( identifier: string, messageId: Uuid, @@ -2504,6 +2662,60 @@ export class MemoryCachedRepository implements Repository { return removed; } + async addQuoteAuthorizationReference( + identifier: string, + authorization: URL, + messageId: Uuid, + ): Promise { + await this.underlying.addQuoteAuthorizationReference( + identifier, + authorization, + messageId, + ); + await this.cache.addQuoteAuthorizationReference( + identifier, + authorization, + messageId, + ); + } + + 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) { + await this.cache.addQuoteAuthorizationReference( + identifier, + authorization, + found, + ); + } + return found; + } + + async removeQuoteAuthorizationReference( + identifier: string, + authorization: URL, + ): Promise { + await this.cache.removeQuoteAuthorizationReference( + identifier, + authorization, + ); + await this.underlying.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..df79366 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, @@ -493,7 +494,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 +526,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, diff --git a/packages/botkit/src/session-impl.ts b/packages/botkit/src/session-impl.ts index a8dfcc0..14568a4 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"; @@ -321,6 +322,7 @@ 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( @@ -419,6 +421,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, From 137773ffe2fdff80616377fe23f4a19e9c3a3762 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 21:17:14 +0900 Subject: [PATCH 02/12] Keep quote references consistent Move quote-authorization reference cleanup after the message update that strips a rejected or revoked quote. Also clear the same reference when a local quote message is deleted, and make the cached repository delete from the durable store before invalidating its cache. This keeps retries possible after transient update failures and avoids stale authorization mappings after local deletes or cache races. https://github.com/fedify-dev/botkit/pull/32#discussion_r3528692553 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528692555 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528692587 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528692601 Assisted-by: Codex:gpt-5.5 --- docs/concepts/events.md | 7 +- packages/botkit/src/bot-impl.test.ts | 98 ++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 10 +-- packages/botkit/src/message-impl.test.ts | 59 +++++++++++++- packages/botkit/src/message-impl.ts | 8 ++ packages/botkit/src/repository.test.ts | 35 +++++++++ packages/botkit/src/repository.ts | 4 +- 7 files changed, 210 insertions(+), 11 deletions(-) diff --git a/docs/concepts/events.md b/docs/concepts/events.md index f62076b..0034ac9 100644 --- a/docs/concepts/events.md +++ b/docs/concepts/events.md @@ -336,9 +336,10 @@ bot.onQuoteAccepted = (_session, message, approver) => { }; ~~~~ -When the request is rejected, BotKit removes the quote target and fallback -quote link from the stored message, sends an `Update` activity, and then calls -`~Bot.onQuoteRejected`: +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"; diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index df0117d..11aebd8 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3943,6 +3943,104 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { assert.deepStrictEqual(rejecter?.id, author.id); }); +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.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 0bfcc92..c991cd1 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -835,16 +835,16 @@ export class BotImpl implements Bot { object: MessageClass, actor: Actor, ): Promise { - if (object.quoteAuthorizationId != null) { - await this.repository.removeQuoteAuthorizationReference( - object.quoteAuthorizationId, - ); - } const strippedObject = (await stripQuoteObject(object)).clone({ updated: Temporal.Now.instant(), }); const updated = stored.clone({ object: strippedObject }); await this.repository.updateMessage(id, () => Promise.resolve(updated)); + if (object.quoteAuthorizationId != null) { + await this.repository.removeQuoteAuthorizationReference( + object.quoteAuthorizationId, + ); + } await this.#sendQuoteUpdate(ctx, strippedObject, actor); if (this.onQuoteRejected != null) { const session = this.getSession(ctx); diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index 9c04cbe..b5a20bc 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({ diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index c687626..6186632 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -585,6 +585,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) { diff --git a/packages/botkit/src/repository.test.ts b/packages/botkit/src/repository.test.ts index e80c12f..ee3920b 100644 --- a/packages/botkit/src/repository.test.ts +++ b/packages/botkit/src/repository.test.ts @@ -244,6 +244,41 @@ test("MemoryCachedRepository does not cache failed quote authorization reference ); }); +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 7e49cf5..9d60030 100644 --- a/packages/botkit/src/repository.ts +++ b/packages/botkit/src/repository.ts @@ -2706,11 +2706,11 @@ export class MemoryCachedRepository implements Repository { identifier: string, authorization: URL, ): Promise { - await this.cache.removeQuoteAuthorizationReference( + await this.underlying.removeQuoteAuthorizationReference( identifier, authorization, ); - await this.underlying.removeQuoteAuthorizationReference( + await this.cache.removeQuoteAuthorizationReference( identifier, authorization, ); From dabba583730552d86e81fe29f4b9039d3aa2ac0d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 21:33:56 +0900 Subject: [PATCH 03/12] Refresh quote policy state Quote policy parsing now treats missing remote approval lists as absent, which keeps malformed interaction rules from aborting message parsing. Authorized messages also refresh their public quotePolicy field after an update changes the serialized interaction policy, so callers do not see stale authorization rules on the same object instance. https://github.com/fedify-dev/botkit/pull/32#discussion_r3528788774 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528861400 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/message-impl.test.ts | 8 ++++++++ packages/botkit/src/message-impl.ts | 7 ++++++- packages/botkit/src/quote.test.ts | 23 +++++++++++++++++++++-- packages/botkit/src/quote.ts | 3 ++- 4 files changed, 37 insertions(+), 4 deletions(-) diff --git a/packages/botkit/src/message-impl.test.ts b/packages/botkit/src/message-impl.test.ts index b5a20bc..3926c16 100644 --- a/packages/botkit/src/message-impl.test.ts +++ b/packages/botkit/src/message-impl.test.ts @@ -663,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 6186632..25e4356 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -98,7 +98,7 @@ export class MessageImpl html: string; readonly replyTarget?: Message | undefined; readonly quoteTarget?: Message | undefined; - readonly quotePolicy?: QuotePolicy | undefined; + quotePolicy?: QuotePolicy | undefined; mentions: readonly Actor[]; hashtags: readonly Hashtag[]; readonly attachments: readonly Document[]; @@ -505,6 +505,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) { 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 9a74a09..8d68bba 100644 --- a/packages/botkit/src/quote.ts +++ b/packages/botkit/src/quote.ts @@ -199,10 +199,11 @@ function serializeQuoteAcceptance( } function parseQuoteAcceptance( - approvals: readonly URL[], + 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"; } From 02c0a2343dff71547b5b0163e84d7a7b21dcbbc2 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 21:50:01 +0900 Subject: [PATCH 04/12] Harden quote update handling Quote request IDs that come from local ActivityPub URLs are now checked before they reach repository lookups, so malformed public URLs do not leak storage-specific UUID errors through the inbox or object dispatcher. Quote acceptance updates now include reply target actors when they fan out the stored message update. Rollback cleanup for failed acceptance updates also logs cleanup failures without hiding the original update error. https://github.com/fedify-dev/botkit/pull/32#discussion_r3528933522 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528953419 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528953429 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528953438 https://github.com/fedify-dev/botkit/pull/32#discussion_r3528953447 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 151 ++++++++++++++++++++++++++- packages/botkit/src/bot-impl.ts | 51 +++++++-- 2 files changed, 194 insertions(+), 8 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 11aebd8..4fa3066 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { UnverifiedActivityReason } from "@fedify/fedify"; +import type { RequestContext, UnverifiedActivityReason } from "@fedify/fedify"; import { type InboxContext, MemoryKvStore } from "@fedify/fedify/federation"; import { Accept, @@ -3685,6 +3685,83 @@ test("BotImpl.onFollowAccepted() accepts quote approvals", async () => { assert.deepStrictEqual(approver?.id, author.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() cleans references after update failures", async () => { class FailingUpdateRepository extends MemoryRepository { override updateMessage( @@ -4041,6 +4118,78 @@ test("BotImpl.onDeleted() keeps references after update failures", async () => { assert.deepStrictEqual(ctx.sentActivities.length, 0); }); +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 = createMockInboxContext(bot, "https://example.com", "bot"); + + assert.deepStrictEqual( + await bot.dispatchQuoteRequest(ctx as unknown as RequestContext, { + 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.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 c991cd1..183da0f 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -57,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, @@ -123,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; @@ -569,6 +578,7 @@ export class BotImpl implements Bot { 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 object = await stored.getObject(ctx); @@ -664,9 +674,10 @@ export class BotImpl implements Bot { ); if ( parsedObj?.type === "object" && parsedObj.class === QuoteRequest && - parsedObj.values.identifier === this.identifier + parsedObj.values.identifier === this.identifier && + isUuid(parsedObj.values.id) ) { - await this.#onQuoteAccepted(ctx, accept, parsedObj.values.id as Uuid); + await this.#onQuoteAccepted(ctx, accept, parsedObj.values.id); return; } if ( @@ -704,9 +715,10 @@ export class BotImpl implements Bot { ); if ( parsedObj?.type === "object" && parsedObj.class === QuoteRequest && - parsedObj.values.identifier === this.identifier + parsedObj.values.identifier === this.identifier && + isUuid(parsedObj.values.id) ) { - await this.#onQuoteRejected(ctx, reject, parsedObj.values.id as Uuid); + await this.#onQuoteRejected(ctx, reject, parsedObj.values.id); return; } if ( @@ -759,9 +771,16 @@ export class BotImpl implements Bot { try { await this.repository.updateMessage(id, () => Promise.resolve(updated)); } catch (error) { - await this.repository.removeQuoteAuthorizationReference( - approval.authorization.id!, - ); + 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 }, + ); + } throw error; } await this.#sendQuoteUpdate(ctx, updatedObject, approval.actor); @@ -998,6 +1017,24 @@ export class BotImpl implements Bot { { preferSharedInbox, excludeBaseUris }, ); } + if (object.replyTargetId != null) { + const replyTarget = await lookupObjectSafely(ctx, object.replyTargetId); + if (isMessageObject(replyTarget)) { + const replyActor = await replyTarget.getAttribution({ + contextLoader: ctx.contextLoader, + documentLoader: ctx.documentLoader, + suppressError: true, + }); + if (isActor(replyActor)) { + await ctx.sendActivity( + this, + replyActor, + update, + { preferSharedInbox, excludeBaseUris, fanout: "skip" }, + ); + } + } + } await ctx.sendActivity( this, quoteActor, From 8bb71d607ce90efc3f69a5d50cb56d5a9f7065fc Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 22:01:25 +0900 Subject: [PATCH 05/12] Keep quote revocations durable Quote authorization deletions now rely on the stored authorization stamp reference instead of refetching the original quote target. This lets a revocation strip the local quote even after the quoted object disappears or becomes inaccessible. Quote update fanout also resolves mentioned actors in parallel, avoiding sequential lookup latency for posts with multiple mentions. https://github.com/fedify-dev/botkit/pull/32#discussion_r3529030756 https://github.com/fedify-dev/botkit/pull/32#discussion_r3529050152 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 85 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 20 +++---- 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 4fa3066..90b6f5a 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4020,6 +4020,91 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { assert.deepStrictEqual(rejecter?.id, author.id); }); +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; diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 183da0f..d2ccf54 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -953,7 +953,9 @@ export class BotImpl implements Bot { del: Delete, object: MessageClass, ): Promise { - if (del.actorId == null || object.quoteId == null) return undefined; + if (del.actorId == null || object.quoteAuthorizationId == null) { + return undefined; + } const actor = await del.getActor({ contextLoader: ctx.contextLoader, documentLoader: ctx.documentLoader, @@ -961,15 +963,11 @@ export class BotImpl implements Bot { }); if ( !isActor(actor) || actor.id == null || - actor.id.href !== del.actorId.href + actor.id.href !== del.actorId.href || + object.quoteAuthorizationId.origin !== actor.id.origin ) { return undefined; } - const target = await lookupObjectSafely(ctx, object.quoteId); - if ( - !isMessageObject(target) || - target.attributionId?.href !== actor.id.href - ) return undefined; return actor; } @@ -997,7 +995,7 @@ export class BotImpl implements Bot { { preferSharedInbox, excludeBaseUris }, ); } - const mentionedActors: Actor[] = []; + const mentionUris: URL[] = []; for await ( const tag of object.getTags({ contextLoader: ctx.contextLoader, @@ -1006,9 +1004,11 @@ export class BotImpl implements Bot { }) ) { if (!(tag instanceof Mention) || tag.href == null) continue; - const mentioned = await lookupObjectSafely(ctx, tag.href); - if (isActor(mentioned)) mentionedActors.push(mentioned); + mentionUris.push(tag.href); } + const mentionedActors = (await Promise.all( + mentionUris.map((uri) => lookupObjectSafely(ctx, uri)), + )).filter(isActor); if (mentionedActors.length > 0) { await ctx.sendActivity( this, From f0b7956d320d13e95d201993b968e636e8522a2a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 22:55:31 +0900 Subject: [PATCH 06/12] Hide private quote requests Quote request dispatch now reuses the message visibility checker before it reconstructs the FEP-044f request object. This keeps followers-only and direct quote metadata from being exposed through a leaked request URL. The malformed ID regression test now uses a real RequestContext, so it no longer needs an unsafe double assertion around the mock inbox context. https://github.com/fedify-dev/botkit/pull/32#discussion_r3529108531 https://github.com/fedify-dev/botkit/pull/32#discussion_r3529123986 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 53 ++++++++++++++++++++++++++-- packages/botkit/src/bot-impl.ts | 2 ++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 90b6f5a..2b2ef58 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -13,7 +13,7 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import type { RequestContext, UnverifiedActivityReason } from "@fedify/fedify"; +import type { UnverifiedActivityReason } from "@fedify/fedify"; import { type InboxContext, MemoryKvStore } from "@fedify/fedify/federation"; import { Accept, @@ -4223,10 +4223,13 @@ test("BotImpl ignores malformed quote request IDs", async (t) => { repository, username: "bot", }); - const ctx = createMockInboxContext(bot, "https://example.com", "bot"); + const ctx = bot.federation.createContext( + new Request("https://example.com/"), + undefined, + ); assert.deepStrictEqual( - await bot.dispatchQuoteRequest(ctx as unknown as RequestContext, { + await bot.dispatchQuoteRequest(ctx, { identifier: "bot", id: "not-a-uuid", }), @@ -4275,6 +4278,50 @@ test("BotImpl ignores malformed quote request IDs", async (t) => { }); }); +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 d2ccf54..d3ad29d 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -581,6 +581,8 @@ export class BotImpl implements Bot { 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 From a0af94bcf47d4cf7a28ded9b76d21534b04139e7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 23:10:03 +0900 Subject: [PATCH 07/12] Sign quote response lookups Quote approval and rejection validation needs the bot-authenticated loader when it fetches the quoted target. Otherwise a followers-only or direct target can be invisible to the unsigned inbox document loader, causing a valid response to be ignored. https://github.com/fedify-dev/botkit/pull/32#discussion_r3529469075 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 76 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 18 ++++--- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 2b2ef58..7e741ec 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3685,6 +3685,82 @@ test("BotImpl.onFollowAccepted() accepts quote approvals", async () => { 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({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index d3ad29d..9907e10 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -902,7 +902,7 @@ export class BotImpl implements Bot { !isActor(actor) || actor.id == null || actor.id.href !== accept.actorId.href ) return undefined; - const target = await lookupObjectSafely(ctx, object.quoteId!); + const target = await lookupObjectSafely(this, ctx, object.quoteId!); if ( !isMessageObject(target) || target.attributionId?.href !== actor.id.href @@ -913,7 +913,7 @@ export class BotImpl implements Bot { suppressError: true, }); if (authorization == null) { - authorization = await lookupObjectSafely(ctx, accept.resultId); + authorization = await lookupObjectSafely(this, ctx, accept.resultId); } if ( !(authorization instanceof QuoteAuthorization) || @@ -942,7 +942,7 @@ export class BotImpl implements Bot { !isActor(actor) || actor.id == null || actor.id.href !== reject.actorId.href ) return undefined; - const target = await lookupObjectSafely(ctx, object.quoteId!); + const target = await lookupObjectSafely(this, ctx, object.quoteId!); if ( !isMessageObject(target) || target.attributionId?.href !== actor.id.href @@ -1009,7 +1009,7 @@ export class BotImpl implements Bot { mentionUris.push(tag.href); } const mentionedActors = (await Promise.all( - mentionUris.map((uri) => lookupObjectSafely(ctx, uri)), + mentionUris.map((uri) => lookupObjectSafely(this, ctx, uri)), )).filter(isActor); if (mentionedActors.length > 0) { await ctx.sendActivity( @@ -1020,7 +1020,11 @@ export class BotImpl implements Bot { ); } if (object.replyTargetId != null) { - const replyTarget = await lookupObjectSafely(ctx, object.replyTargetId); + const replyTarget = await lookupObjectSafely( + this, + ctx, + object.replyTargetId, + ); if (isMessageObject(replyTarget)) { const replyActor = await replyTarget.getAttribution({ contextLoader: ctx.contextLoader, @@ -1964,13 +1968,15 @@ function isRecord(value: unknown): value is Record { } 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: ctx.documentLoader, + documentLoader, }); } catch { return null; From 8cf429f410dc673492021680615ebec33520d03c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 23:27:54 +0900 Subject: [PATCH 08/12] Rollback missing quote updates Quote approval writes can lose a race with message deletion. When the repository reports that the message was not updated, the authorization reference needs the same cleanup as thrown update failures, and the approval update should not be delivered. https://github.com/fedify-dev/botkit/pull/32#discussion_r3529580491 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 86 ++++++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 16 +++++- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 7e741ec..8ab9e79 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3926,6 +3926,92 @@ test("BotImpl.onFollowAccepted() cleans references after update failures", async 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({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 9907e10..74c4476 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -770,9 +770,7 @@ export class BotImpl implements Bot { approval.authorization.id!, id, ); - try { - await this.repository.updateMessage(id, () => Promise.resolve(updated)); - } catch (error) { + const removeReference = async () => { try { await this.repository.removeQuoteAuthorizationReference( approval.authorization.id!, @@ -783,6 +781,18 @@ export class BotImpl implements Bot { { error: cleanupError }, ); } + }; + try { + const wasUpdated = await this.repository.updateMessage( + id, + () => Promise.resolve(updated), + ); + if (!wasUpdated) { + await removeReference(); + return; + } + } catch (error) { + await removeReference(); throw error; } await this.#sendQuoteUpdate(ctx, updatedObject, approval.actor); From 3809a7f942e603b19790ef7af31370df08de2e9a Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 6 Jul 2026 23:41:22 +0900 Subject: [PATCH 09/12] Harden quote revocation handling Quote revocation updates now treat a missing message update as a failed write path: the stale authorization reference is removed, but no update activity or callback is emitted for a message that was not changed. Received quote authorization references also retain the approving actor so a later Delete must come from the actor that issued the stamp, not just from another actor on the same origin. https://github.com/fedify-dev/botkit/pull/32#discussion_r3529663246 https://github.com/fedify-dev/botkit/pull/32#discussion_r3529671943 Assisted-by: Codex:gpt-5.5 --- packages/botkit-postgres/src/mod.ts | 39 ++++- packages/botkit-sqlite/src/mod.ts | 46 ++++- packages/botkit/src/bot-impl.test.ts | 174 +++++++++++++++++++ packages/botkit/src/bot-impl.ts | 28 ++- packages/botkit/src/instance-routing.test.ts | 1 + packages/botkit/src/repository.ts | 135 +++++++++++++- 6 files changed, 404 insertions(+), 19 deletions(-) diff --git a/packages/botkit-postgres/src/mod.ts b/packages/botkit-postgres/src/mod.ts index aa036fc..2914bfb 100644 --- a/packages/botkit-postgres/src/mod.ts +++ b/packages/botkit-postgres/src/mod.ts @@ -291,11 +291,19 @@ async function initializePostgresRepositorySchemaInTransaction( 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" ( @@ -1109,16 +1117,18 @@ export class PostgresRepository implements Repository, AsyncDisposable { 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) - VALUES ($1, $2, $3) + (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`, - [identifier, authorization.href, messageId], + SET message_id = EXCLUDED.message_id, + attribution_uri = EXCLUDED.attribution_uri`, + [identifier, authorization.href, messageId, attribution?.href ?? null], ); } @@ -1137,6 +1147,27 @@ export class PostgresRepository implements Repository, AsyncDisposable { 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, diff --git a/packages/botkit-sqlite/src/mod.ts b/packages/botkit-sqlite/src/mod.ts index e57478d..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 @@ -444,9 +453,15 @@ export class SqliteRepository implements Repository, Disposable { 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(` @@ -1082,13 +1097,19 @@ export class SqliteRepository implements Repository, Disposable { 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) - VALUES (?, ?, ?) + (bot_id, authorization, message_id, attribution) + VALUES (?, ?, ?, ?) `); - stmt.run(identifier, authorization.href, messageId); + stmt.run( + identifier, + authorization.href, + messageId, + attribution?.href ?? null, + ); return Promise.resolve(); } @@ -1106,6 +1127,25 @@ export class SqliteRepository implements Repository, Disposable { 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, diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 8ab9e79..e30f720 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4182,6 +4182,82 @@ test("BotImpl.onDeleted() strips revoked quote authorizations", async () => { 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({ @@ -4365,6 +4441,104 @@ test("BotImpl.onDeleted() keeps references after update failures", async () => { 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( diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 74c4476..e34cca9 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -769,6 +769,7 @@ export class BotImpl implements Bot { await this.repository.addQuoteAuthorizationReference( approval.authorization.id!, id, + approval.actor.id!, ); const removeReference = async () => { try { @@ -870,12 +871,16 @@ export class BotImpl implements Bot { updated: Temporal.Now.instant(), }); const updated = stored.clone({ object: strippedObject }); - await this.repository.updateMessage(id, () => Promise.resolve(updated)); + const wasUpdated = await this.repository.updateMessage( + id, + () => Promise.resolve(updated), + ); if (object.quoteAuthorizationId != null) { await this.repository.removeQuoteAuthorizationReference( object.quoteAuthorizationId, ); } + if (!wasUpdated) return; await this.#sendQuoteUpdate(ctx, strippedObject, actor); if (this.onQuoteRejected != null) { const session = this.getSession(ctx); @@ -975,11 +980,15 @@ export class BotImpl implements Bot { }); if ( !isActor(actor) || actor.id == null || - actor.id.href !== del.actorId.href || - object.quoteAuthorizationId.origin !== actor.id.origin + 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; } @@ -2351,12 +2360,14 @@ export class MigrationGatedRepository implements Repository { identifier: string, authorization: URL, messageId: Uuid, + attribution?: URL, ): Promise { await this.#migration; return await this.#repository.addQuoteAuthorizationReference( identifier, authorization, messageId, + attribution, ); } @@ -2371,6 +2382,17 @@ export class MigrationGatedRepository implements Repository { ); } + async findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + await this.#migration; + return await this.#repository.findQuoteAuthorizationReferenceAttribution( + identifier, + authorization, + ); + } + async removeQuoteAuthorizationReference( identifier: string, authorization: URL, diff --git a/packages/botkit/src/instance-routing.test.ts b/packages/botkit/src/instance-routing.test.ts index a236ced..ffb8fe5 100644 --- a/packages/botkit/src/instance-routing.test.ts +++ b/packages/botkit/src/instance-routing.test.ts @@ -533,6 +533,7 @@ describe("shared inbox routing", () => { "alpha", authorization, messageId, + author.id!, ); await instance.onDeleted( diff --git a/packages/botkit/src/repository.ts b/packages/botkit/src/repository.ts index 9d60030..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. * @@ -367,12 +372,14 @@ export interface Repository { * @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; /** @@ -388,6 +395,18 @@ export interface Repository { 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. @@ -780,16 +799,19 @@ export class ActorScopedRepository { * 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, ); } @@ -808,6 +830,21 @@ export class ActorScopedRepository { ); } + /** + * 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. @@ -1786,10 +1823,13 @@ export class KvRepository implements Repository { identifier: string, authorization: URL, messageId: Uuid, + attribution?: URL, ): Promise { await this.kv.set( this.#quoteAuthorizationReferenceKey(identifier, authorization), - messageId, + attribution == null + ? messageId + : { messageId, attribution: attribution.href }, ); } @@ -1797,9 +1837,27 @@ export class KvRepository implements Repository { identifier: string, authorization: URL, ): Promise { - return await this.kv.get( + 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( @@ -1980,6 +2038,7 @@ interface MemoryActorData { quoteAuthorizations: Map; quoteAuthorizationsByInteractingObject: Map; quoteAuthorizationRefs: Map; + quoteAuthorizationRefAttributions: Map; polls: Record>>; } @@ -2002,6 +2061,7 @@ export class MemoryRepository implements Repository { quoteAuthorizations: new Map(), quoteAuthorizationsByInteractingObject: new Map(), quoteAuthorizationRefs: new Map(), + quoteAuthorizationRefAttributions: new Map(), polls: {}, }; this.#data.set(identifier, data); @@ -2294,11 +2354,18 @@ export class MemoryRepository implements Repository { identifier: string, authorization: URL, messageId: Uuid, + attribution?: URL, ): Promise { - this.#bucket(identifier).quoteAuthorizationRefs.set( - authorization.href, - messageId, - ); + 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(); } @@ -2313,13 +2380,24 @@ export class MemoryRepository implements Repository { ); } + findQuoteAuthorizationReferenceAttribution( + identifier: string, + authorization: URL, + ): Promise { + return Promise.resolve( + this.#data.get(identifier)?.quoteAuthorizationRefAttributions.get( + authorization.href, + ), + ); + } + removeQuoteAuthorizationReference( identifier: string, authorization: URL, ): Promise { - this.#data.get(identifier)?.quoteAuthorizationRefs.delete( - authorization.href, - ); + const data = this.#data.get(identifier); + data?.quoteAuthorizationRefs.delete(authorization.href); + data?.quoteAuthorizationRefAttributions.delete(authorization.href); return Promise.resolve(); } @@ -2666,16 +2744,19 @@ export class MemoryCachedRepository implements Repository { 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, ); } @@ -2693,15 +2774,51 @@ export class MemoryCachedRepository implements Repository { 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, From 1bae6002267d761ca3b34f3ab842263faf7dfd20 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 00:02:18 +0900 Subject: [PATCH 10/12] Let quote authors fetch instruments Private quote requests point the quoted author at the newly published instrument, so the stored note has to authorize that actor to fetch it. Add the quoted author to followers-only and direct quote audiences unless that actor is already included by a mention. https://github.com/fedify-dev/botkit/pull/32#discussion_r3529773132 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/session-impl.test.ts | 33 ++++++++++++++++++++++++ packages/botkit/src/session-impl.ts | 15 ++++++++--- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/botkit/src/session-impl.test.ts b/packages/botkit/src/session-impl.test.ts index df79366..e53818b 100644 --- a/packages/botkit/src/session-impl.test.ts +++ b/packages/botkit/src/session-impl.test.ts @@ -554,6 +554,39 @@ 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("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 14568a4..abdf0c1 100644 --- a/packages/botkit/src/session-impl.ts +++ b/packages/botkit/src/session-impl.ts @@ -288,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; @@ -327,10 +335,10 @@ export class SessionImpl implements Session { 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, @@ -342,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" From 2561994ef75bd4bdccd5c177bba090f5c39bed9e Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 00:18:43 +0900 Subject: [PATCH 11/12] Preserve quote update state Quote approvals now stamp the latest stored message instead of an earlier snapshot, so concurrent edits cannot be rolled back. Quote update delivery also tracks addressed actors to avoid sending the same Update more than once when quote, reply, and mention recipients overlap. Direct private quotes treat the quoted author as an intentional direct recipient and keep that author in the audience when the quote is edited. https://github.com/fedify-dev/botkit/pull/32#discussion_r3529915996 https://github.com/fedify-dev/botkit/pull/32#discussion_r3529916006 https://github.com/fedify-dev/botkit/pull/32#discussion_r3529946049 https://github.com/fedify-dev/botkit/pull/32#discussion_r3529946057 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 172 +++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 65 +++++---- packages/botkit/src/message-impl.ts | 15 +- packages/botkit/src/session-impl.test.ts | 42 +++++- 4 files changed, 267 insertions(+), 27 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index e30f720..919784c 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -3838,6 +3838,178 @@ test("BotImpl.onFollowAccepted() sends quote updates to reply targets", async () 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( diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index e34cca9..7fcb14d 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -759,18 +759,15 @@ export class BotImpl implements Bot { ) { return; } + const quoteId = object.quoteId; const approval = await this.#validateQuoteApproval(ctx, accept, object); if (approval == null) return; - const updatedObject = object.clone({ - quoteAuthorization: approval.authorization.id, - updated: Temporal.Now.instant(), - }); - const updated = stored.clone({ object: updatedObject }); await this.repository.addQuoteAuthorizationReference( approval.authorization.id!, id, approval.actor.id!, ); + let updatedObject: MessageClass | undefined; const removeReference = async () => { try { await this.repository.removeQuoteAuthorizationReference( @@ -786,9 +783,23 @@ export class BotImpl implements Bot { try { const wasUpdated = await this.repository.updateMessage( id, - () => Promise.resolve(updated), + 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) { + if (!wasUpdated || updatedObject == null) { await removeReference(); return; } @@ -1030,13 +1041,31 @@ export class BotImpl implements Bot { const mentionedActors = (await Promise.all( mentionUris.map((uri) => lookupObjectSafely(this, ctx, uri)), )).filter(isActor); - if (mentionedActors.length > 0) { + 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, - mentionedActors, + actor, update, - { preferSharedInbox, excludeBaseUris }, + { 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( @@ -1050,22 +1079,10 @@ export class BotImpl implements Bot { documentLoader: ctx.documentLoader, suppressError: true, }); - if (isActor(replyActor)) { - await ctx.sendActivity( - this, - replyActor, - update, - { preferSharedInbox, excludeBaseUris, fanout: "skip" }, - ); - } + if (isActor(replyActor)) await sendActorOnce(replyActor); } } - await ctx.sendActivity( - this, - quoteActor, - update, - { preferSharedInbox, excludeBaseUris, fanout: "skip" }, - ); + await sendActorOnce(quoteActor); } async onQuoteRequested( diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index 25e4356..df9ca52 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -473,6 +473,12 @@ export class AuthorizedMessageImpl this.hashtags = hashtags; const updated = Temporal.Now.instant(); this.updated = updated; + const quoteTargetActorId = this.quoteTarget?.actor.id; + const privateQuoteAudienceIds = quoteTargetActorId != null && + (this.visibility === "followers" || this.visibility === "direct") && + !mentionedActorIds.some((id) => id.href === quoteTargetActorId.href) + ? [quoteTargetActorId] + : []; const newMessage = message.clone({ contents: this.language == null ? [contentHtml] @@ -484,8 +490,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), @@ -905,6 +912,10 @@ export async function createMessage( : 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, @@ -913,7 +924,7 @@ export async function createMessage( raw.toIds, raw.ccIds, actor, - mentionedActorIds, + directRecipientIds, ), language: raw.content instanceof LanguageString ? raw.content.locale diff --git a/packages/botkit/src/session-impl.test.ts b/packages/botkit/src/session-impl.test.ts index e53818b..bae83f1 100644 --- a/packages/botkit/src/session-impl.test.ts +++ b/packages/botkit/src/session-impl.test.ts @@ -312,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); @@ -587,6 +588,45 @@ test("SessionImpl.publish()", async (t) => { } }); + 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("poll single choice", async () => { ctx.sentActivities = []; const endTime = Temporal.Now.instant().add({ hours: 24 }); From a93dd0db85ffd0c63fc2a3da8c47389e4be0e780 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Tue, 7 Jul 2026 00:35:04 +0900 Subject: [PATCH 12/12] Keep quote strip updates current Quote rejection and revocation now strip quote fields from the latest stored Create instead of an earlier snapshot, so concurrent local edits are preserved while the quote is removed. Private quote updates also keep the publish-time self-quote audience rule, avoiding a redundant self actor recipient when a bot edits its own quote. https://github.com/fedify-dev/botkit/pull/32#discussion_r3530037043 https://github.com/fedify-dev/botkit/pull/32#discussion_r3530044792 https://github.com/fedify-dev/botkit/pull/32#discussion_r3530071746 Assisted-by: Codex:gpt-5.5 --- packages/botkit/src/bot-impl.test.ts | 89 ++++++++++++++++++++++++ packages/botkit/src/bot-impl.ts | 28 +++++--- packages/botkit/src/message-impl.ts | 1 + packages/botkit/src/session-impl.test.ts | 23 ++++++ 4 files changed, 132 insertions(+), 9 deletions(-) diff --git a/packages/botkit/src/bot-impl.test.ts b/packages/botkit/src/bot-impl.test.ts index 919784c..4de21ee 100644 --- a/packages/botkit/src/bot-impl.test.ts +++ b/packages/botkit/src/bot-impl.test.ts @@ -4256,6 +4256,95 @@ test("BotImpl.onFollowRejected() strips rejected quotes", async () => { 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({ diff --git a/packages/botkit/src/bot-impl.ts b/packages/botkit/src/bot-impl.ts index 7fcb14d..593e16d 100644 --- a/packages/botkit/src/bot-impl.ts +++ b/packages/botkit/src/bot-impl.ts @@ -837,7 +837,7 @@ export class BotImpl implements Bot { } const rejecter = await this.#validateQuoteRejection(ctx, reject, object); if (rejecter == null) return; - await this.#stripRejectedQuote(ctx, id, stored, object, rejecter); + await this.#stripRejectedQuote(ctx, id, object, rejecter); } async onDeleted( @@ -868,30 +868,40 @@ export class BotImpl implements Bot { object, ); if (actor == null) return; - await this.#stripRejectedQuote(ctx, id, stored, object, actor); + await this.#stripRejectedQuote(ctx, id, object, actor); } async #stripRejectedQuote( ctx: InboxContext, id: Uuid, - stored: Create, object: MessageClass, actor: Actor, ): Promise { - const strippedObject = (await stripQuoteObject(object)).clone({ - updated: Temporal.Now.instant(), - }); - const updated = stored.clone({ object: strippedObject }); + const quoteId = object.quoteId; + let strippedObject: MessageClass | undefined; const wasUpdated = await this.repository.updateMessage( id, - () => Promise.resolve(updated), + 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) return; + if (!wasUpdated || strippedObject == null) return; await this.#sendQuoteUpdate(ctx, strippedObject, actor); if (this.onQuoteRejected != null) { const session = this.getSession(ctx); diff --git a/packages/botkit/src/message-impl.ts b/packages/botkit/src/message-impl.ts index df9ca52..4404e69 100644 --- a/packages/botkit/src/message-impl.ts +++ b/packages/botkit/src/message-impl.ts @@ -475,6 +475,7 @@ export class AuthorizedMessageImpl 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] diff --git a/packages/botkit/src/session-impl.test.ts b/packages/botkit/src/session-impl.test.ts index bae83f1..e6b91f8 100644 --- a/packages/botkit/src/session-impl.test.ts +++ b/packages/botkit/src/session-impl.test.ts @@ -627,6 +627,29 @@ test("SessionImpl.publish()", async (t) => { 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 });