Skip to content

Commit 94b4841

Browse files
committed
Message.like() & Like.unlike() methods
1 parent 1628ee7 commit 94b4841

File tree

8 files changed

+282
-5
lines changed

8 files changed

+282
-5
lines changed

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"./events": "./src/events.ts",
1313
"./follow": "./src/follow.ts",
1414
"./message": "./src/message.ts",
15+
"./reaction": "./src/reaction.ts",
1516
"./session": "./src/session.ts",
1617
"./text": "./src/text.ts"
1718
},

docs/concepts/message.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ Message
88
=======
99

1010
The `Message` object is a representation of a message that is published to
11-
the fediverse. You can interact with the `Message` object such as [replying
12-
to it](#replying-to-a-message), [sharing it](#sharing-a-message), [deleting
13-
it](#deleting-a-message), and so on.
11+
the fediverse. You can interact with the `Message` object such as
12+
[liking it](#liking-a-message), [replying to it](#replying-to-a-message),
13+
[sharing it](#sharing-a-message), [deleting it](#deleting-a-message),
14+
and so on.
1415

1516

1617
Where to get a `Message` object
@@ -506,6 +507,34 @@ setTimeout(async () => {
506507
> object.
507508
508509

510+
Liking a message
511+
----------------
512+
513+
You can like a message by calling the `~Message.like()` method:
514+
515+
~~~~ typescript
516+
const message = await session.publish(
517+
text`This message will be liked.`
518+
);
519+
await message.like(); // [!code highlight]
520+
~~~~
521+
522+
> [!CAUTION]
523+
> You may call the `~Message.like()` method on a message that is already liked,
524+
> but it will not raise an error. Under the hood, such a call actually sends
525+
> multiple `Like` activities to the fediverse, whose behavior is
526+
> undefined—some servers may ignore the duplicated activities, some servers
527+
> may allow them and count them as multiple likes.
528+
529+
If you need to undo the liking, you can call the `~AuthorizedLike.unlike()`
530+
method:
531+
532+
~~~~ typescript
533+
const like = await message.like();
534+
await like.unlike(); // [!code highlight]
535+
~~~~
536+
537+
509538
Replying to a message
510539
---------------------
511540

docs/recipes.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,20 @@ Deno.cron("scheduled messages", "0 0 12 * * *", async () => {
108108
[`Deno.cron()`]: https://docs.deno.com/api/deno/~/Deno.cron
109109

110110

111+
Automatically liking mentions
112+
-----------------------------
113+
114+
It is simple to automatically like mentions of your bot. You can use the
115+
[`onMention`](./concepts/events.md#mention) event handler and
116+
the [`Message.like()`](./concepts/message.md#liking-a-message) method together:
117+
118+
~~~~ typescript
119+
bot.onMention = async (session, message) => {
120+
await message.like();
121+
};
122+
~~~~
123+
124+
111125
Automatically replying to mentions
112126
----------------------------------
113127

src/message-impl.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,17 @@
1616
import { MemoryKvStore } from "@fedify/fedify/federation";
1717
import {
1818
Announce,
19+
Article,
20+
ChatMessage,
1921
Create,
2022
Delete,
2123
Hashtag,
24+
Like as RawLike,
2225
Mention,
2326
Note,
2427
Person,
2528
PUBLIC_COLLECTION,
29+
Question,
2630
Tombstone,
2731
Undo,
2832
Update,
@@ -32,12 +36,44 @@ import { assertEquals } from "@std/assert/equals";
3236
import { assertInstanceOf } from "@std/assert/instance-of";
3337
import { assertRejects } from "@std/assert/rejects";
3438
import { BotImpl } from "./bot-impl.ts";
35-
import { createMessage, getMessageVisibility } from "./message-impl.ts";
39+
import {
40+
createMessage,
41+
getMessageClass,
42+
getMessageVisibility,
43+
isMessageObject,
44+
} from "./message-impl.ts";
3645
import { MemoryRepository } from "./repository.ts";
3746
import { createMockContext } from "./session-impl.test.ts";
3847
import { SessionImpl } from "./session-impl.ts";
3948
import { text } from "./text.ts";
4049

50+
Deno.test("isMessageObject()", () => {
51+
assert(isMessageObject(new Article({})));
52+
assert(isMessageObject(new ChatMessage({})));
53+
assert(isMessageObject(new Note({})));
54+
assert(isMessageObject(new Question({})));
55+
assert(!isMessageObject(new Person({})));
56+
});
57+
58+
Deno.test("getMessageClass()", () => {
59+
assertEquals(
60+
getMessageClass(new Article({})),
61+
Article,
62+
);
63+
assertEquals(
64+
getMessageClass(new ChatMessage({})),
65+
ChatMessage,
66+
);
67+
assertEquals(
68+
getMessageClass(new Note({})),
69+
Note,
70+
);
71+
assertEquals(
72+
getMessageClass(new Question({})),
73+
Question,
74+
);
75+
});
76+
4177
Deno.test("createMessage()", async () => {
4278
const bot = new BotImpl<void>({ kv: new MemoryKvStore(), username: "bot" });
4379
const session = bot.getSession("https://example.com", undefined);
@@ -319,6 +355,71 @@ Deno.test("MessageImpl.share()", async (t) => {
319355
});
320356
});
321357

358+
Deno.test("MessageImpl.like()", async (t) => {
359+
const bot = new BotImpl<void>({
360+
kv: new MemoryKvStore(),
361+
username: "bot",
362+
});
363+
const ctx = createMockContext(bot, "https://example.com");
364+
const session = new SessionImpl(bot, ctx);
365+
const originalPost = new Note({
366+
id: new URL(
367+
"https://example.com/ap/note/c1c792ce-a0be-4685-b396-e59e5ef8c788",
368+
),
369+
content: "<p>Hello, world!</p>",
370+
attribution: new Person({
371+
id: new URL("https://example.com/ap/actor/john"),
372+
preferredUsername: "john",
373+
}),
374+
to: new URL("https://example.com/ap/actor/john/followers"),
375+
cc: PUBLIC_COLLECTION,
376+
});
377+
const message = await createMessage<Note, void>(
378+
originalPost,
379+
session,
380+
{},
381+
);
382+
const like = await message.like();
383+
384+
await t.step("like()", async () => {
385+
assertEquals(ctx.sentActivities.length, 2);
386+
const { recipients, activity } = ctx.sentActivities[0];
387+
assertEquals(recipients, "followers");
388+
assertInstanceOf(activity, RawLike);
389+
assertEquals(activity.actorId, ctx.getActorUri(bot.identifier));
390+
assertEquals(activity.objectId, message.id);
391+
const { recipients: recipients2, activity: activity2 } =
392+
ctx.sentActivities[1];
393+
assertEquals(recipients2, [message.actor]);
394+
assertInstanceOf(activity2, RawLike);
395+
assertEquals(activity2, activity);
396+
assertEquals(like.actor, await session.getActor());
397+
assertEquals(like.raw, activity);
398+
assertEquals(like.id, activity.id);
399+
assertEquals(like.message, message);
400+
});
401+
402+
ctx.sentActivities = [];
403+
404+
await t.step("unlike()", async () => {
405+
await like.unlike();
406+
assertEquals(ctx.sentActivities.length, 2);
407+
const { recipients, activity } = ctx.sentActivities[0];
408+
assertEquals(recipients, "followers");
409+
assertInstanceOf(activity, Undo);
410+
assertEquals(activity.actorId, ctx.getActorUri(bot.identifier));
411+
const object = await activity.getObject();
412+
assertInstanceOf(object, RawLike);
413+
assertEquals(object.actorId, ctx.getActorUri(bot.identifier));
414+
assertEquals(object.objectId, message.id);
415+
const { recipients: recipients2, activity: activity2 } =
416+
ctx.sentActivities[1];
417+
assertEquals(recipients2, [message.actor]);
418+
assertInstanceOf(activity2, Undo);
419+
assertEquals(activity2, activity);
420+
});
421+
});
422+
322423
Deno.test("AuthorizedMessage.update()", async (t) => {
323424
const actorA = new Person({
324425
id: new URL("https://example.com/ap/actor/john"),

src/message-impl.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
Document,
2525
Hashtag,
2626
isActor,
27+
Like as RawLike,
2728
type Link,
2829
Mention,
2930
Note,
@@ -46,6 +47,7 @@ import type {
4647
MessageShareOptions,
4748
MessageVisibility,
4849
} from "./message.ts";
50+
import type { AuthorizedLike } from "./reaction.ts";
4951
import type { Uuid } from "./repository.ts";
5052
import type { SessionImpl } from "./session-impl.ts";
5153
import type {
@@ -93,7 +95,10 @@ export class MessageImpl<T extends MessageClass, TContextData>
9395

9496
constructor(
9597
session: SessionImpl<TContextData>,
96-
message: Omit<Message<T, TContextData>, "delete" | "reply" | "share">,
98+
message: Omit<
99+
Message<T, TContextData>,
100+
"delete" | "reply" | "share" | "like"
101+
>,
97102
) {
98103
this.session = session;
99104
this.raw = message.raw;
@@ -203,6 +208,66 @@ export class MessageImpl<T extends MessageClass, TContextData>
203208
},
204209
};
205210
}
211+
212+
async like(): Promise<AuthorizedLike<TContextData>> {
213+
const uuid = crypto.randomUUID();
214+
const actor = this.session.context.getActorUri(this.session.bot.identifier);
215+
const id = new URL(`#like/${uuid}`, actor);
216+
const activity = new RawLike({
217+
id,
218+
actor,
219+
object: this.id,
220+
});
221+
await this.session.context.sendActivity(
222+
this.session.bot,
223+
"followers",
224+
activity,
225+
{
226+
preferSharedInbox: true,
227+
excludeBaseUris: [new URL(this.session.context.origin)],
228+
},
229+
);
230+
await this.session.context.sendActivity(
231+
this.session.bot,
232+
this.actor,
233+
activity,
234+
{
235+
preferSharedInbox: true,
236+
excludeBaseUris: [new URL(this.session.context.origin)],
237+
},
238+
);
239+
return {
240+
raw: activity,
241+
id,
242+
actor: await this.session.getActor(),
243+
message: this,
244+
unlike: async () => {
245+
const undo = new Undo({
246+
id: new URL(`#unlike/${uuid}`, actor),
247+
actor,
248+
object: activity,
249+
});
250+
await this.session.context.sendActivity(
251+
this.session.bot,
252+
"followers",
253+
undo,
254+
{
255+
preferSharedInbox: true,
256+
excludeBaseUris: [new URL(this.session.context.origin)],
257+
},
258+
);
259+
await this.session.context.sendActivity(
260+
this.session.bot,
261+
this.actor,
262+
undo,
263+
{
264+
preferSharedInbox: true,
265+
excludeBaseUris: [new URL(this.session.context.origin)],
266+
},
267+
);
268+
},
269+
};
270+
}
206271
}
207272

208273
export class AuthorizedMessageImpl<T extends MessageClass, TContextData>

src/message.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
Question,
2525
} from "@fedify/fedify/vocab";
2626
import type { LanguageTag } from "@phensley/language-tag";
27+
import type { AuthorizedLike } from "./reaction.ts";
2728
import type {
2829
SessionPublishOptions,
2930
SessionPublishOptionsWithClass,
@@ -189,6 +190,12 @@ export interface Message<T extends MessageClass, TContextData> {
189190
share(
190191
options?: MessageShareOptions,
191192
): Promise<AuthorizedSharedMessage<T, TContextData>>;
193+
194+
/**
195+
* Likes the message.
196+
* @returns The like object.
197+
*/
198+
like(): Promise<AuthorizedLike<TContextData>>;
192199
}
193200

194201
/**

src/mod.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export type {
5757
MessageVisibility,
5858
SharedMessage,
5959
} from "./message.ts";
60+
export { type AuthorizedLike, type Like, RawLike } from "./reaction.ts";
6061
export {
6162
Announce,
6263
Create,

src/reaction.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// BotKit by Fedify: A framework for creating ActivityPub bots
2+
// Copyright (C) 2025 Hong Minhee <https://hongminhee.org/>
3+
//
4+
// This program is free software: you can redistribute it and/or modify
5+
// it under the terms of the GNU Affero General Public License as
6+
// published by the Free Software Foundation, either version 3 of the
7+
// License, or (at your option) any later version.
8+
//
9+
// This program is distributed in the hope that it will be useful,
10+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
// GNU Affero General Public License for more details.
13+
//
14+
// You should have received a copy of the GNU Affero General Public License
15+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
import type { Actor, Like as RawLike } from "@fedify/fedify/vocab";
17+
import type { Message, MessageClass } from "./message.ts";
18+
export { type Actor, Like as RawLike } from "@fedify/fedify/vocab";
19+
20+
/**
21+
* A like of a message. It is a thin wrapper around a `Like`, which is
22+
* a Fedify object.
23+
* @typeParam TContextData The type of the context data.
24+
*/
25+
export interface Like<TContextData> {
26+
/**
27+
* The underlying raw `Like` activity.
28+
*/
29+
readonly raw: RawLike;
30+
31+
/**
32+
* The URI of the like activity.
33+
*/
34+
readonly id: URL;
35+
36+
/**
37+
* The actor who liked the message.
38+
*/
39+
readonly actor: Actor;
40+
41+
/**
42+
* The message that was liked.
43+
*/
44+
readonly message: Message<MessageClass, TContextData>;
45+
}
46+
47+
/**
48+
* An authorized like of a message. Usually it is a like that the bot
49+
* itself made.
50+
* @typeParam TContextData The type of the context data.
51+
*/
52+
export interface AuthorizedLike<TContextData> extends Like<TContextData> {
53+
/**
54+
* Undoes the like.
55+
*
56+
* If the like is already undone, this method does nothing.
57+
*/
58+
unlike(): Promise<void>;
59+
}

0 commit comments

Comments
 (0)