feat: add all-fonts slash command
This commit is contained in:
@@ -19,6 +19,12 @@ npm start
|
|||||||
The global `Fontify` message command is refreshed at startup. In Discord, right-click a message,
|
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.
|
open Apps, select Fontify, and choose a font. Only the user who opened the menu can choose from it.
|
||||||
|
|
||||||
|
Use `/fontify-all text:Hello world` to post all 14 available styles together, with
|
||||||
|
font names and copyable code blocks. The text option accepts up to 200 characters.
|
||||||
|
Long results are automatically split into messages without truncation; mentions
|
||||||
|
are disabled. Characters without a font mapping (including Japanese) stay unchanged.
|
||||||
|
Both commands are registered globally at startup.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
Generated
+3
-3
@@ -1155,9 +1155,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.28.0",
|
"version": "6.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz",
|
||||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
"integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.17"
|
"node": ">=18.17"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import list from "./fontlist.ts";
|
||||||
|
import { translateText } from "./translation.ts";
|
||||||
|
|
||||||
|
export const ALL_FONTS_MAX_INPUT = 200;
|
||||||
|
|
||||||
|
/** Keep each labelled font together; never split Unicode or truncate output. */
|
||||||
|
export function formatAllFonts(text: string): string[] {
|
||||||
|
if (!text.trim() || text.length > ALL_FONTS_MAX_INPUT) {
|
||||||
|
throw new RangeError(`Text must contain 1–${ALL_FONTS_MAX_INPUT} characters.`);
|
||||||
|
}
|
||||||
|
const messages: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
for (const { label, font } of list) {
|
||||||
|
const translated = translateText(text, font).replaceAll("```", "``\u200b`");
|
||||||
|
const block = `**${label}**\n\`\`\`\n${translated}\n\`\`\``;
|
||||||
|
if (block.length > 2_000) throw new RangeError("Font output exceeds Discord's limit.");
|
||||||
|
if (current && current.length + 2 + block.length > 2_000) {
|
||||||
|
messages.push(current);
|
||||||
|
current = "";
|
||||||
|
}
|
||||||
|
current += `${current ? "\n\n" : ""}${block}`;
|
||||||
|
}
|
||||||
|
if (current) messages.push(current);
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { Interaction } from "discord.js";
|
||||||
|
import interactionCreate from "./listener/interaction_create.ts";
|
||||||
|
import { formatAllFonts } from "./all_fonts.ts";
|
||||||
|
|
||||||
|
test("slash command acknowledges first and posts every chunk without mentions", async () => {
|
||||||
|
const calls: { method: string; payload?: any }[] = [];
|
||||||
|
const text = "A".repeat(200);
|
||||||
|
const interaction = {
|
||||||
|
isChatInputCommand: () => true,
|
||||||
|
commandName: "fontify-all",
|
||||||
|
options: { getString: () => text },
|
||||||
|
deferReply: async () => { calls.push({ method: "defer" }); },
|
||||||
|
editReply: async (payload: any) => { calls.push({ method: "edit", payload }); },
|
||||||
|
followUp: async (payload: any) => { calls.push({ method: "follow", payload }); },
|
||||||
|
} as unknown as Interaction;
|
||||||
|
await interactionCreate(interaction);
|
||||||
|
assert.equal(calls[0].method, "defer");
|
||||||
|
assert.equal(calls[1].method, "edit");
|
||||||
|
assert.deepEqual(calls.slice(1).map((c) => c.payload.content), formatAllFonts(text));
|
||||||
|
assert.ok(calls.slice(2).every((c) => c.method === "follow"));
|
||||||
|
for (const call of calls.slice(1)) assert.deepEqual(call.payload.allowedMentions, { parse: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blank input produces only a private validation reply", async () => {
|
||||||
|
const calls: any[] = [];
|
||||||
|
const interaction = {
|
||||||
|
isChatInputCommand: () => true,
|
||||||
|
commandName: "fontify-all",
|
||||||
|
options: { getString: () => " " },
|
||||||
|
reply: async (payload: any) => { calls.push(payload); },
|
||||||
|
} as unknown as Interaction;
|
||||||
|
await interactionCreate(interaction);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0].ephemeral, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { formatAllFonts } from "./all_fonts.ts";
|
||||||
|
import list from "./fontlist.ts";
|
||||||
|
import { translateText } from "./translation.ts";
|
||||||
|
import commands from "./context_commands.ts";
|
||||||
|
|
||||||
|
test("all fonts appear exactly once, in catalogue order, with converted text", () => {
|
||||||
|
const text = "Hello 123 日本語 😀";
|
||||||
|
const output = formatAllFonts(text).join("\n\n");
|
||||||
|
assert.equal(output, list.map(({ label, font }) =>
|
||||||
|
`**${label}**\n\`\`\`\n${translateText(text, font)}\n\`\`\``
|
||||||
|
).join("\n\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("long input is split into bounded messages without dropping any font", () => {
|
||||||
|
const messages = formatAllFonts("A".repeat(200));
|
||||||
|
assert.ok(messages.length > 1);
|
||||||
|
assert.ok(messages.every((message) => message.length <= 2_000));
|
||||||
|
for (const { label, font } of list) {
|
||||||
|
assert.equal(messages.filter((m) => m.includes(`**${label}**\n`)).length, 1);
|
||||||
|
assert.ok(messages.some((m) => m.includes(translateText("A".repeat(200), font))));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("code fences are escaped and emoji stay intact", () => {
|
||||||
|
const messages = formatAllFonts("```😀".repeat(40));
|
||||||
|
for (const message of messages) {
|
||||||
|
assert.ok(message.length <= 2_000);
|
||||||
|
assert.ok(message.includes("``\u200b`😀"));
|
||||||
|
assert.ok(!message.includes("\uFFFD"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reject invalid input", () => {
|
||||||
|
for (const text of ["", " \n\t", "a".repeat(201)]) {
|
||||||
|
assert.throws(() => formatAllFonts(text), RangeError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("registration preserves Fontify and adds the bounded slash command", () => {
|
||||||
|
const definitions = commands.map((command) => command.toJSON());
|
||||||
|
assert.deepEqual(definitions.map((c) => c.name), ["Fontify", "fontify-all"]);
|
||||||
|
const slash = definitions[1];
|
||||||
|
assert.equal(slash.type, 1);
|
||||||
|
assert.equal(slash.options?.[0].name, "text");
|
||||||
|
});
|
||||||
+10
-1
@@ -1,7 +1,16 @@
|
|||||||
import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js";
|
import { ApplicationCommandType, ContextMenuCommandBuilder, SlashCommandBuilder } from "discord.js";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
new ContextMenuCommandBuilder()
|
new ContextMenuCommandBuilder()
|
||||||
.setName("Fontify")
|
.setName("Fontify")
|
||||||
.setType(ApplicationCommandType.Message),
|
.setType(ApplicationCommandType.Message),
|
||||||
|
new SlashCommandBuilder()
|
||||||
|
.setName("fontify-all")
|
||||||
|
.setDescription("Show your text in every available font / 全書体をまとめて表示")
|
||||||
|
.addStringOption((option) => option
|
||||||
|
.setName("text")
|
||||||
|
.setDescription("Text to convert / 変換する文字列(200文字まで)")
|
||||||
|
.setRequired(true)
|
||||||
|
.setMinLength(1)
|
||||||
|
.setMaxLength(200)),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import fontSelectionComponent, {
|
|||||||
} from "../selection_component.ts";
|
} from "../selection_component.ts";
|
||||||
import list from "../fontlist.ts";
|
import list from "../fontlist.ts";
|
||||||
import { formatTranslation, translateText } from "../translation.ts";
|
import { formatTranslation, translateText } from "../translation.ts";
|
||||||
|
import { ALL_FONTS_MAX_INPUT, formatAllFonts } from "../all_fonts.ts";
|
||||||
|
|
||||||
const FONTIFY_COMMAND = "Fontify";
|
const FONTIFY_COMMAND = "Fontify";
|
||||||
|
|
||||||
@@ -12,6 +13,24 @@ export default async function interactionCreate(
|
|||||||
interaction: Interaction<CacheType>,
|
interaction: Interaction<CacheType>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
if (interaction.isChatInputCommand() && interaction.commandName === "fontify-all") {
|
||||||
|
const text = interaction.options.getString("text", true);
|
||||||
|
if (!text.trim() || text.length > ALL_FONTS_MAX_INPUT) {
|
||||||
|
await interaction.reply({
|
||||||
|
content: `空白以外の文字を含む${ALL_FONTS_MAX_INPUT}文字以内のテキストを指定してください。`,
|
||||||
|
ephemeral: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await interaction.deferReply();
|
||||||
|
const messages = formatAllFonts(text);
|
||||||
|
await interaction.editReply({ content: messages[0], allowedMentions: { parse: [] } });
|
||||||
|
for (const content of messages.slice(1)) {
|
||||||
|
await interaction.followUp({ content, allowedMentions: { parse: [] } });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
interaction.isMessageContextMenuCommand() &&
|
interaction.isMessageContextMenuCommand() &&
|
||||||
interaction.commandName === FONTIFY_COMMAND
|
interaction.commandName === FONTIFY_COMMAND
|
||||||
|
|||||||
Reference in New Issue
Block a user