Skip to content

Commit e4733b5

Browse files
committed
Custom emojis
1 parent d1f283d commit e4733b5

File tree

13 files changed

+819
-47
lines changed

13 files changed

+819
-47
lines changed

CHANGES.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,21 @@ To be released.
88

99
- Image attachments in posts became shown in the web interface.
1010

11+
- Added custom emoji support.
12+
13+
- The return type of `Text.getTags()` method became
14+
`AsyncIterable<Link | Object>` (was `AsyncIterable<Link>`).
15+
- Added `Bot.addCustomEmojis()` method.
16+
- Added `CustomEmojiText` class.
17+
- Added `customEmoji()` function.
18+
- Added `CustomEmojiBase` interface.
19+
- Added `CustomEmojiFromUrl` interface.
20+
- Added `CustomEmojiFromFile` interface.
21+
- Added `CustomEmoji` type.
22+
- Added `DeferredEmoji` type.
23+
- Added `Emoji` class.
24+
25+
1126

1227
Version 0.1.1
1328
-------------

deno.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"exports": {
1010
".": "./src/mod.ts",
1111
"./bot": "./src/bot.ts",
12+
"./emoji": "./src/emoji.ts",
1213
"./events": "./src/events.ts",
1314
"./follow": "./src/follow.ts",
1415
"./message": "./src/message.ts",
@@ -26,6 +27,7 @@
2627
"@phensley/language-tag": "npm:@phensley/language-tag@^1.9.2",
2728
"@std/assert": "jsr:@std/assert@^1.0.10",
2829
"@std/html": "jsr:@std/html@^1.0.3",
30+
"@std/media-types": "jsr:@std/media-types@^1.1.0",
2931
"@std/uuid": "jsr:@std/uuid@^1.0.4",
3032
"markdown-it": "npm:markdown-it@^14.1.0",
3133
"xss": "npm:xss@^1.0.15"

docs/concepts/text.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,39 @@ The above code will create a text like this:
401401
> It is not a code block, but an inline code.
402402
403403

404+
Custom emojis
405+
-------------
406+
407+
*This API is available since BotKit 0.2.0.*
408+
409+
You can include a custom emoji in the text using the `customEmoji()` function.
410+
It is an inline construct.
411+
412+
In order to use the `customEmoji()` function, you need to define custom emojis
413+
first. You can define custom emojis by using the `Bot.addCustomEmojis()`
414+
method after creating the bot:
415+
416+
~~~~ typescript
417+
// Define custom emojis:
418+
const emojis = bot.addCustomEmojis({
419+
// Use a local image file:
420+
botkit: { file: `${import.meta.dirname}/botkit.png`, type: "image/png" },
421+
// Use a remote image URL:
422+
fedify: { url: "https://fedify.dev/logo.png", type: "image/png" },
423+
});
424+
~~~~
425+
426+
The `~Bot.addCustomEmojis()` method returns an object that contains the custom
427+
emojis. You can use the keys of the object to refer to the custom emojis.
428+
For example:
429+
430+
~~~~ typescript
431+
text`Here's a custom emoji:
432+
433+
${customEmoji(emojis.botkit)} by ${customEmoji(emojis.fedify)}.`
434+
~~~~
435+
436+
404437
Markdown
405438
--------
406439

examples/greet.ts

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
import { createBot, hashtag, Image, link, mention, text } from "@fedify/botkit";
1+
import {
2+
createBot,
3+
customEmoji,
4+
hashtag,
5+
Image,
6+
link,
7+
mention,
8+
text,
9+
} from "@fedify/botkit";
210
import { DenoKvMessageQueue, DenoKvStore } from "@fedify/fedify/x/denokv";
311

412
const kv = await Deno.openKv();
@@ -25,9 +33,18 @@ const bot = createBot<void>({
2533
pages: { color: "green" },
2634
});
2735

36+
const emojis = bot.addCustomEmojis({
37+
botkit: {
38+
type: "image/png",
39+
file: `${import.meta.dirname}/../docs/public/favicon-192x192.png`,
40+
},
41+
});
42+
2843
bot.onFollow = async (session, followRequest) => {
2944
await session.publish(
30-
text`Thanks for following me, ${followRequest.follower}!`,
45+
text`Thanks for following me, ${followRequest.follower}! ${
46+
customEmoji(emojis.botkit)
47+
}`,
3148
{
3249
visibility: "direct",
3350
attachments: [
@@ -46,15 +63,20 @@ bot.onFollow = async (session, followRequest) => {
4663
};
4764

4865
bot.onUnfollow = async (session, follower) => {
49-
await session.publish(text`Goodbye, ${follower}!`, {
50-
visibility: "direct",
51-
});
66+
await session.publish(
67+
text`Goodbye, ${follower}! ${customEmoji(emojis.botkit)}`,
68+
{ visibility: "direct" },
69+
);
5270
};
5371

5472
bot.onReply = async (session, message) => {
5573
const botUri = session.actorId.href;
5674
if (message.mentions.some((a) => a.id?.href === botUri)) return;
57-
await message.reply(text`Thanks for your reply, ${message.actor}!`);
75+
await message.reply(
76+
text`Thanks for your reply, ${message.actor}! ${
77+
customEmoji(emojis.botkit)
78+
}`,
79+
);
5880
};
5981

6082
bot.onMention = async (_session, message) => {
@@ -64,9 +86,9 @@ bot.onMention = async (_session, message) => {
6486
const session = bot.getSession(Deno.env.get("ORIGIN") ?? "http://localhost");
6587
setInterval(async () => {
6688
const message = await session.publish(
67-
text`Hi, folks! It's a minutely greeting. It will be deleted in 30 seconds. ${
68-
hashtag("greet")
69-
}`,
89+
text`Hi, folks! It's a minutely greeting. It will be deleted in 30 seconds. ${
90+
customEmoji(emojis.botkit)
91+
} ${hashtag("greet")}`,
7092
);
7193
setTimeout(async () => {
7294
await message.delete();

src/bot-impl.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
Article,
2323
Create,
2424
CryptographicKey,
25+
Emoji,
2526
Follow,
2627
Image,
2728
Like as RawLike,
@@ -40,8 +41,10 @@ import { assert } from "@std/assert/assert";
4041
import { assertEquals } from "@std/assert/equals";
4142
import { assertFalse } from "@std/assert/false";
4243
import { assertInstanceOf } from "@std/assert/instance-of";
44+
import { assertThrows } from "@std/assert/throws";
4345
import { BotImpl } from "./bot-impl.ts";
4446
import { parseSemVer } from "./bot.ts";
47+
import type { CustomEmoji } from "./emoji.ts";
4548
import type { FollowRequest } from "./follow.ts";
4649
import type { Message, MessageClass, SharedMessage } from "./message.ts";
4750
import type { Like } from "./reaction.ts";
@@ -1963,6 +1966,171 @@ Deno.test("BotImpl.fetch()", async () => {
19631966
assertEquals(response2.status, 200);
19641967
});
19651968

1969+
// Test BotImpl.addCustomEmoji() and BotImpl.addCustomEmojis()
1970+
Deno.test("BotImpl.addCustomEmoji(), BotImpl.addCustomEmojis()", async (t) => {
1971+
const bot = new BotImpl<void>({ kv: new MemoryKvStore(), username: "bot" });
1972+
1973+
await t.step("addCustomEmoji()", () => {
1974+
const emojiData: CustomEmoji = {
1975+
type: "image/png",
1976+
url: "https://example.com/emoji.png",
1977+
};
1978+
const deferredEmoji = bot.addCustomEmoji("testEmoji", emojiData);
1979+
assertEquals(typeof deferredEmoji, "function");
1980+
assertEquals(bot.customEmojis["testEmoji"], emojiData);
1981+
1982+
// Test invalid name
1983+
assertThrows(
1984+
() => bot.addCustomEmoji("invalid name", emojiData),
1985+
TypeError,
1986+
"Invalid custom emoji name",
1987+
);
1988+
1989+
// Test duplicate name
1990+
assertThrows(
1991+
() => bot.addCustomEmoji("testEmoji", emojiData),
1992+
TypeError,
1993+
"Duplicate custom emoji name",
1994+
);
1995+
1996+
// Test unsupported media type
1997+
assertThrows(
1998+
() =>
1999+
bot.addCustomEmoji("invalidType", {
2000+
// @ts-expect-error: Intended type error for testing runtime check
2001+
type: "text/plain",
2002+
url: "https://example.com/emoji.txt",
2003+
}),
2004+
TypeError,
2005+
"Unsupported media type",
2006+
);
2007+
});
2008+
2009+
await t.step("addCustomEmojis()", () => {
2010+
const emojisData = {
2011+
emoji1: { type: "image/png", url: "https://example.com/emoji1.png" },
2012+
emoji2: { type: "image/gif", file: "/path/to/emoji2.gif" },
2013+
} as const;
2014+
const deferredEmojis = bot.addCustomEmojis(emojisData);
2015+
2016+
assertEquals(typeof deferredEmojis["emoji1"], "function");
2017+
assertEquals(typeof deferredEmojis["emoji2"], "function");
2018+
assertEquals(bot.customEmojis["emoji1"], emojisData.emoji1);
2019+
assertEquals(bot.customEmojis["emoji2"], emojisData.emoji2);
2020+
2021+
// Test duplicate name within the batch
2022+
assertThrows(
2023+
() =>
2024+
bot.addCustomEmojis({
2025+
emoji1: { type: "image/png", url: "https://example.com/dup1.png" },
2026+
}),
2027+
TypeError,
2028+
"Duplicate custom emoji name: emoji1",
2029+
);
2030+
});
2031+
});
2032+
2033+
// Test BotImpl.getEmoji()
2034+
Deno.test("BotImpl.getEmoji()", async () => {
2035+
const bot = new BotImpl<void>({ kv: new MemoryKvStore(), username: "bot" });
2036+
const ctx = bot.federation.createContext(
2037+
new URL("https://example.com"),
2038+
undefined,
2039+
);
2040+
2041+
// Test with remote URL
2042+
const remoteEmojiData: CustomEmoji = {
2043+
type: "image/png",
2044+
url: "https://remote.com/emoji.png",
2045+
};
2046+
bot.customEmojis["remoteEmoji"] = remoteEmojiData;
2047+
const remoteEmoji = bot.getEmoji(ctx, "remoteEmoji", remoteEmojiData);
2048+
assertInstanceOf(remoteEmoji, Emoji);
2049+
assertEquals(
2050+
remoteEmoji.id,
2051+
new URL("https://example.com/ap/emoji/remoteEmoji"),
2052+
);
2053+
assertEquals(remoteEmoji.name, ":remoteEmoji:");
2054+
const icon = await remoteEmoji.getIcon();
2055+
assertInstanceOf(icon, Image);
2056+
assertEquals(icon.mediaType, "image/png");
2057+
assertEquals(icon.url?.href, "https://remote.com/emoji.png");
2058+
2059+
// Test with local file
2060+
const localEmojiData: CustomEmoji = {
2061+
type: "image/gif",
2062+
file: "/path/to/local/emoji.gif",
2063+
};
2064+
bot.customEmojis["localEmoji"] = localEmojiData;
2065+
const localEmoji = bot.getEmoji(ctx, "localEmoji", localEmojiData);
2066+
assertInstanceOf(localEmoji, Emoji);
2067+
assertEquals(
2068+
localEmoji.id,
2069+
new URL("https://example.com/ap/emoji/localEmoji"),
2070+
);
2071+
assertEquals(localEmoji.name, ":localEmoji:");
2072+
const icon2 = await localEmoji.getIcon();
2073+
assertInstanceOf(icon2, Image);
2074+
assertEquals(icon2.mediaType, "image/gif");
2075+
assertEquals(
2076+
icon2.url?.href,
2077+
"https://example.com/emojis/localEmoji.gif",
2078+
);
2079+
2080+
// Test with local file without extension mapping
2081+
const localEmojiDataNoExt: CustomEmoji = {
2082+
type: "image/webp",
2083+
file: "/path/to/local/emoji",
2084+
};
2085+
bot.customEmojis["localEmojiNoExt"] = localEmojiDataNoExt;
2086+
const localEmojiNoExt = bot.getEmoji(
2087+
ctx,
2088+
"localEmojiNoExt",
2089+
localEmojiDataNoExt,
2090+
);
2091+
const icon3 = await localEmojiNoExt.getIcon();
2092+
assertInstanceOf(icon3, Image);
2093+
assertEquals(icon3.mediaType, "image/webp");
2094+
assertEquals(
2095+
icon3.url?.href,
2096+
"https://example.com/emojis/localEmojiNoExt.webp",
2097+
);
2098+
});
2099+
2100+
// Test BotImpl.dispatchEmoji()
2101+
Deno.test("BotImpl.dispatchEmoji()", () => {
2102+
const bot = new BotImpl<void>({ kv: new MemoryKvStore(), username: "bot" });
2103+
const ctx = bot.federation.createContext(
2104+
new URL("https://example.com"),
2105+
undefined,
2106+
);
2107+
const emojiData: CustomEmoji = {
2108+
type: "image/png",
2109+
url: "https://example.com/emoji.png",
2110+
};
2111+
bot.customEmojis["testEmoji"] = emojiData;
2112+
2113+
// Test dispatching an existing emoji
2114+
const emoji = bot.dispatchEmoji(ctx, { name: "testEmoji" });
2115+
assertInstanceOf(emoji, Emoji);
2116+
assertEquals(emoji.id, new URL("https://example.com/ap/emoji/testEmoji"));
2117+
assertEquals(emoji.name, ":testEmoji:");
2118+
2119+
// Test dispatching a non-existent emoji
2120+
const nonExistent = bot.dispatchEmoji(ctx, { name: "nonExistent" });
2121+
assertEquals(nonExistent, null);
2122+
});
2123+
2124+
Deno.test("BotImpl.getFollowersFirstCursor()", () => {
2125+
const bot = new BotImpl<void>({ kv: new MemoryKvStore(), username: "bot" });
2126+
const ctx = bot.federation.createContext(
2127+
new URL("https://example.com"),
2128+
undefined,
2129+
);
2130+
assertEquals(bot.getFollowersFirstCursor(ctx, "non-existent"), null);
2131+
assertEquals(bot.getFollowersFirstCursor(ctx, "bot"), "0");
2132+
});
2133+
19662134
interface SentActivity {
19672135
recipients: "followers" | Recipient[];
19682136
activity: Activity;

0 commit comments

Comments
 (0)