Skip to content

Commit

Permalink
Bot.onUnlike event
Browse files Browse the repository at this point in the history
  • Loading branch information
dahlia committed Feb 2, 2025
1 parent 4591cbc commit 80e8250
Show file tree
Hide file tree
Showing 7 changed files with 142 additions and 9 deletions.
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"unfollowed",
"unfollowing",
"unfollows",
"unliked",
"uuidv7",
"vitepress"
]
Expand Down
22 changes: 22 additions & 0 deletions docs/concepts/events.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,25 @@ bot.onLike = async (session, like) => {
);
};
~~~~


Unlike
------

The `~Bot.onUnlike` event handler is called when someone undoes a `Like`
activity on messages on your bot or actors your bot follows. It receives
a `Like` object, which represents the `Like` activity which was undone,
as the second argument.

The following is an example of an unlike event handler that sends a direct
message when someone undoes a like activity on a message on your bot:

~~~~ typescript
bot.onUnlike = async (session, like) => {
if (like.message.actor.id?.href !== session.actorId.href) return;
await session.publish(
text`I'm sorry to hear that you unliked my message, ${like.actor}.`,
{ visibility: "direct" },
);
};
~~~~
46 changes: 45 additions & 1 deletion src/bot-impl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1832,7 +1832,7 @@ Deno.test("BotImpl.onLiked()", async () => {
bot.onLike = (session, like) => void (likes.push([session, like]));
const ctx = createMockInboxContext(bot, "https://example.com", "bot");
const rawLike = new RawLike({
id: new URL("https://example.com/ap/actor/bot/announce/1"),
id: new URL("https://example.com/ap/actor/bot/like/1"),
actor: new URL("https://example.com/ap/actor/bot"),
object: new Note({
id: new URL("https://example.com/ap/actor/bot/note/1"),
Expand All @@ -1855,6 +1855,50 @@ Deno.test("BotImpl.onLiked()", async () => {
assertEquals(ctx.forwardedRecipients, []);
});

Deno.test("BotImpl.onUnliked()", async () => {
const bot = new BotImpl<void>({
kv: new MemoryKvStore(),
username: "bot",
});
const likes: [Session<void>, Like<void>][] = [];
bot.onUnlike = (session, like) => void (likes.push([session, like]));
const ctx = createMockInboxContext(bot, "https://example.com", "bot");
const rawLike = new RawLike({
id: new URL("https://example.com/ap/actor/bot/like/1"),
actor: new URL("https://example.com/ap/actor/bot"),
object: new Note({
id: new URL("https://example.com/ap/actor/bot/note/1"),
attribution: new URL("https://example.com/ap/actor/bot"),
to: PUBLIC_COLLECTION,
cc: new URL("https://example.com/ap/actor/bot/followers"),
content: "Hello, world!",
}),
});
const undo = new Undo({
id: new URL("https://example.com/ap/actor/bot/unlike/1"),
actor: new URL("https://example.com/ap/actor/bot"),
object: rawLike,
});
await bot.onUnliked(ctx, undo);
assertEquals(likes.length, 1);
const [session, like] = likes[0];
assertEquals(session.bot, bot);
assertEquals(session.context, ctx);
assertEquals(like.raw, rawLike);
assertEquals(like.id, rawLike.id);
assertEquals(like.actor.id, rawLike.actorId);
assertEquals(like.message.id, rawLike.objectId);
assertEquals(ctx.sentActivities, []);
assertEquals(ctx.forwardedRecipients, []);

likes.pop();
const invalidUndo = undo.clone({
actor: new URL("https://example.com/ap/actor/another"),
});
await bot.onUnliked(ctx, invalidUndo);
assertEquals(likes, []);
});

Deno.test("BotImpl.dispatchNodeInfo()", () => {
const bot = new BotImpl<void>({
kv: new MemoryKvStore(),
Expand Down
54 changes: 46 additions & 8 deletions src/bot-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import {
Reject,
} from "@fedify/fedify/vocab";
import { getXForwardedRequest } from "@hongminhee/x-forwarded-fetch";
import { getLogger } from "@logtape/logtape";
import metadata from "../deno.json" with { type: "json" };
import type { Bot, CreateBotOptions, PagesOptions } from "./bot.ts";
import type {
Expand All @@ -64,6 +65,7 @@ import type {
ReplyEventHandler,
SharedMessageEventHandler,
UnfollowEventHandler,
UnlikeEventHandler,
} from "./events.ts";
import { FollowRequestImpl } from "./follow-impl.ts";
import {
Expand Down Expand Up @@ -113,6 +115,7 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
onMessage?: MessageEventHandler<TContextData>;
onSharedMessage?: SharedMessageEventHandler<TContextData>;
onLike?: LikeEventHandler<TContextData>;
onUnlike?: UnlikeEventHandler<TContextData>;

constructor(options: BotImplOptions<TContextData>) {
this.identifier = options.identifier ?? "bot";
Expand Down Expand Up @@ -207,7 +210,18 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
this.federation
.setInboxListeners("/ap/actor/{identifier}/inbox", "/ap/inbox")
.on(Follow, this.onFollowed.bind(this))
.on(Undo, this.onUnfollowed.bind(this))
.on(Undo, async (ctx, undo) => {
const object = await undo.getObject(ctx);
if (object instanceof Follow) await this.onUnfollowed(ctx, undo);
else if (object instanceof RawLike) await this.onUnliked(ctx, undo);
else {
const logger = getLogger(["botkit", "bot", "inbox"]);
logger.warn(
"The Undo object {undoId} is not about Follow or Like: {object}.",
{ undoId: undo.id?.href, object },
);
}
})
.on(Accept, this.onFollowAccepted.bind(this))
.on(Reject, this.onFollowRejected.bind(this))
.on(Create, this.onCreated.bind(this))
Expand Down Expand Up @@ -677,8 +691,13 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
await this.onSharedMessage(session, sharedMessage);
}

async onLiked(ctx: InboxContext<TContextData>, like: RawLike): Promise<void> {
if (this.onLike == null || like.id == null || like.actorId == null) return;
async #parseLike(
ctx: InboxContext<TContextData>,
like: RawLike,
): Promise<
{ session: Session<TContextData>; like: Like<TContextData> } | undefined
> {
if (like.id == null || like.actorId == null) return undefined;
const objectUri = ctx.parseUri(like.objectId);
let object: Object | null = null;
if (
Expand All @@ -691,22 +710,41 @@ export class BotImpl<TContextData> implements Bot<TContextData> {
} else {
object = await like.getObject(ctx);
}
if (!isMessageObject(object)) return;
if (!isMessageObject(object)) return undefined;
const session = this.getSession(ctx);
const actor = like.actorId.href == session.actorId.href
? await session.getActor()
: await like.getActor(ctx);
if (actor == null) return;
const message = await createMessage(object, session, {});
await this.onLike(
return {
session,
{
like: {
raw: like,
id: like.id,
actor,
message,
} satisfies Like<TContextData>,
);
},
};
}

async onLiked(ctx: InboxContext<TContextData>, like: RawLike): Promise<void> {
if (this.onLike == null) return;
const sessionAndLike = await this.#parseLike(ctx, like);
if (sessionAndLike == null) return;
const { session, like: likeObject } = sessionAndLike;
await this.onLike(session, likeObject);
}

async onUnliked(ctx: InboxContext<TContextData>, undo: Undo): Promise<void> {
if (this.onUnlike == null) return;
const like = await undo.getObject(ctx);
if (!(like instanceof RawLike)) return;
if (undo.actorId?.href !== like.actorId?.href) return;
const sessionAndLike = await this.#parseLike(ctx, like);
if (sessionAndLike == null) return;
const { session, like: likeObject } = sessionAndLike;
await this.onUnlike(session, likeObject);
}

dispatchNodeInfo(_ctx: Context<TContextData>): NodeInfo {
Expand Down
5 changes: 5 additions & 0 deletions src/bot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ Deno.test("createBot()", async () => {
assertEquals(bot.onLike, onLike);
assertEquals(impl.onLike, onLike);

function onUnlike(_session: Session<void>, _like: Like<void>) {}
bot.onUnlike = onUnlike;
assertEquals(bot.onUnlike, onUnlike);
assertEquals(impl.onUnlike, onUnlike);

const response = await bot.fetch(
new Request(
"https://example.com/.well-known/webfinger?resource=acct:bot@example.com",
Expand Down
12 changes: 12 additions & 0 deletions src/bot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type {
ReplyEventHandler,
SharedMessageEventHandler,
UnfollowEventHandler,
UnlikeEventHandler,
} from "./events.ts";
import type { Repository } from "./repository.ts";
import type { Session } from "./session.ts";
Expand Down Expand Up @@ -133,6 +134,11 @@ export interface Bot<TContextData> {
* An event handler for a like of a message.
*/
onLike?: LikeEventHandler<TContextData>;

/**
* An event handler for an undoing of a like of a message.
*/
onUnlike?: UnlikeEventHandler<TContextData>;
}

/**
Expand Down Expand Up @@ -401,6 +407,12 @@ export function createBot<TContextData = void>(
set onLike(value) {
bot.onLike = value;
},
get onUnlike() {
return bot.onUnlike;
},
set onUnlike(value) {
bot.onUnlike = value;
},
} satisfies Bot<TContextData> & { impl: BotImpl<TContextData> };
// @ts-ignore: the wrapper implements BotWithVoidContextData
return wrapper;
Expand Down
11 changes: 11 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,14 @@ export type LikeEventHandler<TContextData> = (
session: Session<TContextData>,
like: Like<TContextData>,
) => void | Promise<void>;

/**
* An event handler for undoing a like of a message.
* @typeParam TContextData The type of the context data.
* @param session The session of the bot.
* @param like The like activity which is undone.
*/
export type UnlikeEventHandler<TContextData> = (
session: Session<TContextData>,
like: Like<TContextData>,
) => void | Promise<void>;

0 comments on commit 80e8250

Please sign in to comment.