Skip to content

Commit

Permalink
feat: Add command /context to search for words in context. (#357)
Browse files Browse the repository at this point in the history
Closes #336.
  • Loading branch information
vxern authored Jul 24, 2024
2 parents 5531c32 + 1a0f2d3 commit 8b1884e
Show file tree
Hide file tree
Showing 18 changed files with 450 additions and 89 deletions.
11 changes: 11 additions & 0 deletions assets/localisations/commands/eng-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,17 @@
"word.strings.sourcedResponsibly.dictionaries.one": "the dictionary above",
"word.strings.sourcedResponsibly.dictionaries.two": "the dictionaries above",
"word.strings.sourcedResponsibly.dictionaries.many": "the dictionaries above",
"context.name": "context",
"context.description": "Search for a phrase to see it used in context.",
"context.options.phrase.name": "phrase",
"context.options.phrase.description": "The phrase to search for.",
"context.options.language.name": "language",
"context.options.language.description": "The language to search for the phrase in.",
"context.options.caseSensitive.name": "case-sensitive",
"context.options.caseSensitive.description": "Whether to make the search case-sensitive.",
"context.strings.noSentencesFound.title": "No sentences found",
"context.strings.noSentencesFound.description": "Could not find sentences containing the specified phrase.",
"context.strings.phraseInContext.title": "Phrase '{phrase}' in context",
"list.name": "list",
"list.description": "Allows the viewing of various information about users.",
"list.options.warnings.name": "warnings",
Expand Down
5 changes: 5 additions & 0 deletions source/constants/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import dictionaries from "logos:constants/dictionaries";
import emojis from "logos:constants/emojis";
import endpoints from "logos:constants/endpoints";
import gifs from "logos:constants/gifs";
import keys from "logos:constants/keys";
import languages from "logos:constants/languages";
import licences from "logos:constants/licences";
import links from "logos:constants/links";
Expand All @@ -34,12 +35,15 @@ const constants = Object.freeze({
MAXIMUM_HISTORY_ENTRIES: 100,
MAXIMUM_QUEUE_ENTRIES: 100,
MAXIMUM_EMBED_FIELD_LENGTH: 1024,
MAXIMUM_EMBED_DESCRIPTION_LENGTH: 3072,
RESULTS_PER_PAGE: 10,
STATUS_CYCLE_PERIOD: 1000 * 10, // 10 seconds in milliseconds.
INTERACTION_TOKEN_EXPIRY: 1000 * 60 * 15 - 1000 * 10, // 14 minutes, 50 seconds in milliseconds.
SLOWMODE_COLLISION_TIMEOUT: 1000 * 20, // 20 seconds in milliseconds.
AUTO_DELETE_MESSAGE_TIMEOUT: 1000 * 10, // 10 seconds in milliseconds.
PICK_MISSING_WORD_CHOICES: 4,
SHORT_TEXT_LENGTH: 60,
SENTENCE_PAIRS_TO_SHOW: 5,
LOCALISATIONS_DIRECTORY: "./assets/localisations",
SENTENCE_PAIRS_DIRECTORY: "./assets/sentences",
} as const);
Expand All @@ -57,6 +61,7 @@ export default Object.freeze({
emojis,
endpoints,
gifs,
keys,
languages,
licences,
links,
Expand Down
7 changes: 7 additions & 0 deletions source/constants/contexts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1567,5 +1567,12 @@ export default Object.freeze({
cannotUseUntil: localise("interactions.rateLimited.description.cannotUseAgainUntil", locale),
},
}),
noSentencesFound: ({ localise, locale }) => ({
title: localise("context.strings.noSentencesFound.title", locale)(),
description: localise("context.strings.noSentencesFound.description", locale)(),
}),
phraseInContext: ({ localise, locale }) => ({
title: localise("context.strings.phraseInContext.title", locale),
}),
} satisfies Record<string, ContextBuilder<any>>);
export type { ContextBuilder };
3 changes: 0 additions & 3 deletions source/constants/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,6 @@ const MUSIC_DISCONNECT_TIMEOUT: TimeStruct = [2, "minute"];
const MINIMUM_VOICE_CHANNELS = 0;
const MAXIMUM_VOICE_CHANNELS = 5;

const WARN_MESSAGE_DELETE_TIMEOUT = 1000 * 10; // 10 seconds in milliseconds.

export default Object.freeze({
LOCALISATION_LANGUAGE,
LOCALISATION_LOCALE,
Expand All @@ -53,5 +51,4 @@ export default Object.freeze({
MUSIC_DISCONNECT_TIMEOUT,
MINIMUM_VOICE_CHANNELS,
MAXIMUM_VOICE_CHANNELS,
WARN_MESSAGE_DELETE_TIMEOUT,
});
11 changes: 11 additions & 0 deletions source/constants/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const keys = Object.freeze({
redis: {
sentencePairIndex: ({ locale }: { locale: string }) => `${locale}:SI`,
sentencePair: ({ locale, sentenceId }: { locale: string; sentenceId: string | number }) =>
`${locale}:S:${sentenceId}`,
lemmaUseIndex: ({ locale, lemma }: { locale: string; lemma: string }) => `${locale}:LI:${lemma}`,
lemmaFormIndex: ({ locale, lemma }: { locale: string; lemma: string }) => `${locale}:LF:${lemma.toLowerCase()}`,
},
});

export default keys;
3 changes: 2 additions & 1 deletion source/constants/patterns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ const patterns = Object.freeze({
/** Used for matching short time expressions, e.g. 22:51:09 */
conciseTimeExpression: /^(?:(?:(0?[0-9]|1[0-9]|2[0-4]):)?(?:(0?[0-9]|[1-5][0-9]|60):))?(0?[0-9]|[1-5][0-9]|60)$/,
/** Used for matching a full word and nothing else around. */
wholeWord: (word: string) => new RegExp(`(?<=^|\\p{Z}|\\p{P})${word}(?=\\p{Z}|\\p{P}|$)`, "giu"),
wholeWord: (word: string, { caseSensitive }: { caseSensitive: boolean }) =>
new RegExp(`(?<=^|\\p{Z}|\\p{P})${word}(?=\\p{Z}|\\p{P}|$)`, `gu${caseSensitive ? "" : "i"}`),
/** Used for matching emojis, e.g. ✨ */
emojiExpression: /\p{Extended_Pictographic}/u,
/** Used for matching word separators to determine if a word is a compound in the game command. */
Expand Down
31 changes: 26 additions & 5 deletions source/library/commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Discord from "@discordeno/bot";
import { handleDisplayAcknowledgements } from "logos/commands/handlers/acknowledgements";
import { handleAnswer } from "logos/commands/handlers/answer";
import { handleDisplayCefrGuide } from "logos/commands/handlers/cefr";
import { handleFindInContext, handleFindInContextAutocomplete } from "logos/commands/handlers/context";
import { handleMakeFullCorrection, handleMakePartialCorrection } from "logos/commands/handlers/correction";
import { handleDisplayCredits } from "logos/commands/handlers/credits";
import { handleStartGame } from "logos/commands/handlers/game";
Expand Down Expand Up @@ -79,7 +80,6 @@ import { handleFindWord, handleFindWordAutocomplete } from "logos/commands/handl
* Commands, command groups and options are ordered alphabetically.
*/
const commands = Object.freeze({
// Information
information: {
identifier: "information",
type: Discord.ApplicationCommandTypes.ChatInput,
Expand Down Expand Up @@ -131,7 +131,6 @@ const commands = Object.freeze({
},
},
},
// Language
answerMessage: {
identifier: "answer.message",
type: Discord.ApplicationCommandTypes.Message,
Expand Down Expand Up @@ -249,7 +248,31 @@ const commands = Object.freeze({
},
flags: { hasRateLimit: true, isShowable: true },
},
// Meta
context: {
identifier: "context",
type: Discord.ApplicationCommandTypes.ChatInput,
defaultMemberPermissions: ["VIEW_CHANNEL"],
handle: handleFindInContext,
handleAutocomplete: handleFindInContextAutocomplete,
options: {
phrase: {
identifier: "phrase",
type: Discord.ApplicationCommandOptionTypes.String,
required: true,
},
language: {
identifier: "language",
type: Discord.ApplicationCommandOptionTypes.String,
autocomplete: true,
},
"case-sensitive": {
identifier: "caseSensitive",
type: Discord.ApplicationCommandOptionTypes.Boolean,
},
show: constants.parameters.show,
},
flags: { hasRateLimit: true, isShowable: true },
},
acknowledgements: {
identifier: "acknowledgements",
type: Discord.ApplicationCommandTypes.ChatInput,
Expand Down Expand Up @@ -362,7 +385,6 @@ const commands = Object.freeze({
},
},
},
// Moderation
pardon: {
identifier: "pardon",
type: Discord.ApplicationCommandTypes.ChatInput,
Expand Down Expand Up @@ -510,7 +532,6 @@ const commands = Object.freeze({
},
},
},
// Social
music: {
identifier: "music",
type: Discord.ApplicationCommandTypes.ChatInput,
Expand Down
29 changes: 29 additions & 0 deletions source/library/commands/fragments/autocomplete/language.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { trim } from "logos:core/formatting.ts";
import type { Client } from "logos/client.ts";
import { handleSimpleAutocomplete } from "logos/commands/fragments/autocomplete/simple.ts";

async function autocompleteLanguage(
client: Client,
interaction: Logos.Interaction<any, { language: string | undefined }>,
): Promise<void> {
const strings = constants.contexts.autocompleteLanguage({
localise: client.localise.bind(client),
locale: interaction.locale,
});

if (interaction.parameters.language === undefined) {
await client.respond(interaction, [{ name: trim(strings.autocomplete, 100), value: "" }]);
return;
}

await handleSimpleAutocomplete(client, interaction, {
query: interaction.parameters.language,
elements: constants.languages.languages.localisation,
getOption: (language) => ({
name: client.localise(constants.localisations.languages[language], interaction.locale)(),
value: language,
}),
});
}

export { autocompleteLanguage };
111 changes: 111 additions & 0 deletions source/library/commands/handlers/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { getLocaleByLearningLanguage, isLocalisationLanguage } from "logos:constants/languages.ts";
import { shuffle } from "ioredis/built/utils";
import type { Client } from "logos/client.ts";
import { autocompleteLanguage } from "logos/commands/fragments/autocomplete/language.ts";
import type { SentencePair } from "logos/stores/volatile.ts";

async function handleFindInContextAutocomplete(
client: Client,
interaction: Logos.Interaction<any, { language: string | undefined }>,
): Promise<void> {
await autocompleteLanguage(client, interaction);
}

async function handleFindInContext(
client: Client,
interaction: Logos.Interaction<
any,
{ phrase: string; language: string | undefined; "case-sensitive": boolean | undefined }
>,
): Promise<void> {
if (interaction.parameters.language !== undefined && !isLocalisationLanguage(interaction.parameters.language)) {
const strings = constants.contexts.invalidLanguage({ localise: client.localise, locale: interaction.locale });
await client.reply(interaction, {
embeds: [
{
title: strings.title,
description: strings.description,
color: constants.colours.red,
},
],
});
return;
}

await client.postponeReply(interaction, { visible: interaction.parameters.show });

const learningLanguage =
interaction.parameters.language !== undefined ? interaction.parameters.language : interaction.learningLanguage;
const learningLocale = getLocaleByLearningLanguage(learningLanguage);

const segmenter = new Intl.Segmenter(learningLocale, { granularity: "word" });
const lemmas = Array.from(segmenter.segment(interaction.parameters.phrase)).map((data) => data.segment);
const lemmaUses = await client.volatile?.searchForLemmaUses({
lemmas,
learningLocale: learningLocale,
caseSensitive: interaction.parameters["case-sensitive"],
});
if (lemmaUses === undefined || lemmaUses.sentencePairs.length === 0) {
const strings = constants.contexts.noSentencesFound({
localise: client.localise.bind(client),
locale: interaction.displayLocale,
});
await client.warned(
interaction,
{
title: strings.title,
description: strings.description,
},
{ autoDelete: true },
);

return;
}

shuffle(lemmaUses.sentencePairs);

let sentencePairSelection: SentencePair[];
if (lemmaUses.sentencePairs.length <= constants.SENTENCE_PAIRS_TO_SHOW) {
sentencePairSelection = lemmaUses.sentencePairs;
} else {
sentencePairSelection = lemmaUses.sentencePairs.slice(0, constants.SENTENCE_PAIRS_TO_SHOW);
}

const lemmaPatterns = lemmaUses.lemmas.map<[lemma: string, pattern: RegExp]>((lemma) => [
lemma,
constants.patterns.wholeWord(lemma, { caseSensitive: true }),
]);

const strings = constants.contexts.phraseInContext({
localise: client.localise.bind(client),
locale: interaction.displayLocale,
});
await client.noticed(interaction, {
embeds: [
{
title: strings.title({ phrase: interaction.parameters.phrase }),
fields: sentencePairSelection.map((sentencePair) => {
let sentenceFormatted = sentencePair.sentence;
for (const [lemma, pattern] of lemmaPatterns) {
sentenceFormatted = sentenceFormatted.replaceAll(pattern, `__${lemma}__`);
}

return {
name: sentenceFormatted,
value: `> ${sentencePair.translation}`,
};
}),
},
],
components: interaction.parameters.show
? undefined
: [
{
type: Discord.MessageComponentTypes.ActionRow,
components: [client.interactionRepetitionService.getShowButton(interaction)],
},
],
});
}

export { handleFindInContext, handleFindInContextAutocomplete };
22 changes: 10 additions & 12 deletions source/library/commands/handlers/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,13 @@ async function handleStartGame(client: Client, interaction: Logos.Interaction):
localise: client.localise,
locale: interaction.locale,
});
await client.warning(interaction, {
title: strings.title,
description: strings.description,
});

setTimeout(
() =>
client.deleteReply(interaction).catch(() => {
client.log.warn(`Failed to delete "no results for word" message.`);
}),
constants.defaults.WARN_MESSAGE_DELETE_TIMEOUT,
await client.warning(
interaction,
{
title: strings.title,
description: strings.description,
},
{ autoDelete: true },
);

return;
Expand Down Expand Up @@ -146,7 +142,9 @@ async function getGameView(

await sourceNotice.register();

const wholeWordPattern = constants.patterns.wholeWord(data.sentenceSelection.correctPick[1]);
const wholeWordPattern = constants.patterns.wholeWord(data.sentenceSelection.correctPick[1], {
caseSensitive: false,
});
const mask = constants.special.game.mask.repeat(data.sentenceSelection.correctPick[1].length);

const strings = constants.contexts.game({ localise: client.localise, locale: interaction.locale });
Expand Down
27 changes: 2 additions & 25 deletions source/library/commands/handlers/word.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,17 @@
import defaults from "logos:constants/defaults";
import { isLocalisationLanguage } from "logos:constants/languages";
import { type PartOfSpeech, isUnknownPartOfSpeech } from "logos:constants/parts-of-speech";
import { code, trim } from "logos:core/formatting";
import type { Definition, DictionaryEntry, Expression } from "logos/adapters/dictionaries/adapter";
import type { Client } from "logos/client";
import { InteractionCollector } from "logos/collectors";
import { WordSourceNotice } from "logos/commands/components/source-notices/word-source-notice.ts";
import { autocompleteLanguage } from "logos/commands/fragments/autocomplete/language.ts";

async function handleFindWordAutocomplete(
client: Client,
interaction: Logos.Interaction<any, { language: string | undefined }>,
): Promise<void> {
const guildId = interaction.guildId;
if (guildId === undefined) {
return;
}

const languageQueryTrimmed = interaction.parameters.language?.trim();
if (languageQueryTrimmed === undefined || languageQueryTrimmed.length === 0) {
const strings = constants.contexts.autocompleteLanguage({
localise: client.localise,
locale: interaction.locale,
});
await client.respond(interaction, [{ name: trim(strings.autocomplete, 100), value: "" }]);
return;
}

const languageQueryLowercase = languageQueryTrimmed.toLowerCase();
const choices = constants.languages.languages.localisation
.map((language) => ({
name: client.localise(constants.localisations.languages[language], interaction.locale)(),
value: language,
}))
.filter((choice) => choice.name.toLowerCase().includes(languageQueryLowercase));

await client.respond(interaction, choices);
await autocompleteLanguage(client, interaction);
}

/** Allows the user to look up a word and get information about it. */
Expand Down
Loading

0 comments on commit 8b1884e

Please sign in to comment.