Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
43728f3
Implement inbound quote authorization
dahlia Jul 5, 2026
8f1bf33
Require transactional schema init
dahlia Jul 5, 2026
f4dabfa
Gate quote requests by target visibility
dahlia Jul 5, 2026
30aa4e4
Serialize KV quote stamp writes
dahlia Jul 5, 2026
12bb347
Document quote request review polish
dahlia Jul 5, 2026
d5ef282
Reject wider-audience quote requests
dahlia Jul 5, 2026
2411c63
Treat unknown quote visibility defensively
dahlia Jul 5, 2026
2a71388
Harden quote request validation
dahlia Jul 6, 2026
01f759f
Harden quote request edge cases
dahlia Jul 6, 2026
22fd902
Constrain quote authorization scope
dahlia Jul 6, 2026
9141de1
Allow quote stamps for target authors
dahlia Jul 6, 2026
6afdd9f
Honor FEP quote targets
dahlia Jul 6, 2026
f14bdf4
Revoke stale quote stamps
dahlia Jul 6, 2026
a563113
Remove KV quote stamps before parsing
dahlia Jul 6, 2026
44cd215
Preserve FEP quote handling
dahlia Jul 6, 2026
559b978
Check direct quote recipients
dahlia Jul 6, 2026
0851c4f
Permit follower quote recipients
dahlia Jul 6, 2026
b9213cd
Validate inbound quote stamps
dahlia Jul 6, 2026
7ac74aa
Require stamps for FEP fallback quotes
dahlia Jul 6, 2026
8735b98
Route FEP quote creates
dahlia Jul 6, 2026
59e1e31
Prefer FEP quote targets
dahlia Jul 6, 2026
6858ba4
Recheck quote delivery audiences
dahlia Jul 6, 2026
ecd35ca
Recover stale quote stamp indexes
dahlia Jul 6, 2026
3aba861
Block unauthorized FEP quote delivery
dahlia Jul 6, 2026
05031e0
Honor quote approvals on updates
dahlia Jul 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,30 @@ To be released.
deployments and preserves their behavior, including the web pages served
at the root.

- Added inbound support for consent-respecting quote posts using
[FEP-044f]. [[#27]] [[#28]] [[#31]]

BotKit now serializes quote policies on outgoing messages, handles
incoming `QuoteRequest` activities, automatically accepts or rejects them
according to each message's policy, and stores `QuoteAuthorization` stamps
for accepted quotes. Applications can set a default
`CreateBotOptions.quotePolicy`, override it per message with
`Session.publish()` or `AuthorizedMessage.update()`, and moderate pending
requests with the new `Bot.onQuoteRequest` event handler.

- Added `QuotePolicy`, `QuotePolicyOption`, `QuoteRequest`, and
`QuoteRequestEventHandler` types. [[#27]] [[#28]] [[#31]]
- Added `Bot.onQuoteRequest` event handler. [[#27]] [[#28]] [[#31]]
- Added `ReadonlyBot.quotePolicy`, `CreateBotOptions.quotePolicy`, and
`BotProfile.quotePolicy` properties. [[#27]] [[#28]] [[#31]]
- Added `SessionPublishOptions.quotePolicy` and
`AuthorizedMessageUpdateOptions.quotePolicy` options. [[#27]]
[[#28]] [[#31]]
- Added `AuthorizedMessage.unauthorizeQuote()` method for revoking an
existing quote authorization stamp by the quoted message or its URI.
[[#27]] [[#28]] [[#31]]
- Added `@fedify/botkit/quote` module. [[#27]] [[#28]] [[#31]]

Comment thread
dahlia marked this conversation as resolved.
- The `Repository` interface now stores data for multiple bot actors:
every method takes the identifier of the owning bot actor as its first
parameter, and data belonging to different identifiers are isolated from
Expand All @@ -56,6 +80,11 @@ To be released.
- Added `identifier` parameter to all `Repository` methods.
- Added `Repository.findFollowedBots()` method, a reverse lookup
answering which bots follow a given actor.
- Added quote authorization storage methods:
`Repository.addQuoteAuthorization()`,
`Repository.getQuoteAuthorization()`,
`Repository.findQuoteAuthorization()`, and
`Repository.removeQuoteAuthorization()`.
- Added optional `Repository.migrate()` method for adopting data
stored by BotKit 0.4 or earlier.
- Added `Repository.forIdentifier()` method and `ActorScopedRepository`
Expand Down Expand Up @@ -85,11 +114,18 @@ To be released.
- Upgraded Fedify to 2.3.1, Hono to 4.12.27, LogTape to 2.2.3,
and Markdown It to 14.3.0.

[FEP-044f]: https://w3id.org/fep/044f
[#16]: https://github.com/fedify-dev/botkit/issues/16
[#24]: https://github.com/fedify-dev/botkit/pull/24
[#27]: https://github.com/fedify-dev/botkit/issues/27
[#28]: https://github.com/fedify-dev/botkit/issues/28
[#31]: https://github.com/fedify-dev/botkit/pull/31

### @fedify/botkit-sqlite

- Added a `quote_authorizations` table for [FEP-044f] quote authorization
stamps. [[#27]] [[#28]] [[#31]]

- All tables now have a `bot_id` column and composite primary keys, so
a single database stores the data of multiple bots. Opening a database
created by version 0.4 or earlier rebuilds the affected tables in place,
Expand All @@ -101,6 +137,9 @@ To be released.

### @fedify/botkit-postgres

- Added a `quote_authorizations` table for [FEP-044f] quote authorization
stamps. [[#27]] [[#28]] [[#31]]

- All tables now have a `bot_id` column and composite primary keys, so
a single schema stores the data of multiple bots. Initializing a schema
created by version 0.4 upgrades it in place, and
Expand Down
1 change: 1 addition & 0 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions docs/concepts/bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,51 @@ It can be changed after the bot is federated.
> or `FollowRequest.reject()` in the [`Bot.onFollow`](./events.md#follow) event
> handler.

### `~CreateBotOptions.quotePolicy`

Who can quote messages published by the bot by default. BotKit serializes the
policy into each outgoing message and uses it as the fallback for older stored
messages that do not have a quote policy yet.

The shorthand values are:

`"public"` (default)
: Accept every quote request automatically.

`"followers"`
: Accept quote requests from followers automatically.

`"manual"`
: Leave every quote request pending for the
[`Bot.onQuoteRequest`](./events.md#quote-request) event handler.

`"nobody"`
: Reject every quote request except the bot's own requests.

You can also use the full two-axis policy object:

~~~~ typescript twoslash
import type { KvStore } from "@fedify/fedify";

declare const kv: KvStore;
// ---cut-before---
import { createBot } from "@fedify/botkit";

const bot = createBot({
username: "mybot",
name: "My Bot",
kv,
quotePolicy: {
automatic: "followers",
manual: "public",
},
});
~~~~

This accepts followers automatically and sends other quote requests to
`Bot.onQuoteRequest` for manual moderation. A publish call can override the
default with `Session.publish()`'s `quotePolicy` option.

### `~CreateBotOptions.queue`

The message queue for handling incoming and outgoing activities. If omitted,
Expand Down
48 changes: 48 additions & 0 deletions docs/concepts/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,54 @@ property. See also the [*Quotes* section](./message.md#quotes) in the *Message*
concept document.


Quote request
-------------

*This API is available since BotKit 0.5.0.*

The `~Bot.onQuoteRequest` event handler is called when a remote actor asks
permission to quote one of your bot's messages through [FEP-044f]. It
receives a `QuoteRequest` object, whose `quote` property is the remote quote
post and whose `target` property is the bot's quoted message.

If the message's quote policy automatically accepts or rejects the request,
BotKit sends the response before the event handler is called. The handler can
inspect `~QuoteRequest.state` to see whether the request is still pending:

~~~~ typescript twoslash
import { type Bot } from "@fedify/botkit";
const bot = {} as unknown as Bot<void>;
// ---cut-before---
bot.onQuoteRequest = async (session, request) => {
if (request.state === "pending") {
await request.accept();
}
};
~~~~

If you later need to revoke a quote authorization, call
`~AuthorizedMessage.unauthorizeQuote()` on the target message with the quote
message:

~~~~ typescript twoslash
import { type Bot } from "@fedify/botkit";
const bot = {} as unknown as Bot<void>;
// ---cut-before---
bot.onQuoteRequest = async (session, request) => {
if (request.state === "accepted") {
await request.target.unauthorizeQuote(request.quote);
}
};
~~~~

Use the `quotePolicy` option on `createBot()` or `Session.publish()` to choose
which quote requests are automatic and which require manual review. For
details, see the [*Quote policy* section](./message.md#quote-policy) in
the *Message* concept document.

[FEP-044f]: https://w3id.org/fep/044f


Message
-------

Expand Down
67 changes: 67 additions & 0 deletions docs/concepts/message.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,73 @@ await session.publish(text`Hello, ${mention("@fedify@hollo.social")}!`, {
});
~~~~

### Quote policy

You can control who may quote a message by providing
`~SessionPublishOptions.quotePolicy`. BotKit serializes this policy as
the [FEP-044f] interaction policy on the ActivityPub object and uses it when
answering incoming quote requests.

The shorthand values are:

`"public"`
: Anyone may quote the message. This is the default.

`"followers"`
: Followers may quote the message automatically.

`"nobody"`
: Nobody may quote the message, except the bot itself.

`"manual"`
: Quote requests are left pending for `~Bot.onQuoteRequest`.

~~~~ typescript twoslash
import { type Session, text } from "@fedify/botkit";
const session = {} as unknown as Session<void>;
// ---cut-before---
await session.publish(text`Followers can quote this.`, {
quotePolicy: "followers",
});
~~~~

You can also use the two-axis form to distinguish actors whose quotes are
approved automatically from actors whose quotes await manual review:

~~~~ typescript twoslash
import { type Session, text } from "@fedify/botkit";
const session = {} as unknown as Session<void>;
// ---cut-before---
await session.publish(text`Followers are reviewed before quoting this.`, {
quotePolicy: { manual: "followers" },
});
~~~~

The quote policy can be changed when editing a message:

~~~~ typescript twoslash
import { type Session, text } from "@fedify/botkit";
const session = {} as unknown as Session<void>;
// ---cut-before---
const message = await session.publish(text`This starts public.`);
await message.update(text`This is now harder to quote.`, {
quotePolicy: "nobody",
});
~~~~

If a quote was already authorized through an incoming quote request, you can
revoke its authorization stamp from the quoted message:

~~~~ typescript twoslash
import { type AuthorizedMessage, type Message, type MessageClass } from "@fedify/botkit";
declare const message: AuthorizedMessage<MessageClass, void>;
declare const quote: Message<MessageClass, void>;
// ---cut-before---
await message.unauthorizeQuote(quote);
~~~~

[FEP-044f]: https://w3id.org/fep/044f

### Attaching media

You can attach media files to a message by providing
Expand Down
58 changes: 57 additions & 1 deletion packages/botkit-postgres/src/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ import {
PostgresRepository,
} from "@fedify/botkit-postgres";
import { importJwk } from "@fedify/fedify/sig";
import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab";
import {
Create,
Follow,
Note,
Person,
PUBLIC_COLLECTION,
QuoteAuthorization,
} from "@fedify/vocab";
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import postgres from "postgres";
Expand Down Expand Up @@ -129,6 +136,7 @@ if (postgresUrl == null) {
"key_pairs",
"messages",
"poll_votes",
"quote_authorizations",
"sent_follows",
],
);
Expand Down Expand Up @@ -211,6 +219,13 @@ if (postgresUrl == null) {
test("does not emit unhandled rejections for schema initialization", async () => {
const error = new Error("Schema initialization failed.");
const sql = {
begin: async (
callback: (
transactionSql: { unsafe: () => Promise<never> },
) => Promise<void>,
) => {
await callback(sql);
},
// deno-lint-ignore require-await
unsafe: async () => {
throw error;
Expand Down Expand Up @@ -1027,6 +1042,47 @@ if (postgresUrl == null) {
await sql.end();
}
});

test("keeps the first quote authorization for a quote", async () => {
const harness = createHarness();
try {
const repo = harness.repository;
const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4";
const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5";
const quote = new URL("https://remote.example/notes/quote");
const target = new URL("https://example.com/ap/note/1");
const first = new QuoteAuthorization({
id: new URL(
`https://example.com/ap/actor/bot/quote-authorization/${firstId}`,
),
attribution: new URL("https://example.com/ap/actor/bot"),
interactingObject: quote,
interactionTarget: target,
});
const second = new QuoteAuthorization({
id: new URL(
`https://example.com/ap/actor/bot/quote-authorization/${secondId}`,
),
attribution: new URL("https://example.com/ap/actor/bot"),
interactingObject: quote,
interactionTarget: target,
});

await repo.addQuoteAuthorization("bot", firstId, first);
await repo.addQuoteAuthorization("bot", secondId, second);

assert.deepStrictEqual(
(await repo.findQuoteAuthorization("bot", quote))?.id?.href,
first.id?.href,
);
assert.deepStrictEqual(
await repo.getQuoteAuthorization("bot", secondId),
undefined,
);
} finally {
await harness.cleanup();
}
});
});
}

Expand Down
Loading
Loading