feat: modernize and harden Lettia bot

This commit is contained in:
2026-08-13 23:58:24 +09:00
parent 974677b463
commit 44dada3e23
16 changed files with 873 additions and 393 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
.env
node_modules
npm-debug.log
README.md
tsconfig.json
+7 -8
View File
@@ -1,13 +1,12 @@
FROM node:22-alpine FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
WORKDIR /app WORKDIR /app
COPY package.json /app COPY --chown=node:node package.json package-lock.json ./
COPY package-lock.json /app RUN npm ci --omit=dev && npm cache clean --force
COPY .env /app
COPY tsconfig.json /app
COPY src /app/src
RUN npm i COPY --chown=node:node src ./src
CMD ["npm", "run", "start"] USER node
CMD ["npm", "run", "start"]
+30
View File
@@ -0,0 +1,30 @@
# discord-lettia
A Discord message context-menu bot that converts message text into selectable Unicode font styles.
## Discord setup
Create a bot application with the Guilds, Guild Messages, and privileged Message Content intents.
The bot needs View Channel, Read Message History, and Send Messages permissions in channels where
Fontify is used.
Set the application credentials and start the bot:
```sh
cp default.env .env
npm ci
npm start
```
The global `Fontify` message command is refreshed at startup. In Discord, right-click a message,
open Apps, select Fontify, and choose a font. Only the user who opened the menu can choose from it.
## Develop
```sh
npm ci
npm run check
docker build -t discord-lettia .
```
Secrets are supplied at runtime and are not copied into the Docker image.
+2 -2
View File
@@ -1,2 +1,2 @@
TOKEN = 0 # Your bot token TOKEN= # Your bot token
CLIENT_ID = 0 # Your bot client ID CLIENT_ID= # Your bot client ID
+597 -269
View File
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -1,14 +1,18 @@
{ {
"scripts": { "scripts": {
"start": "tsx src/index.ts" "start": "tsx src/index.ts",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test src/*_test.ts",
"check": "npm run typecheck && npm test"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"discord.js": "^14.16.3", "discord.js": "14.27.0",
"dotenv": "^16.4.5" "dotenv": "17.4.2",
"tsx": "4.23.12"
}, },
"devDependencies": { "devDependencies": {
"tsx": "^4.19.1", "@types/node": "24.10.15",
"typescript": "^5.6.3" "typescript": "7.0.2"
} }
} }
+3 -3
View File
@@ -1,7 +1,7 @@
import { ApplicationCommandType, ContextMenuCommandBuilder, ContextMenuCommandType } from "discord.js"; import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js";
export default [ export default [
new ContextMenuCommandBuilder() new ContextMenuCommandBuilder()
.setName("Fontify") .setName("Fontify")
.setType(ApplicationCommandType.Message as ContextMenuCommandType) .setType(ApplicationCommandType.Message),
] ];
+7 -9
View File
@@ -1,6 +1,6 @@
export class Font { export class Font {
preprocesser?: (text: string) => string preprocesser?: (text: string) => string;
letters: { [key: string]: number } letters: { [key: string]: number };
constructor(patterns: { [key: string]: number | number[] }, preprocesser?: (text: string) => string) { constructor(patterns: { [key: string]: number | number[] }, preprocesser?: (text: string) => string) {
this.preprocesser = preprocesser; this.preprocesser = preprocesser;
this.letters = {}; this.letters = {};
@@ -9,26 +9,24 @@ export class Font {
if (v.length === 1) return v.charCodeAt(0); if (v.length === 1) return v.charCodeAt(0);
const [start, end] = v.split("-"); const [start, end] = v.split("-");
if (start === undefined || end === undefined || end.length !== 1 || start.length !== 1) if (start === undefined || end === undefined || end.length !== 1 || start.length !== 1)
return return;
return [...Array(end.charCodeAt(0) - start.charCodeAt(0) + 1).keys()].map((i) => start.charCodeAt(0) + i); return [...Array(end.charCodeAt(0) - start.charCodeAt(0) + 1).keys()].map((i) => start.charCodeAt(0) + i);
}).filter((v) => v !== undefined); }).filter((v) => v !== undefined);
for (const key of keys) { for (const key of keys) {
if (Array.isArray(key)) { if (Array.isArray(key)) {
if (Array.isArray(value)) for (let [i, v] of key.entries()) { if (Array.isArray(value)) for (const [i, v] of key.entries()) {
if (value[i] === undefined) continue; if (value[i] === undefined) continue;
this.letters[String.fromCharCode(v)] = value[i]; this.letters[String.fromCharCode(v)] = value[i];
} }
else for (let [i, v] of key.entries()) { else for (const [i, v] of key.entries()) {
this.letters[String.fromCharCode(v)] = value + i; this.letters[String.fromCharCode(v)] = value + i;
} }
} else { } else {
let v = Array.isArray(value) ? value[0] : value; const v = Array.isArray(value) ? value[0] : value;
this.letters[String.fromCharCode(key)] = v; this.letters[String.fromCharCode(key)] = v;
} }
} }
} }
console.log(Object.entries(this.letters).map(([k, v]) => `${k}`).join(""));
console.log(Object.entries(this.letters).map(([k, v]) => `${String.fromCodePoint(v)}`).join(""));
} }
} }
+50 -24
View File
@@ -1,47 +1,73 @@
import 'dotenv/config' import "dotenv/config";
const TOKEN = process.env.TOKEN; const TOKEN = process.env.TOKEN;
const CLIENT_ID = process.env.CLIENT_ID; const CLIENT_ID = process.env.CLIENT_ID;
if (!TOKEN || !CLIENT_ID) { if (!TOKEN || !CLIENT_ID) {
console.error(`No ${TOKEN ? "CLIENT_ID" : "TOKEN"} provided`); console.error(`Missing required environment variable: ${TOKEN ? "CLIENT_ID" : "TOKEN"}`);
process.exit(); process.exit(1);
} }
import { import {
Client,
Events,
GatewayIntentBits,
REST, REST,
Routes, Routes,
Client,
GatewayIntentBits,
} from "discord.js"; } from "discord.js";
import commands from "./context_commands.ts"; import commands from "./context_commands.ts";
const rest = new REST({ version: "10" }).setToken(TOKEN); const rest = new REST({ version: "10" }).setToken(TOKEN);
try { console.log("Refreshing application commands...");
console.log("refreshing slash commands..."); await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });
await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands }); console.log("Application commands refreshed.");
console.log("OK");
} catch (error) {
console.error(error);
}
const client = new Client({ const client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.GuildMessageReactions,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
] ],
}); });
client.on("ready", () => { client.once(Events.ClientReady, (readyClient) => {
if (!client.user) { console.log(
console.error("Failed to login"); `Logged in as ${readyClient.user.tag}; serving ${readyClient.guilds.cache.size} guild(s).`,
process.exit(); );
} readyClient.user.setActivity("kawaii fonts!");
console.log(`Logged in as ${client.user.tag}!`);
client.user.setActivity("kawaii fonts!");
}); });
import interactionCreate from './listener/interaction_create.ts'; import interactionCreate from "./listener/interaction_create.ts";
client.on("interactionCreate", interactionCreate); client.on(Events.InteractionCreate, interactionCreate);
client.on(Events.Error, (error) => console.error("Discord client error:", error));
client.on(Events.Warn, (message) => console.warn("Discord client warning:", message));
client.on(Events.ShardError, (error, shardId) => {
console.error(`Discord shard ${shardId} error:`, error);
});
client.on(Events.ShardDisconnect, (event, shardId) => {
console.warn(`Discord shard ${shardId} disconnected with code ${event.code}.`);
});
client.on(Events.ShardReconnecting, (shardId) => {
console.warn(`Discord shard ${shardId} reconnecting.`);
});
client.on(Events.ShardResume, (shardId, replayedEvents) => {
console.log(`Discord shard ${shardId} resumed; replayed ${replayedEvents} event(s).`);
});
client.login(TOKEN); for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.once(signal, () => {
console.log(`Received ${signal}; closing Discord connection.`);
client.destroy();
process.exit(0);
});
}
process.on("unhandledRejection", (reason) => {
console.error("Unhandled promise rejection:", reason);
});
process.on("uncaughtException", (error) => {
console.error("Uncaught exception:", error);
client.destroy();
process.exit(1);
});
await client.login(TOKEN);
+62 -57
View File
@@ -1,65 +1,70 @@
import { CacheType, Interaction } from "discord.js"; import { CacheType, Interaction } from "discord.js";
import selection_compoent from "../selection_compoent.ts"; import fontSelectionComponent, {
parseFontSelectionId,
} from "../selection_component.ts";
import list from "../fontlist.ts"; import list from "../fontlist.ts";
import { formatTranslation, translateText } from "../translation.ts";
const proc_cache: { const FONTIFY_COMMAND = "Fontify";
[id: string]: {
resolve?: (value: string) => void,
text: string
}
} = {};
export default async (interaction: Interaction<CacheType>) => { export default async function interactionCreate(
if (interaction.isMessageContextMenuCommand()) { interaction: Interaction<CacheType>,
if (interaction.commandName === "Fontify") { ): Promise<void> {
try { try {
await interaction.deferReply({ ephemeral: false }); if (
await interaction.followUp({ interaction.isMessageContextMenuCommand() &&
content: "-# " + interaction.targetMessage.id, interaction.commandName === FONTIFY_COMMAND
components: [selection_compoent], ) {
}) await interaction.reply({
proc_cache[interaction.targetMessage.id] = { text: interaction.targetMessage.content }; content: `-# ${interaction.targetMessage.id}`,
new Promise<string>((resolve) => { components: [
proc_cache[interaction.targetMessage.id].resolve = resolve; fontSelectionComponent(interaction.targetMessage.id, interaction.user.id),
}).then((value) => { ],
interaction.editReply({ });
content: "```\n" + value + "\n```", return;
components: [],
});
}).catch((e) => {
console.error(e);
});
} catch (e) {
console.error(e);
}
} }
} else if (interaction.isStringSelectMenu()) {
if (interaction.customId === "font_selection") { if (!interaction.isStringSelectMenu()) return;
try { const selection = parseFontSelectionId(interaction.customId);
await interaction.reply({ if (!selection) return;
content: "Processing...",
ephemeral: true, if (selection.requesterId !== interaction.user.id) {
}); await interaction.reply({
const selected = interaction.values[0]; content: "Only the person who opened Fontify can choose this font.",
const font = list.find((f) => f.value === selected)?.font; ephemeral: true,
if (!font) { });
await interaction.followUp({ return;
content: "Invalid font selected!", }
});
return; const selected = interaction.values[0];
} const font = list.find((item) => item.value === selected)?.font;
const id = interaction.message.content.slice(3); if (!font) {
const text = proc_cache[id].text; await interaction.reply({ content: "That font is no longer available.", ephemeral: true });
// console.log(text); return;
const result = text.split("").map((c) => font.letters[c] ? String.fromCodePoint(font.letters[c]) : c).join(""); }
// console.log(result);
await interaction.deleteReply(); const sourceMessage = await interaction.message.channel.messages.fetch(
if (proc_cache[id].resolve) proc_cache[id].resolve(result); selection.sourceMessageId,
else console.error(`Resolve function not found for id: ${id}`); );
} catch (e) { const result = translateText(sourceMessage.content, font);
console.error(e); await interaction.update({ content: formatTranslation(result), components: [] });
} } catch (error) {
console.error(
`Interaction ${interaction.id} failed in guild ${interaction.guildId ?? "DM"}:`,
error,
);
if (!interaction.isRepliable()) return;
const payload = { content: "Fontify failed. Please try again.", ephemeral: true } as const;
if (interaction.deferred || interaction.replied) {
await interaction.followUp(payload).catch((replyError) => {
console.error(`Failed to report interaction ${interaction.id} error:`, replyError);
});
} else {
await interaction.reply(payload).catch((replyError) => {
console.error(`Failed to report interaction ${interaction.id} error:`, replyError);
});
} }
} }
} }
-10
View File
@@ -1,10 +0,0 @@
import { ActionRowBuilder, StringSelectMenuBuilder } from 'discord.js';
import list from './fontlist.ts';
export default new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId('font_selection')
.setPlaceholder('Select a font')
.addOptions(list.map(({ label, value }) => ({ label, value })))
);
+25
View File
@@ -0,0 +1,25 @@
import { ActionRowBuilder, StringSelectMenuBuilder } from "discord.js";
import list from "./fontlist.ts";
const CUSTOM_ID_PREFIX = "font_selection";
export default function fontSelectionComponent(
sourceMessageId: string,
requesterId: string,
): ActionRowBuilder<StringSelectMenuBuilder> {
return new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
new StringSelectMenuBuilder()
.setCustomId(`${CUSTOM_ID_PREFIX}:${sourceMessageId}:${requesterId}`)
.setPlaceholder("Select a font")
.addOptions(list.map(({ label, value }) => ({ label, value }))),
);
}
export function parseFontSelectionId(
customId: string,
): { sourceMessageId: string; requesterId: string } | undefined {
const match = customId.match(/^font_selection:(\d{17,20}):(\d{17,20})$/);
if (!match) return undefined;
return { sourceMessageId: match[1], requesterId: match[2] };
}
+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { parseFontSelectionId } from "./selection_component.ts";
test("parseFontSelectionId extracts source and requester IDs", () => {
assert.deepEqual(
parseFontSelectionId("font_selection:12345678901234567:12345678901234567890"),
{
sourceMessageId: "12345678901234567",
requesterId: "12345678901234567890",
},
);
});
test("parseFontSelectionId rejects unrelated or malformed component IDs", () => {
assert.equal(parseFontSelectionId("other:12345678901234567:12345678901234567"), undefined);
assert.equal(parseFontSelectionId("font_selection:not-an-id:12345678901234567"), undefined);
});
+29
View File
@@ -0,0 +1,29 @@
import { Font } from "./font.ts";
const MAX_DISCORD_MESSAGE_LENGTH = 2_000;
const CODE_BLOCK_PREFIX = "```\n";
const CODE_BLOCK_SUFFIX = "\n```";
export function translateText(text: string, font: Font): string {
const input = font.preprocesser?.(text) ?? text;
return [...input]
.map((character) => {
const codePoint = font.letters[character];
return codePoint === undefined ? character : String.fromCodePoint(codePoint);
})
.join("");
}
export function formatTranslation(value: string): string {
const escaped = value.replaceAll("```", "``\u200b`");
const available = MAX_DISCORD_MESSAGE_LENGTH - CODE_BLOCK_PREFIX.length -
CODE_BLOCK_SUFFIX.length;
if (escaped.length <= available) {
return `${CODE_BLOCK_PREFIX}${escaped}${CODE_BLOCK_SUFFIX}`;
}
let truncated = escaped.slice(0, available - 1);
const finalCodeUnit = truncated.charCodeAt(truncated.length - 1);
if (finalCodeUnit >= 0xD800 && finalCodeUnit <= 0xDBFF) truncated = truncated.slice(0, -1);
return `${CODE_BLOCK_PREFIX}${truncated}${CODE_BLOCK_SUFFIX}`;
}
+21
View File
@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { Font } from "./font.ts";
import { formatTranslation, translateText } from "./translation.ts";
test("translateText replaces configured characters and preserves the rest", () => {
const font = new Font({ "a-c": 0x1D5EE });
assert.equal(translateText("abc!", font), "𝗮𝗯𝗰!");
});
test("translateText applies a font preprocessor", () => {
const font = new Font({ a: 0x1D5EE }, (value) => value.toLowerCase());
assert.equal(translateText("A", font), "𝗮");
});
test("formatTranslation stays within Discord's message limit", () => {
const output = formatTranslation("𝗮".repeat(2_000));
assert.ok(output.length <= 2_000);
assert.match(output, /…\n```$/);
});
+5 -6
View File
@@ -4,17 +4,16 @@
"lib": [ "lib": [
"ES2023" "ES2023"
], ],
"module": "node16", "module": "NodeNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"noEmit": true, "noEmit": true
"outDir": "./dist",
}, },
"include": [ "include": [
"src/**/*.ts" "src/**/*.ts"
, "src/font_translater.js" ] ]
} }