Skip to content

Commit ef748db

Browse files
authored
Merge pull request #31 from dahlia/fep-044f/inbound
Inbound FEP-044f quote authorization
2 parents 6339841 + 05031e0 commit ef748db

33 files changed

Lines changed: 4095 additions & 24 deletions

CHANGES.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,30 @@ To be released.
4747
deployments and preserves their behavior, including the web pages served
4848
at the root.
4949

50+
- Added inbound support for consent-respecting quote posts using
51+
[FEP-044f]. [[#27]] [[#28]] [[#31]]
52+
53+
BotKit now serializes quote policies on outgoing messages, handles
54+
incoming `QuoteRequest` activities, automatically accepts or rejects them
55+
according to each message's policy, and stores `QuoteAuthorization` stamps
56+
for accepted quotes. Applications can set a default
57+
`CreateBotOptions.quotePolicy`, override it per message with
58+
`Session.publish()` or `AuthorizedMessage.update()`, and moderate pending
59+
requests with the new `Bot.onQuoteRequest` event handler.
60+
61+
- Added `QuotePolicy`, `QuotePolicyOption`, `QuoteRequest`, and
62+
`QuoteRequestEventHandler` types. [[#27]] [[#28]] [[#31]]
63+
- Added `Bot.onQuoteRequest` event handler. [[#27]] [[#28]] [[#31]]
64+
- Added `ReadonlyBot.quotePolicy`, `CreateBotOptions.quotePolicy`, and
65+
`BotProfile.quotePolicy` properties. [[#27]] [[#28]] [[#31]]
66+
- Added `SessionPublishOptions.quotePolicy` and
67+
`AuthorizedMessageUpdateOptions.quotePolicy` options. [[#27]]
68+
[[#28]] [[#31]]
69+
- Added `AuthorizedMessage.unauthorizeQuote()` method for revoking an
70+
existing quote authorization stamp by the quoted message or its URI.
71+
[[#27]] [[#28]] [[#31]]
72+
- Added `@fedify/botkit/quote` module. [[#27]] [[#28]] [[#31]]
73+
5074
- The `Repository` interface now stores data for multiple bot actors:
5175
every method takes the identifier of the owning bot actor as its first
5276
parameter, and data belonging to different identifiers are isolated from
@@ -56,6 +80,11 @@ To be released.
5680
- Added `identifier` parameter to all `Repository` methods.
5781
- Added `Repository.findFollowedBots()` method, a reverse lookup
5882
answering which bots follow a given actor.
83+
- Added quote authorization storage methods:
84+
`Repository.addQuoteAuthorization()`,
85+
`Repository.getQuoteAuthorization()`,
86+
`Repository.findQuoteAuthorization()`, and
87+
`Repository.removeQuoteAuthorization()`.
5988
- Added optional `Repository.migrate()` method for adopting data
6089
stored by BotKit 0.4 or earlier.
6190
- Added `Repository.forIdentifier()` method and `ActorScopedRepository`
@@ -85,11 +114,18 @@ To be released.
85114
- Upgraded Fedify to 2.3.1, Hono to 4.12.27, LogTape to 2.2.3,
86115
and Markdown It to 14.3.0.
87116

117+
[FEP-044f]: https://w3id.org/fep/044f
88118
[#16]: https://github.com/fedify-dev/botkit/issues/16
89119
[#24]: https://github.com/fedify-dev/botkit/pull/24
120+
[#27]: https://github.com/fedify-dev/botkit/issues/27
121+
[#28]: https://github.com/fedify-dev/botkit/issues/28
122+
[#31]: https://github.com/fedify-dev/botkit/pull/31
90123

91124
### @fedify/botkit-sqlite
92125

126+
- Added a `quote_authorizations` table for [FEP-044f] quote authorization
127+
stamps. [[#27]] [[#28]] [[#31]]
128+
93129
- All tables now have a `bot_id` column and composite primary keys, so
94130
a single database stores the data of multiple bots. Opening a database
95131
created by version 0.4 or earlier rebuilds the affected tables in place,
@@ -101,6 +137,9 @@ To be released.
101137

102138
### @fedify/botkit-postgres
103139

140+
- Added a `quote_authorizations` table for [FEP-044f] quote authorization
141+
stamps. [[#27]] [[#28]] [[#31]]
142+
104143
- All tables now have a `bot_id` column and composite primary keys, so
105144
a single schema stores the data of multiple bots. Initializing a schema
106145
created by version 0.4 upgrades it in place, and

deno.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/concepts/bot.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,51 @@ It can be changed after the bot is federated.
226226
> or `FollowRequest.reject()` in the [`Bot.onFollow`](./events.md#follow) event
227227
> handler.
228228
229+
### `~CreateBotOptions.quotePolicy`
230+
231+
Who can quote messages published by the bot by default. BotKit serializes the
232+
policy into each outgoing message and uses it as the fallback for older stored
233+
messages that do not have a quote policy yet.
234+
235+
The shorthand values are:
236+
237+
`"public"` (default)
238+
: Accept every quote request automatically.
239+
240+
`"followers"`
241+
: Accept quote requests from followers automatically.
242+
243+
`"manual"`
244+
: Leave every quote request pending for the
245+
[`Bot.onQuoteRequest`](./events.md#quote-request) event handler.
246+
247+
`"nobody"`
248+
: Reject every quote request except the bot's own requests.
249+
250+
You can also use the full two-axis policy object:
251+
252+
~~~~ typescript twoslash
253+
import type { KvStore } from "@fedify/fedify";
254+
255+
declare const kv: KvStore;
256+
// ---cut-before---
257+
import { createBot } from "@fedify/botkit";
258+
259+
const bot = createBot({
260+
username: "mybot",
261+
name: "My Bot",
262+
kv,
263+
quotePolicy: {
264+
automatic: "followers",
265+
manual: "public",
266+
},
267+
});
268+
~~~~
269+
270+
This accepts followers automatically and sends other quote requests to
271+
`Bot.onQuoteRequest` for manual moderation. A publish call can override the
272+
default with `Session.publish()`'s `quotePolicy` option.
273+
229274
### `~CreateBotOptions.queue`
230275

231276
The message queue for handling incoming and outgoing activities. If omitted,

docs/concepts/events.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,54 @@ property. See also the [*Quotes* section](./message.md#quotes) in the *Message*
265265
concept document.
266266

267267

268+
Quote request
269+
-------------
270+
271+
*This API is available since BotKit 0.5.0.*
272+
273+
The `~Bot.onQuoteRequest` event handler is called when a remote actor asks
274+
permission to quote one of your bot's messages through [FEP-044f]. It
275+
receives a `QuoteRequest` object, whose `quote` property is the remote quote
276+
post and whose `target` property is the bot's quoted message.
277+
278+
If the message's quote policy automatically accepts or rejects the request,
279+
BotKit sends the response before the event handler is called. The handler can
280+
inspect `~QuoteRequest.state` to see whether the request is still pending:
281+
282+
~~~~ typescript twoslash
283+
import { type Bot } from "@fedify/botkit";
284+
const bot = {} as unknown as Bot<void>;
285+
// ---cut-before---
286+
bot.onQuoteRequest = async (session, request) => {
287+
if (request.state === "pending") {
288+
await request.accept();
289+
}
290+
};
291+
~~~~
292+
293+
If you later need to revoke a quote authorization, call
294+
`~AuthorizedMessage.unauthorizeQuote()` on the target message with the quote
295+
message:
296+
297+
~~~~ typescript twoslash
298+
import { type Bot } from "@fedify/botkit";
299+
const bot = {} as unknown as Bot<void>;
300+
// ---cut-before---
301+
bot.onQuoteRequest = async (session, request) => {
302+
if (request.state === "accepted") {
303+
await request.target.unauthorizeQuote(request.quote);
304+
}
305+
};
306+
~~~~
307+
308+
Use the `quotePolicy` option on `createBot()` or `Session.publish()` to choose
309+
which quote requests are automatic and which require manual review. For
310+
details, see the [*Quote policy* section](./message.md#quote-policy) in
311+
the *Message* concept document.
312+
313+
[FEP-044f]: https://w3id.org/fep/044f
314+
315+
268316
Message
269317
-------
270318

docs/concepts/message.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,73 @@ await session.publish(text`Hello, ${mention("@fedify@hollo.social")}!`, {
158158
});
159159
~~~~
160160

161+
### Quote policy
162+
163+
You can control who may quote a message by providing
164+
`~SessionPublishOptions.quotePolicy`. BotKit serializes this policy as
165+
the [FEP-044f] interaction policy on the ActivityPub object and uses it when
166+
answering incoming quote requests.
167+
168+
The shorthand values are:
169+
170+
`"public"`
171+
: Anyone may quote the message. This is the default.
172+
173+
`"followers"`
174+
: Followers may quote the message automatically.
175+
176+
`"nobody"`
177+
: Nobody may quote the message, except the bot itself.
178+
179+
`"manual"`
180+
: Quote requests are left pending for `~Bot.onQuoteRequest`.
181+
182+
~~~~ typescript twoslash
183+
import { type Session, text } from "@fedify/botkit";
184+
const session = {} as unknown as Session<void>;
185+
// ---cut-before---
186+
await session.publish(text`Followers can quote this.`, {
187+
quotePolicy: "followers",
188+
});
189+
~~~~
190+
191+
You can also use the two-axis form to distinguish actors whose quotes are
192+
approved automatically from actors whose quotes await manual review:
193+
194+
~~~~ typescript twoslash
195+
import { type Session, text } from "@fedify/botkit";
196+
const session = {} as unknown as Session<void>;
197+
// ---cut-before---
198+
await session.publish(text`Followers are reviewed before quoting this.`, {
199+
quotePolicy: { manual: "followers" },
200+
});
201+
~~~~
202+
203+
The quote policy can be changed when editing a message:
204+
205+
~~~~ typescript twoslash
206+
import { type Session, text } from "@fedify/botkit";
207+
const session = {} as unknown as Session<void>;
208+
// ---cut-before---
209+
const message = await session.publish(text`This starts public.`);
210+
await message.update(text`This is now harder to quote.`, {
211+
quotePolicy: "nobody",
212+
});
213+
~~~~
214+
215+
If a quote was already authorized through an incoming quote request, you can
216+
revoke its authorization stamp from the quoted message:
217+
218+
~~~~ typescript twoslash
219+
import { type AuthorizedMessage, type Message, type MessageClass } from "@fedify/botkit";
220+
declare const message: AuthorizedMessage<MessageClass, void>;
221+
declare const quote: Message<MessageClass, void>;
222+
// ---cut-before---
223+
await message.unauthorizeQuote(quote);
224+
~~~~
225+
226+
[FEP-044f]: https://w3id.org/fep/044f
227+
161228
### Attaching media
162229

163230
You can attach media files to a message by providing

packages/botkit-postgres/src/mod.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ import {
1818
PostgresRepository,
1919
} from "@fedify/botkit-postgres";
2020
import { importJwk } from "@fedify/fedify/sig";
21-
import { Create, Follow, Note, Person, PUBLIC_COLLECTION } from "@fedify/vocab";
21+
import {
22+
Create,
23+
Follow,
24+
Note,
25+
Person,
26+
PUBLIC_COLLECTION,
27+
QuoteAuthorization,
28+
} from "@fedify/vocab";
2229
import assert from "node:assert/strict";
2330
import { describe, test } from "node:test";
2431
import postgres from "postgres";
@@ -129,6 +136,7 @@ if (postgresUrl == null) {
129136
"key_pairs",
130137
"messages",
131138
"poll_votes",
139+
"quote_authorizations",
132140
"sent_follows",
133141
],
134142
);
@@ -211,6 +219,13 @@ if (postgresUrl == null) {
211219
test("does not emit unhandled rejections for schema initialization", async () => {
212220
const error = new Error("Schema initialization failed.");
213221
const sql = {
222+
begin: async (
223+
callback: (
224+
transactionSql: { unsafe: () => Promise<never> },
225+
) => Promise<void>,
226+
) => {
227+
await callback(sql);
228+
},
214229
// deno-lint-ignore require-await
215230
unsafe: async () => {
216231
throw error;
@@ -1027,6 +1042,47 @@ if (postgresUrl == null) {
10271042
await sql.end();
10281043
}
10291044
});
1045+
1046+
test("keeps the first quote authorization for a quote", async () => {
1047+
const harness = createHarness();
1048+
try {
1049+
const repo = harness.repository;
1050+
const firstId = "01942976-3400-7f34-872e-2cbf0f9eeac4";
1051+
const secondId = "01942976-3400-7f34-872e-2cbf0f9eeac5";
1052+
const quote = new URL("https://remote.example/notes/quote");
1053+
const target = new URL("https://example.com/ap/note/1");
1054+
const first = new QuoteAuthorization({
1055+
id: new URL(
1056+
`https://example.com/ap/actor/bot/quote-authorization/${firstId}`,
1057+
),
1058+
attribution: new URL("https://example.com/ap/actor/bot"),
1059+
interactingObject: quote,
1060+
interactionTarget: target,
1061+
});
1062+
const second = new QuoteAuthorization({
1063+
id: new URL(
1064+
`https://example.com/ap/actor/bot/quote-authorization/${secondId}`,
1065+
),
1066+
attribution: new URL("https://example.com/ap/actor/bot"),
1067+
interactingObject: quote,
1068+
interactionTarget: target,
1069+
});
1070+
1071+
await repo.addQuoteAuthorization("bot", firstId, first);
1072+
await repo.addQuoteAuthorization("bot", secondId, second);
1073+
1074+
assert.deepStrictEqual(
1075+
(await repo.findQuoteAuthorization("bot", quote))?.id?.href,
1076+
first.id?.href,
1077+
);
1078+
assert.deepStrictEqual(
1079+
await repo.getQuoteAuthorization("bot", secondId),
1080+
undefined,
1081+
);
1082+
} finally {
1083+
await harness.cleanup();
1084+
}
1085+
});
10301086
});
10311087
}
10321088

0 commit comments

Comments
 (0)