Skip to content

Commit 0b14a79

Browse files
committed
Quotes
1 parent d811530 commit 0b14a79

8 files changed

Lines changed: 222 additions & 16 deletions

File tree

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ To be released.
3434
- Added `ReactionEventHandler` type.
3535
- Added `UndoneReactionEventHandler` type.
3636

37+
- Added quote support.
38+
39+
- Added `SessionPublishOptions.quoteTarget` option.
40+
- Added `Message.quoteTarget` property.
41+
3742
- Added `SessionGetOutboxOptions` interface.
3843

3944

docs/concepts/message.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,23 @@ await session.publish(text`你好,世界!`, {
209209
210210
[BCP 47]: https://tools.ietf.org/html/bcp47
211211

212+
### Quoting
213+
214+
*This API is available since BotKit 0.2.0.*
215+
216+
You can quote a message by providing `~SessionPublishOptions.quoteTarget`
217+
option. The value of the option has to be a `Message` object that you want
218+
to quote. For example:
219+
220+
~~~~ typescript
221+
bot.onMention = async (session, message) => {
222+
await session.publish(
223+
text`This message quotes the message.`,
224+
{ quoteTarget: message }, // [!code highlight]
225+
);
226+
};
227+
~~~~
228+
212229

213230
Extracting information from a message
214231
-------------------------------------
@@ -392,6 +409,26 @@ object.
392409

393410
[`Temporal.Instant`]: https://tc39.es/proposal-temporal/docs/instant.html
394411

412+
### Quotes
413+
414+
*This API is available since BotKit 0.2.0.*
415+
416+
You can get the message that is quoted in the message through
417+
the `~Message.quoteTarget` property. It is either another `Message` object
418+
or `undefined` if the message is not a quote.
419+
420+
Since the quoted message itself can be a quote, you can traverse the
421+
conversation by following the `~Message.quoteTarget` property recursively:
422+
423+
~~~~ typescript
424+
let quote: Message<MessageClass, void> | undefined = message.quoteTarget;
425+
while (quote != null) {
426+
console.log(quote);
427+
quote = quote.quoteTarget;
428+
}
429+
~~~~
430+
431+
395432
### Want more?
396433

397434
If you want more data from the message, you can get the raw object of

src/message-impl.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@ Deno.test("AuthorizedMessageImpl.delete()", async () => {
211211
session,
212212
{},
213213
undefined,
214+
undefined,
214215
true,
215216
);
216217
await repository.addMessage(

src/message-impl.ts

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import {
2727
Hashtag,
2828
isActor,
2929
Like as RawLike,
30-
type Link,
30+
Link,
3131
Mention,
3232
Note,
3333
type Object,
@@ -39,6 +39,7 @@ import {
3939
} from "@fedify/fedify/vocab";
4040
import type { LanguageTag } from "@phensley/language-tag";
4141
import { unescape } from "@std/html/entities";
42+
import { parseMediaType } from "@std/media-types/parse-media-type";
4243
import { generate as uuidv7 } from "@std/uuid/unstable-v7";
4344
import { FilterXSS, getDefaultWhiteList } from "xss";
4445
import type { DeferredCustomEmoji, Emoji } from "./emoji.ts";
@@ -90,6 +91,7 @@ export class MessageImpl<T extends MessageClass, TContextData>
9091
text: string;
9192
html: string;
9293
readonly replyTarget?: Message<MessageClass, TContextData> | undefined;
94+
readonly quoteTarget?: Message<MessageClass, TContextData> | undefined;
9395
mentions: readonly Actor[];
9496
hashtags: readonly Hashtag[];
9597
readonly attachments: readonly Document[];
@@ -112,6 +114,7 @@ export class MessageImpl<T extends MessageClass, TContextData>
112114
this.text = message.text;
113115
this.html = message.html;
114116
this.replyTarget = message.replyTarget;
117+
this.quoteTarget = message.quoteTarget;
115118
this.mentions = message.mentions;
116119
this.hashtags = message.hashtags;
117120
this.attachments = message.attachments;
@@ -121,17 +124,17 @@ export class MessageImpl<T extends MessageClass, TContextData>
121124

122125
reply(
123126
text: Text<"block", TContextData>,
124-
options?: SessionPublishOptions,
127+
options?: SessionPublishOptions<TContextData>,
125128
): Promise<AuthorizedMessage<Note, TContextData>>;
126129
reply<T extends MessageClass>(
127130
text: Text<"block", TContextData>,
128-
options?: SessionPublishOptionsWithClass<T> | undefined,
131+
options?: SessionPublishOptionsWithClass<T, TContextData> | undefined,
129132
): Promise<AuthorizedMessage<T, TContextData>>;
130133
reply(
131134
text: Text<"block", TContextData>,
132135
options?:
133-
| SessionPublishOptions
134-
| SessionPublishOptionsWithClass<MessageClass>,
136+
| SessionPublishOptions<TContextData>
137+
| SessionPublishOptionsWithClass<MessageClass, TContextData>,
135138
): Promise<AuthorizedMessage<MessageClass, TContextData>> {
136139
return this.session.publish(text, {
137140
visibility: this.visibility === "unknown" ? "direct" : this.visibility,
@@ -544,20 +547,23 @@ export async function createMessage<T extends MessageClass, TContextData>(
544547
session: SessionImpl<TContextData>,
545548
cachedObjects: Record<string, Object>,
546549
replyTarget?: Message<MessageClass, TContextData>,
550+
quote?: Message<MessageClass, TContextData>,
547551
authorized?: true,
548552
): Promise<AuthorizedMessage<T, TContextData>>;
549553
export async function createMessage<T extends MessageClass, TContextData>(
550554
raw: T,
551555
session: SessionImpl<TContextData>,
552556
cachedObjects: Record<string, Object>,
553557
replyTarget?: Message<MessageClass, TContextData>,
558+
quote?: Message<MessageClass, TContextData>,
554559
authorized?: boolean,
555560
): Promise<Message<T, TContextData>>;
556561
export async function createMessage<T extends MessageClass, TContextData>(
557562
raw: T,
558563
session: SessionImpl<TContextData>,
559564
cachedObjects: Record<string, Object>,
560565
replyTarget?: Message<MessageClass, TContextData>,
566+
quoteTarget?: Message<MessageClass, TContextData>,
561567
authorized: boolean = false,
562568
): Promise<Message<T, TContextData>> {
563569
if (raw.id == null) throw new TypeError("The raw.id is required.");
@@ -582,6 +588,7 @@ export async function createMessage<T extends MessageClass, TContextData>(
582588
const mentions: Actor[] = [];
583589
const mentionedActorIds = new Set<string>();
584590
const hashtags: Hashtag[] = [];
591+
const quoteLinks: Link[] = [];
585592
for await (const tag of raw.getTags(options)) {
586593
if (tag instanceof Mention && tag.href != null) {
587594
const obj = tag.href.href === session.actorId?.href
@@ -593,6 +600,18 @@ export async function createMessage<T extends MessageClass, TContextData>(
593600
mentionedActorIds.add(tag.href.href);
594601
} else if (tag instanceof Hashtag) {
595602
hashtags.push(tag);
603+
} else if (tag instanceof Link) {
604+
const mediaType = tag.mediaType == null
605+
? null
606+
: parseMediaType(tag.mediaType);
607+
if (
608+
tag.rel === "https://misskey-hub.net/ns#_misskey_quote" ||
609+
mediaType?.[0] === "application/activity+json" ||
610+
mediaType?.[0] === "application/ld+json" &&
611+
mediaType[1]?.profile === "https://www.w3.org/ns/activitystreams"
612+
) {
613+
quoteLinks.push(tag);
614+
}
596615
}
597616
}
598617
const attachments: Document[] = [];
@@ -612,14 +631,46 @@ export async function createMessage<T extends MessageClass, TContextData>(
612631
session.context,
613632
parsed.values.id,
614633
);
615-
} else rt = await raw.getReplyTarget(options);
634+
} else {
635+
rt = await raw.getReplyTarget(options);
636+
}
616637
if (
617638
rt instanceof Article || rt instanceof ChatMessage ||
618639
rt instanceof Note || rt instanceof Question
619640
) {
620641
replyTarget = await createMessage(rt, session, cachedObjects);
621642
}
622643
}
644+
if (quoteTarget == null) {
645+
let quoteUrl: URL | null = null;
646+
for (const quoteLink of quoteLinks) {
647+
if (quoteLink.href == null) continue;
648+
quoteUrl = quoteLink.href;
649+
break;
650+
}
651+
if (quoteUrl == null) quoteUrl = raw.quoteUrl;
652+
let qt: Object | null = null;
653+
const parsed = session.context.parseUri(quoteUrl);
654+
// @ts-ignore: The `class` property satisfies the `MessageClass` type.
655+
if (parsed?.type === "object" && messageClasses.includes(parsed.class)) {
656+
// @ts-ignore: The `class` property satisfies the `MessageClass` type.
657+
// deno-lint-ignore no-explicit-any
658+
const cls: new (values: any) => T = parsed.class;
659+
qt = await session.bot.dispatchMessage(
660+
cls,
661+
session.context,
662+
parsed.values.id,
663+
);
664+
} else if (quoteUrl != null) {
665+
qt = await session.context.lookupObject(quoteUrl, options);
666+
}
667+
if (
668+
qt instanceof Article || qt instanceof ChatMessage ||
669+
qt instanceof Note || qt instanceof Question
670+
) {
671+
quoteTarget = await createMessage(qt, session, cachedObjects);
672+
}
673+
}
623674
return new (authorized ? AuthorizedMessageImpl : MessageImpl)(session, {
624675
raw,
625676
id: raw.id,
@@ -636,6 +687,7 @@ export async function createMessage<T extends MessageClass, TContextData>(
636687
text: unescape(text),
637688
html,
638689
replyTarget,
690+
quoteTarget,
639691
mentions,
640692
hashtags,
641693
attachments,

src/message.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,12 @@ export interface Message<T extends MessageClass, TContextData> {
146146
*/
147147
readonly attachments: readonly Document[];
148148

149+
/**
150+
* The message quoted by this message, if any.
151+
* @since 0.2.0
152+
*/
153+
readonly quoteTarget?: Message<MessageClass, TContextData>;
154+
149155
/**
150156
* The published time of the message.
151157
*/
@@ -164,7 +170,7 @@ export interface Message<T extends MessageClass, TContextData> {
164170
*/
165171
reply(
166172
text: Text<"block", TContextData>,
167-
options?: SessionPublishOptions,
173+
options?: SessionPublishOptions<TContextData>,
168174
): Promise<AuthorizedMessage<Note, TContextData>>;
169175

170176
/**
@@ -176,7 +182,7 @@ export interface Message<T extends MessageClass, TContextData> {
176182
*/
177183
reply<T extends MessageClass>(
178184
text: Text<"block", TContextData>,
179-
options?: SessionPublishOptionsWithClass<T>,
185+
options?: SessionPublishOptionsWithClass<T, TContextData>,
180186
): Promise<AuthorizedMessage<T, TContextData>>;
181187

182188
/**

src/session-impl.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { assertFalse } from "@std/assert/false";
3030
import { assertInstanceOf } from "@std/assert/instance-of";
3131
import { assertRejects } from "@std/assert/rejects";
3232
import { BotImpl } from "./bot-impl.ts";
33+
import { createMessage } from "./message-impl.ts";
3334
import { MemoryRepository, type Uuid } from "./repository.ts";
3435
import { SessionImpl } from "./session-impl.ts";
3536
import { mention, text } from "./text.ts";
@@ -386,6 +387,71 @@ Deno.test("SessionImpl.publish()", async (t) => {
386387
assertEquals(directMsg.visibility, "direct");
387388
// assertEquals(directMsg.mentions, [mentioned]); // FIXME
388389
});
390+
391+
ctx.sentActivities = [];
392+
393+
await t.step("quote", async () => {
394+
const originalAuthor = new Person({
395+
id: new URL("https://example.com/ap/actor/john"),
396+
preferredUsername: "john",
397+
});
398+
const originalPost = new Note({
399+
id: new URL(
400+
"https://example.com/ap/note/c1c792ce-a0be-4685-b396-e59e5ef8c788",
401+
),
402+
content: "<p>Hello, world!</p>",
403+
attribution: originalAuthor,
404+
to: new URL("https://example.com/ap/actor/john/followers"),
405+
cc: PUBLIC_COLLECTION,
406+
});
407+
const originalMsg = await createMessage<Note, void>(
408+
originalPost,
409+
session,
410+
{},
411+
);
412+
const quote = await session.publish(text`Check this out!`, {
413+
quoteTarget: originalMsg,
414+
});
415+
assertEquals(ctx.sentActivities.length, 2);
416+
const { recipients, activity } = ctx.sentActivities[0];
417+
assertEquals(recipients, "followers");
418+
assertInstanceOf(activity, Create);
419+
assertEquals(activity.actorId, ctx.getActorUri(bot.identifier));
420+
assertEquals(activity.toIds, [PUBLIC_COLLECTION]);
421+
assertEquals(activity.ccIds, [ctx.getFollowersUri(bot.identifier)]);
422+
const object = await activity.getObject(ctx);
423+
const { recipients: recipients2, activity: activity2 } =
424+
ctx.sentActivities[1];
425+
assertEquals(recipients2, [originalAuthor]);
426+
assertInstanceOf(activity2, Create);
427+
assertEquals(activity2.actorId, ctx.getActorUri(bot.identifier));
428+
assertEquals(activity2.toIds, [PUBLIC_COLLECTION]);
429+
assertEquals(activity2.ccIds, [ctx.getFollowersUri(bot.identifier)]);
430+
assertInstanceOf(object, Note);
431+
assertEquals(object.attributionId, ctx.getActorUri(bot.identifier));
432+
assertEquals(object.toIds, [PUBLIC_COLLECTION]);
433+
assertEquals(object.ccIds, [ctx.getFollowersUri(bot.identifier)]);
434+
assertEquals(
435+
object.content,
436+
`<p>Check this out!</p>
437+
438+
<p class="quote-inline"><br>RE: <a href="${originalMsg.id.href}">${originalMsg.id.href}</a></p>`,
439+
);
440+
assertEquals(object.quoteUrl, originalMsg.id);
441+
assertEquals(quote.id, object.id);
442+
assertEquals(
443+
quote.text,
444+
`Check this out!\n\nRE: ${originalMsg.id.href}`,
445+
);
446+
assertEquals(
447+
quote.html,
448+
`<p>Check this out!</p>
449+
450+
<p><br>RE: <a href="${originalMsg.id.href}">${originalMsg.id.href}</a></p>`,
451+
);
452+
assertEquals(quote.visibility, "public");
453+
assertEquals(quote.quoteTarget?.id, originalMsg.id);
454+
});
389455
});
390456

391457
Deno.test("SessionImpl.getOutbox()", async (t) => {

0 commit comments

Comments
 (0)