Skip to content

Commit d6ab4bd

Browse files
committed
Bot.onQuote event for handling quoted messages
This commit implements the Bot.onQuote event handler which gets triggered when someone quotes one of the bot's messages. This feature allows bots to respond when their messages are quoted in the fediverse. The implementation includes a test case and documentation for this new event handler.
1 parent 515dade commit d6ab4bd

File tree

8 files changed

+157
-1
lines changed

8 files changed

+157
-1
lines changed

CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ To be released.
4040

4141
- Added `SessionPublishOptions.quoteTarget` option.
4242
- Added `Message.quoteTarget` property.
43+
- Added `Bot.onQuote` event.
44+
- Added `QuoteEventHandler` type.
4345

4446
- Added `SessionGetOutboxOptions` interface.
4547

docs/concepts/events.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,29 @@ document.
203203
> };
204204
205205
206+
Quote
207+
-----
208+
209+
*This API is available since BotKit 0.2.0.*
210+
211+
The `~Bot.onQuote` event handler is called when someone quotes one of your bot's
212+
messages. It receives a `Message` object, which represents the message that
213+
quotes your bot's message, as the second argument.
214+
215+
The following is an example of a quote event handler that responds to a message
216+
that quotes one of your bot's posts:
217+
218+
~~~~ typescript
219+
bot.onQuote = async (session, quote) => {
220+
await quote.reply(text`I see you quoted my post, ${quote.actor}!`);
221+
};
222+
~~~~
223+
224+
If you want to get the quoted message, you can use the `~Message.quoteTarget`
225+
property. See also the [*Quotes* section](./message.md#quotes) in the *Message*
226+
concept document.
227+
228+
206229
Message
207230
-------
208231

docs/concepts/message.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,12 @@ bot.onMention = async (session, message) => {
226226
};
227227
~~~~
228228

229+
> [!NOTE]
230+
> Quoting behavior can vary significantly between different ActivityPub
231+
> implementations. Some platforms like Misskey display quotes prominently,
232+
> while others like Mastodon might implement quotes differently or not support
233+
> them at all.
234+
229235

230236
Extracting information from a message
231237
-------------------------------------

src/bot-impl.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1753,6 +1753,60 @@ Deno.test("BotImpl.onCreated()", async (t) => {
17531753

17541754
mentioned = [];
17551755
messaged = [];
1756+
ctx.forwardedRecipients = [];
1757+
1758+
await t.step("on quote", async () => {
1759+
const create = new Create({
1760+
id: new URL(
1761+
"https://example.com/ap/create/9cfd7129-4cf0-4505-90d8-3cac2dc42434",
1762+
),
1763+
actor: new URL("https://example.com/ap/actor/john"),
1764+
to: PUBLIC_COLLECTION,
1765+
cc: new URL("https://example.com/ap/actor/john/followers"),
1766+
object: new Note({
1767+
id: new URL(
1768+
"https://example.com/ap/note/9cfd7129-4cf0-4505-90d8-3cac2dc42434",
1769+
),
1770+
attribution: new Person({
1771+
id: new URL("https://example.com/ap/actor/john"),
1772+
preferredUsername: "john",
1773+
}),
1774+
to: PUBLIC_COLLECTION,
1775+
cc: new URL("https://example.com/ap/actor/john/followers"),
1776+
content: "It's a quote!",
1777+
quoteUrl: new URL(
1778+
"https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de",
1779+
),
1780+
}),
1781+
});
1782+
let quoted: [Session<void>, Message<MessageClass, void>][] = [];
1783+
bot.onQuote = (session, msg) => void (quoted.push([session, msg]));
1784+
1785+
await bot.onCreated(ctx, create);
1786+
1787+
assertEquals(quoted.length, 1);
1788+
const [session, msg] = quoted[0];
1789+
assertEquals(session.bot, bot);
1790+
assertEquals(session.context, ctx);
1791+
assertInstanceOf(msg.raw, Note);
1792+
assertEquals(msg.raw.id, create.objectId);
1793+
assert(msg.quoteTarget != null);
1794+
assertEquals(
1795+
msg.quoteTarget.id,
1796+
new URL(
1797+
"https://example.com/ap/note/a6358f1b-c978-49d3-8065-37a1df6168de",
1798+
),
1799+
);
1800+
assertEquals(replied, []);
1801+
assertEquals(mentioned, []);
1802+
assertEquals(messaged, quoted);
1803+
assertEquals(ctx.sentActivities, []);
1804+
assertEquals(ctx.forwardedRecipients, ["followers"]);
1805+
1806+
quoted = [];
1807+
messaged = [];
1808+
ctx.forwardedRecipients = [];
1809+
});
17561810

17571811
await t.step("on message", async () => {
17581812
const create = new Create({

src/bot-impl.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import {
5656
import { getXForwardedRequest } from "@hongminhee/x-forwarded-fetch";
5757
import { getLogger } from "@logtape/logtape";
5858
import { extension } from "@std/media-types/extension";
59+
import { parseMediaType } from "@std/media-types/parse-media-type";
5960
import metadata from "../deno.json" with { type: "json" };
6061
import type { Bot, CreateBotOptions, PagesOptions } from "./bot.ts";
6162
import {
@@ -70,6 +71,7 @@ import type {
7071
LikeEventHandler,
7172
MentionEventHandler,
7273
MessageEventHandler,
74+
QuoteEventHandler,
7375
ReactionEventHandler,
7476
RejectEventHandler,
7577
ReplyEventHandler,
@@ -124,6 +126,7 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
124126
onRejectFollow?: RejectEventHandler<TContextData>;
125127
onMention?: MentionEventHandler<TContextData>;
126128
onReply?: ReplyEventHandler<TContextData>;
129+
onQuote?: QuoteEventHandler<TContextData>;
127130
onMessage?: MessageEventHandler<TContextData>;
128131
onSharedMessage?: SharedMessageEventHandler<TContextData>;
129132
onLike?: LikeEventHandler<TContextData>;
@@ -667,10 +670,52 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
667670
if (
668671
message.visibility === "public" || message.visibility === "unlisted"
669672
) {
670-
await ctx.forwardActivity(this, "followers");
673+
await ctx.forwardActivity(this, "followers", {
674+
skipIfUnsigned: true,
675+
preferSharedInbox: true,
676+
excludeBaseUris: [new URL(ctx.origin)],
677+
});
671678
}
672679
await this.onReply(session, message);
673680
}
681+
let quoteUrl: URL | null = null;
682+
// FIXME: eliminate this duplication
683+
for await (const tag of object.getTags(ctx)) {
684+
if (tag instanceof Link) {
685+
const mediaType = tag.mediaType == null
686+
? null
687+
: parseMediaType(tag.mediaType);
688+
if (
689+
tag.rel === "https://misskey-hub.net/ns#_misskey_quote" ||
690+
mediaType?.[0] === "application/activity+json" ||
691+
mediaType?.[0] === "application/ld+json" &&
692+
mediaType[1]?.profile === "https://www.w3.org/ns/activitystreams"
693+
) {
694+
quoteUrl = tag.href;
695+
break;
696+
}
697+
}
698+
}
699+
if (quoteUrl == null) quoteUrl = object.quoteUrl;
700+
const quoteTarget = ctx.parseUri(quoteUrl);
701+
if (
702+
this.onQuote != null &&
703+
quoteTarget?.type === "object" &&
704+
// @ts-ignore: quoteTarget.class satisfies (typeof messageClasses)[number]
705+
messageClasses.includes(quoteTarget.class)
706+
) {
707+
const message = await getMessage();
708+
if (
709+
message.visibility === "public" || message.visibility === "unlisted"
710+
) {
711+
await ctx.forwardActivity(this, "followers", {
712+
skipIfUnsigned: true,
713+
preferSharedInbox: true,
714+
excludeBaseUris: [new URL(ctx.origin)],
715+
});
716+
}
717+
await this.onQuote(session, message);
718+
}
674719
for await (const tag of object.getTags(ctx)) {
675720
if (
676721
tag instanceof Mention && tag.href != null && this.onMention != null

src/bot.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import type {
2929
LikeEventHandler,
3030
MentionEventHandler,
3131
MessageEventHandler,
32+
QuoteEventHandler,
3233
ReactionEventHandler,
3334
RejectEventHandler,
3435
ReplyEventHandler,
@@ -137,6 +138,12 @@ export interface Bot<TContextData> {
137138
*/
138139
onReply?: ReplyEventHandler<TContextData>;
139140

141+
/**
142+
* An event handler for a quote of the bot's message.
143+
* @since 0.2.0
144+
*/
145+
onQuote?: QuoteEventHandler<TContextData>;
146+
140147
/**
141148
* An event handler for a message shown to the bot's timeline. To listen
142149
* to this event, your bot needs to follow others first.
@@ -425,6 +432,12 @@ export function createBot<TContextData = void>(
425432
set onReply(value) {
426433
bot.onReply = value;
427434
},
435+
get onQuote() {
436+
return bot.onQuote;
437+
},
438+
set onQuote(value) {
439+
bot.onQuote = value;
440+
},
428441
get onMessage() {
429442
return bot.onMessage;
430443
},

src/events.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,18 @@ export type ReplyEventHandler<TContextData> = (
8585
reply: Message<MessageClass, TContextData>,
8686
) => void | Promise<void>;
8787

88+
/**
89+
* An event handler for a quote of the bot's message.
90+
* @typeParam TContextData The type of the context data.
91+
* @param session The session of the bot.
92+
* @param quote The message which quotes the bot's message.
93+
* @since 0.2.0
94+
*/
95+
export type QuoteEventHandler<TContextData> = (
96+
session: Session<TContextData>,
97+
quote: Message<MessageClass, TContextData>,
98+
) => void | Promise<void>;
99+
88100
/**
89101
* An event handler for a message shown to the bot's timeline. To listen to
90102
* this event, your bot needs to follow others first.

src/message-impl.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ export async function createMessage<T extends MessageClass, TContextData>(
654654
} else if (tag instanceof Hashtag) {
655655
hashtags.push(tag);
656656
} else if (tag instanceof Link) {
657+
// FIXME: eliminate this duplication
657658
const mediaType = tag.mediaType == null
658659
? null
659660
: parseMediaType(tag.mediaType);

0 commit comments

Comments
 (0)