Skip to content

Request approval when publishing quote posts (FEP-044f) #29

Description

@dahlia

Part of #27; depends on #28. This issue makes publish({ quoteTarget }) follow the FEP-044f handshake, so that quote posts authored by a bot are verified and rendered as quotes by Mastodon 4.4+ and other implementations, instead of only by the Misskey family.

The quote property

publish() currently emits the legacy quoteUrl property, a Misskey-style Link tag, and an HTML fallback paragraph. Keep all of those, since the FEP itself recommends carrying the compatibility properties and Misskey-family servers understand nothing else, and additionally set the quote property to the quoted message's id. Fedify exposes it as the quote constructor option next to quoteUrl.

Sending the QuoteRequest

After the Create has been delivered exactly as today, and only when the quoted message's author is not the bot itself (the FEP exempts self-quotes from approval), send a QuoteRequest to the quoted author: actor is the bot, object is the quoted message's id, and instrument is the freshly created message object inlined in full, which the FEP recommends so the author can inspect what they are approving. Give the activity the id ctx.getObjectUri(QuoteRequest, { identifier, id }) where id is the message's UUID. Deriving the activity id from the message id is what lets the Accept and Reject handlers below correlate responses without any new storage.

Fedify only mints getObjectUri() values for registered dispatchers, so register an object dispatcher for QuoteRequest at /ap/actor/{identifier}/quote-request/{id} alongside the existing activity dispatchers. It reconstructs the activity from the stored message (object from its quote property, instrument referencing the message by id) and returns null when the message no longer exists or no longer quotes anything. This keeps the activity dereferenceable, which the FEP expects of a QuoteRequest whose instrument the recipient may want to re-inspect, without storing anything.

Keeping publish() non-blocking is settled in #27: the Create goes out immediately and approval arrives whenever it arrives. A remote server that predates the FEP never answers, and the message then behaves exactly as a quote does today.

Handling Accept

onFollowAccepted currently assumes every incoming Accept concerns a follow. Branch on the accepted object instead: when parseLocalUri on the Accept's object yields a QuoteRequest URI belonging to one of the instance's bots, treat it as a quote approval; otherwise fall through to the follow path. Extract the message UUID from the URI and load the stored Create; ignore the Accept when no such message exists or the stored message carries no quote property (already stripped after a rejection, or never a quote).

Do not trust the Accept before validating it. Dereference the quoted object and check that the Accept's actor matches its attributedTo; dereference the result and check that it is a QuoteAuthorization whose interactingObject is the bot's message id, whose interactionTarget is the quoted message's id, and whose attributedTo is that same author; and require the stamp's own id to share the author's origin, which is what makes the stamp attributable to that author's server. Put this check into a shared validateQuoteAuthorization() helper, because #30 needs the identical logic when verifying third parties' quotes. On any failure, log and ignore the activity.

On success, rewrite the stored activity through Repository.updateMessage() setting quoteAuthorization to the stamp's id, deliver an Update to the same audience the message edit path already computes (followers, mentioned actors, and the quoted author), and fire the new onQuoteAccepted event with the updated message. This path must also record the stamp URI against the message UUID through the received-stamp index that #30 adds to the Repository interface (addQuoteAuthorizationReference); the revocation handling there depends on it.

Handling Reject

Correlate the same way in the Reject handler. The default reaction, decided in #27, is to remove the quote rather than delete the post: rewrite the stored object clearing quote, quoteUrl, and quoteAuthorization, dropping the _misskey_quote Link tag, and stripping the trailing <p class="quote-inline">…</p> fallback from every content value, then deliver an Update the same way as above. When the message had already been granted a stamp, also remove its entry from the received-stamp index (removeQuoteAuthorizationReference from #30). Fire onQuoteRejected afterwards with the already-updated message. A bot that would rather take the whole post down calls message.delete() from its handler; there is deliberately no way to prevent the default, because keeping a quote whose author said no is exactly what the FEP exists to prevent, and a deterministic default keeps the federated state predictable.

Approval state and policy inspection

Expose the outcome on the bot's own messages: AuthorizedMessage gains a quoteApprovalState?: "pending" | "accepted" | "notRequired" property, derived from the stored object whenever quoteTarget is present ("notRequired" for self-quotes, "accepted" when quoteAuthorization is set, "pending" otherwise) and undefined for messages that quote nothing. There is no "rejected" state, because rejection strips the quote and the message stops being a quote post altogether.

Give Message a quotePolicy?: QuotePolicy property parsed from the raw interactionPolicy.canQuote, so a bot can check whether quoting is welcome before publishing. Each axis maps back from the approval arrays: PUBLIC_COLLECTION means "public", the author's followers collection (compare against actor.followersId) means "followers", and an array containing nothing beyond the author herself means "nobody". A message without a canQuote rule leaves the property undefined, which callers should read as the server predating the FEP. The FEP is explicit that this policy is advisory, a hint for user interfaces: BotKit must never use it for verification, and publish() must not refuse to quote based on it.

New events

Add two handler types to src/events.ts and wire them through the Bot interface, CreateBotOptions, and src/bot-impl.ts like the existing ones:

export type QuoteAcceptedEventHandler<TContextData> = (
  session: Session<TContextData>,
  message: AuthorizedMessage<MessageClass, TContextData>,
) => void | Promise<void>;

export type QuoteRejectedEventHandler<TContextData> = (
  session: Session<TContextData>,
  message: AuthorizedMessage<MessageClass, TContextData>,
  rejecter: Actor,
) => void | Promise<void>;

The accepted handler needs no separate actor parameter, since the accepter is reachable as message.quoteTarget?.actor. The rejected handler takes one because the quote has already been stripped by the time it fires, so the rejecting author is no longer recoverable from the message itself.

Documentation and tests

Write the tests first: emission of quote alongside the legacy properties, QuoteRequest delivery and the self-quote exemption, the QuoteRequest object dispatcher, the Accept validation matrix (wrong actor, wrong interactingObject, wrong interactionTarget, cross-origin stamp id, missing result), the stored-state rewrite and Update delivery on acceptance, the stripping behavior on rejection including the content fallback, quoteApprovalState derivation, and quotePolicy parsing. Document the flow under docs/ and record the changes in CHANGES.md under version 0.5.0.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Fields

Priority

None yet

Effort

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions