feat: modernize and harden Lettia bot
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
tsconfig.json
|
||||
+6
-7
@@ -1,13 +1,12 @@
|
||||
FROM node:22-alpine
|
||||
FROM node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json /app
|
||||
COPY package-lock.json /app
|
||||
COPY .env /app
|
||||
COPY tsconfig.json /app
|
||||
COPY src /app/src
|
||||
COPY --chown=node:node package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
RUN npm i
|
||||
COPY --chown=node:node src ./src
|
||||
|
||||
USER node
|
||||
|
||||
CMD ["npm", "run", "start"]
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
TOKEN = 0 # Your bot token
|
||||
CLIENT_ID = 0 # Your bot client ID
|
||||
TOKEN= # Your bot token
|
||||
CLIENT_ID= # Your bot client ID
|
||||
|
||||
Generated
+597
-269
File diff suppressed because it is too large
Load Diff
+9
-5
@@ -1,14 +1,18 @@
|
||||
{
|
||||
"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",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.16.3",
|
||||
"dotenv": "^16.4.5"
|
||||
"discord.js": "14.27.0",
|
||||
"dotenv": "17.4.2",
|
||||
"tsx": "4.23.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.1",
|
||||
"typescript": "^5.6.3"
|
||||
"@types/node": "24.10.15",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApplicationCommandType, ContextMenuCommandBuilder, ContextMenuCommandType } from "discord.js";
|
||||
import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js";
|
||||
|
||||
export default [
|
||||
new ContextMenuCommandBuilder()
|
||||
.setName("Fontify")
|
||||
.setType(ApplicationCommandType.Message as ContextMenuCommandType)
|
||||
]
|
||||
.setType(ApplicationCommandType.Message),
|
||||
];
|
||||
|
||||
+6
-8
@@ -1,6 +1,6 @@
|
||||
export class Font {
|
||||
preprocesser?: (text: string) => string
|
||||
letters: { [key: string]: number }
|
||||
preprocesser?: (text: string) => string;
|
||||
letters: { [key: string]: number };
|
||||
constructor(patterns: { [key: string]: number | number[] }, preprocesser?: (text: string) => string) {
|
||||
this.preprocesser = preprocesser;
|
||||
this.letters = {};
|
||||
@@ -9,26 +9,24 @@ export class Font {
|
||||
if (v.length === 1) return v.charCodeAt(0);
|
||||
const [start, end] = v.split("-");
|
||||
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);
|
||||
}).filter((v) => v !== undefined);
|
||||
|
||||
for (const key of keys) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
let v = Array.isArray(value) ? value[0] : value;
|
||||
const v = Array.isArray(value) ? value[0] : value;
|
||||
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
@@ -1,47 +1,73 @@
|
||||
import 'dotenv/config'
|
||||
import "dotenv/config";
|
||||
const TOKEN = process.env.TOKEN;
|
||||
const CLIENT_ID = process.env.CLIENT_ID;
|
||||
if (!TOKEN || !CLIENT_ID) {
|
||||
console.error(`No ${TOKEN ? "CLIENT_ID" : "TOKEN"} provided`);
|
||||
process.exit();
|
||||
console.error(`Missing required environment variable: ${TOKEN ? "CLIENT_ID" : "TOKEN"}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
import {
|
||||
Client,
|
||||
Events,
|
||||
GatewayIntentBits,
|
||||
REST,
|
||||
Routes,
|
||||
Client,
|
||||
GatewayIntentBits,
|
||||
} from "discord.js";
|
||||
|
||||
import commands from "./context_commands.ts";
|
||||
const rest = new REST({ version: "10" }).setToken(TOKEN);
|
||||
try {
|
||||
console.log("refreshing slash commands...");
|
||||
await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });
|
||||
console.log("OK");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
console.log("Refreshing application commands...");
|
||||
await rest.put(Routes.applicationCommands(CLIENT_ID), { body: commands });
|
||||
console.log("Application commands refreshed.");
|
||||
|
||||
const client = new Client({
|
||||
intents: [
|
||||
GatewayIntentBits.Guilds,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
GatewayIntentBits.MessageContent,
|
||||
]
|
||||
],
|
||||
});
|
||||
|
||||
client.on("ready", () => {
|
||||
if (!client.user) {
|
||||
console.error("Failed to login");
|
||||
process.exit();
|
||||
}
|
||||
console.log(`Logged in as ${client.user.tag}!`);
|
||||
client.user.setActivity("kawaii fonts!");
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
console.log(
|
||||
`Logged in as ${readyClient.user.tag}; serving ${readyClient.guilds.cache.size} guild(s).`,
|
||||
);
|
||||
readyClient.user.setActivity("kawaii fonts!");
|
||||
});
|
||||
|
||||
import interactionCreate from './listener/interaction_create.ts';
|
||||
client.on("interactionCreate", interactionCreate);
|
||||
import interactionCreate from "./listener/interaction_create.ts";
|
||||
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);
|
||||
|
||||
@@ -1,65 +1,70 @@
|
||||
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 { formatTranslation, translateText } from "../translation.ts";
|
||||
|
||||
const proc_cache: {
|
||||
[id: string]: {
|
||||
resolve?: (value: string) => void,
|
||||
text: string
|
||||
}
|
||||
} = {};
|
||||
const FONTIFY_COMMAND = "Fontify";
|
||||
|
||||
export default async (interaction: Interaction<CacheType>) => {
|
||||
if (interaction.isMessageContextMenuCommand()) {
|
||||
if (interaction.commandName === "Fontify") {
|
||||
try {
|
||||
await interaction.deferReply({ ephemeral: false });
|
||||
await interaction.followUp({
|
||||
content: "-# " + interaction.targetMessage.id,
|
||||
components: [selection_compoent],
|
||||
})
|
||||
proc_cache[interaction.targetMessage.id] = { text: interaction.targetMessage.content };
|
||||
new Promise<string>((resolve) => {
|
||||
proc_cache[interaction.targetMessage.id].resolve = resolve;
|
||||
}).then((value) => {
|
||||
interaction.editReply({
|
||||
content: "```\n" + value + "\n```",
|
||||
components: [],
|
||||
});
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
} else if (interaction.isStringSelectMenu()) {
|
||||
if (interaction.customId === "font_selection") {
|
||||
export default async function interactionCreate(
|
||||
interaction: Interaction<CacheType>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
interaction.isMessageContextMenuCommand() &&
|
||||
interaction.commandName === FONTIFY_COMMAND
|
||||
) {
|
||||
await interaction.reply({
|
||||
content: "Processing...",
|
||||
ephemeral: true,
|
||||
});
|
||||
const selected = interaction.values[0];
|
||||
const font = list.find((f) => f.value === selected)?.font;
|
||||
if (!font) {
|
||||
await interaction.followUp({
|
||||
content: "Invalid font selected!",
|
||||
content: `-# ${interaction.targetMessage.id}`,
|
||||
components: [
|
||||
fontSelectionComponent(interaction.targetMessage.id, interaction.user.id),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const id = interaction.message.content.slice(3);
|
||||
const text = proc_cache[id].text;
|
||||
// console.log(text);
|
||||
const result = text.split("").map((c) => font.letters[c] ? String.fromCodePoint(font.letters[c]) : c).join("");
|
||||
// console.log(result);
|
||||
await interaction.deleteReply();
|
||||
if (proc_cache[id].resolve) proc_cache[id].resolve(result);
|
||||
else console.error(`Resolve function not found for id: ${id}`);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
|
||||
if (!interaction.isStringSelectMenu()) return;
|
||||
const selection = parseFontSelectionId(interaction.customId);
|
||||
if (!selection) return;
|
||||
|
||||
if (selection.requesterId !== interaction.user.id) {
|
||||
await interaction.reply({
|
||||
content: "Only the person who opened Fontify can choose this font.",
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = interaction.values[0];
|
||||
const font = list.find((item) => item.value === selected)?.font;
|
||||
if (!font) {
|
||||
await interaction.reply({ content: "That font is no longer available.", ephemeral: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceMessage = await interaction.message.channel.messages.fetch(
|
||||
selection.sourceMessageId,
|
||||
);
|
||||
const result = translateText(sourceMessage.content, font);
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })))
|
||||
);
|
||||
@@ -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] };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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```$/);
|
||||
});
|
||||
+4
-5
@@ -4,17 +4,16 @@
|
||||
"lib": [
|
||||
"ES2023"
|
||||
],
|
||||
"module": "node16",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true,
|
||||
|
||||
"outDir": "./dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
, "src/font_translater.js" ]
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user