Skip to content
Merged
48 changes: 37 additions & 11 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Comment thread
dahlia marked this conversation as resolved.
Comment thread
dahlia marked this conversation as resolved.

BotKit now serializes quote policies on outgoing messages, handles
incoming `QuoteRequest` activities, automatically accepts or rejects them
Expand All @@ -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
Expand All @@ -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`
Expand Down Expand Up @@ -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
Expand All @@ -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
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.

39 changes: 39 additions & 0 deletions docs/concepts/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,45 @@ the *Message* concept document.
[FEP-044f]: https://w3id.org/fep/044f


Quote accepted and rejected
---------------------------

*This API is available since BotKit 0.5.0.*

The `~Bot.onQuoteAccepted` and `~Bot.onQuoteRejected` event handlers are called
when a remote author responds to a quote request that your bot sent for one of
its own quote posts.

When the request is accepted, BotKit verifies the returned
`QuoteAuthorization` stamp, applies it to the stored message, sends an `Update`
activity, and then calls `~Bot.onQuoteAccepted`:

~~~~ typescript twoslash
import { type Bot } from "@fedify/botkit";
const bot = {} as unknown as Bot<void>;
// ---cut-before---
bot.onQuoteAccepted = (_session, message, approver) => {
console.log(`${approver.id} approved ${message.id}`);
console.log(message.quoteApprovalState); // "accepted"
};
~~~~

When the request is rejected, or when a previously accepted
`QuoteAuthorization` stamp is later deleted by the quoted author, BotKit
removes the quote target and fallback quote link from the stored message,
sends an `Update` activity, and then calls `~Bot.onQuoteRejected`:

~~~~ typescript twoslash
import { type Bot } from "@fedify/botkit";
const bot = {} as unknown as Bot<void>;
// ---cut-before---
bot.onQuoteRejected = (_session, message, rejecter) => {
console.log(`${rejecter.id} rejected ${message.id}`);
console.log(message.quoteTarget); // undefined
};
~~~~


Message
-------

Expand Down
28 changes: 28 additions & 0 deletions docs/concepts/message.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
declare const quoted: Message<MessageClass, void>;
// ---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.*
Expand Down Expand Up @@ -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:

Expand Down
8 changes: 8 additions & 0 deletions docs/concepts/repository.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
--------------
Expand Down
46 changes: 46 additions & 0 deletions packages/botkit-postgres/src/mod.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ if (postgresUrl == null) {
"key_pairs",
"messages",
"poll_votes",
"quote_authorization_refs",
"quote_authorizations",
"sent_follows",
],
Expand Down Expand Up @@ -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();
}
});
});
}

Expand Down
87 changes: 87 additions & 0 deletions packages/botkit-postgres/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,25 @@ async function initializePostgresRepositorySchemaInTransaction(
[],
prepare,
);
await execute(
sql,
`CREATE TABLE IF NOT EXISTS "${validatedSchema}"."quote_authorization_refs" (
bot_id TEXT NOT NULL,
authorization_uri TEXT NOT NULL,
message_id TEXT NOT NULL,
attribution_uri TEXT,
PRIMARY KEY (bot_id, authorization_uri)
)`,
[],
prepare,
);
await execute(
sql,
`ALTER TABLE "${validatedSchema}"."quote_authorization_refs"
ADD COLUMN IF NOT EXISTS attribution_uri TEXT`,
[],
prepare,
);
await execute(
sql,
`CREATE TABLE IF NOT EXISTS "${validatedSchema}"."poll_votes" (
Expand Down Expand Up @@ -1094,6 +1113,74 @@ export class PostgresRepository implements Repository, AsyncDisposable {
return await parseQuoteAuthorization(rows[0]?.authorization_json);
}

async addQuoteAuthorizationReference(
identifier: string,
authorization: URL,
messageId: Uuid,
attribution?: URL,
): Promise<void> {
await this.ensureReady();
await this.query(
this.sql,
`INSERT INTO ${this.table("quote_authorization_refs")}
(bot_id, authorization_uri, message_id, attribution_uri)
VALUES ($1, $2, $3, $4)
ON CONFLICT (bot_id, authorization_uri) DO UPDATE
SET message_id = EXCLUDED.message_id,
attribution_uri = EXCLUDED.attribution_uri`,
[identifier, authorization.href, messageId, attribution?.href ?? null],
);
}

async findQuoteAuthorizationReference(
identifier: string,
authorization: URL,
): Promise<Uuid | undefined> {
await this.ensureReady();
const rows = await this.query<{ readonly message_id: Uuid }>(
this.sql,
`SELECT message_id
FROM ${this.table("quote_authorization_refs")}
WHERE bot_id = $1 AND authorization_uri = $2`,
[identifier, authorization.href],
);
return rows[0]?.message_id;
}

async findQuoteAuthorizationReferenceAttribution(
identifier: string,
authorization: URL,
): Promise<URL | undefined> {
await this.ensureReady();
const rows = await this.query<{ readonly attribution_uri: string | null }>(
this.sql,
`SELECT attribution_uri
FROM ${this.table("quote_authorization_refs")}
WHERE bot_id = $1 AND authorization_uri = $2`,
[identifier, authorization.href],
);
const attribution = rows[0]?.attribution_uri;
if (attribution == null) return undefined;
try {
return new URL(attribution);
} catch {
return undefined;
}
}

async removeQuoteAuthorizationReference(
identifier: string,
authorization: URL,
): Promise<void> {
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,
Expand Down
Loading
Loading