Skip to content

Commit 3636389

Browse files
feat: add translate command
1 parent 9188577 commit 3636389

File tree

7 files changed

+311
-5
lines changed

7 files changed

+311
-5
lines changed

src/framework/typescript/commands/Command.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ export type PlainPermissionResolvable = PermissionsString | bigint;
8484
* Represents an abstract command.
8585
* @template T - The type of context the command supports.
8686
*/
87-
abstract class Command<T extends ContextType = ContextType.ChatInput | ContextType.Legacy>
87+
abstract class Command<T extends ContextType = ContextType.ChatInput | ContextType.Legacy, N extends boolean = false>
8888
implements Builder<CommandBuilders>
8989
{
9090
/**
@@ -238,6 +238,11 @@ abstract class Command<T extends ContextType = ContextType.ChatInput | ContextTy
238238
*/
239239
private readonly internalPermissionManager: PermissionManagerServiceInterface;
240240

241+
/**
242+
* Whether this command has been initialized.
243+
*/
244+
private _initialized = false;
245+
241246
/**
242247
* Creates a new instance of the Command class.
243248
*
@@ -250,6 +255,21 @@ abstract class Command<T extends ContextType = ContextType.ChatInput | ContextTy
250255
) satisfies PermissionManagerServiceInterface;
251256
}
252257

258+
/**
259+
* A wrapper for the _initialized private property.
260+
*/
261+
public get initialized() {
262+
return this._initialized;
263+
}
264+
265+
/**
266+
* Initializes the command.
267+
* This method gets called when the command is loaded.
268+
*
269+
* @returns - Nothing, or a promise that resolves when the command is initialized.
270+
*/
271+
public initialize?(): Awaitable<void>;
272+
253273
/**
254274
* Checks if the command supports legacy context.
255275
*
@@ -376,7 +396,7 @@ abstract class Command<T extends ContextType = ContextType.ChatInput | ContextTy
376396
* @param context - The command context.
377397
* @param args - The command arguments.
378398
*/
379-
public abstract execute(context: Context, ...args: ArgumentPayload): Promise<void>;
399+
public abstract execute(context: Context, ...args: ArgumentPayload<N>): Promise<void>;
380400

381401
/**
382402
* Handles the case when a subcommand is not found.
@@ -776,7 +796,7 @@ export type AuthorizeOptions<K extends Exclude<keyof PolicyActions, number>> = {
776796
};
777797

778798
export type Arguments = Record<string | number, unknown>;
779-
export type ArgumentPayload = Array<Argument<unknown> | null> | [Arguments];
799+
export type ArgumentPayload<N extends boolean = false> = Array<Argument<unknown> | null | (N extends true ? undefined : never)> | [Arguments | (N extends true ? undefined : never)];
780800
export type CommandExecutionState<L extends boolean = false> = {
781801
memberPermissions: MemberPermissionData | (L extends false ? undefined : never);
782802
isSystemAdmin: boolean | (L extends false ? undefined : never);

src/framework/typescript/import/ClassLoader.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ class ClassLoader {
106106
continue;
107107
}
108108

109-
this.modules.push(module);
109+
this.modules.push(basename(module));
110110
}
111111

112112
return modules;
@@ -116,7 +116,7 @@ class ClassLoader {
116116
const modules = module ? [module] : await this.loadModules();
117117

118118
for (const moduleName of modules) {
119-
const filePath = path.resolve(moduleName, "/resources/", name);
119+
const filePath = path.join(this.application.projectRootPath, "src", moduleName, "resources/", name);
120120
const file = File.of(filePath);
121121

122122
if (!file.exists) {
@@ -328,6 +328,10 @@ class ClassLoader {
328328
this.getContainer().resolveProperties(CommandClass, command);
329329
}
330330

331+
if (!command.initialized) {
332+
await command.initialize?.();
333+
}
334+
331335
const defaultGroup = basename(dirname(filepath));
332336
await commandManager.addCommand(command, loadMetadata, groups, defaultGroup);
333337
this.application.logger.info("Loaded Command: ", command.name);

src/main/typescript/commands/tools/SetSlowmodeCommand.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import ChannelArgument from "@framework/arguments/ChannelArgument";
66
import Duration from "@framework/datetime/Duration";
77
import DurationArgument from "@framework/arguments/DurationArgument";
88
import { isDiscordAPIError } from "@framework/utils/errors";
9+
import { PermissionFlags } from "@framework/permissions/PermissionFlag";
910

1011
type SetSlowmodeCommandArgs = {
1112
duration: Duration;
@@ -30,6 +31,7 @@ class SetSlowmodeCommand extends Command {
3031
public override readonly defer = true;
3132
public override readonly aliases = ["slowmode", "ratelimit"];
3233
public override readonly usage = ["<duration: Duration> [channel: Channel]"];
34+
public override readonly permissions = [PermissionFlags.ManageChannels];
3335

3436
public override build(): Buildable[] {
3537
return [
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { type Buildable, Command } from "@framework/commands/Command";
2+
import { TakesArgument } from "@framework/arguments/ArgumentTypes";
3+
import StringArgument from "@framework/arguments/StringArgument";
4+
import { ErrorType } from "@framework/arguments/InvalidArgumentError";
5+
import type Context from "@framework/commands/Context";
6+
import { ContextType } from "@framework/commands/ContextType";
7+
import ClassLoader from "@framework/import/ClassLoader";
8+
import FatalError from "@main/core/FatalError";
9+
import { GatewayEventListener } from "@framework/events/GatewayEventListener";
10+
import {
11+
type ApplicationCommandOptionChoiceData,
12+
EmbedBuilder,
13+
type Interaction,
14+
User
15+
} from "discord.js";
16+
import TranslationService from "@main/services/TranslationService";
17+
import { Inject } from "@framework/container/Inject";
18+
import RestStringArgument from "@framework/arguments/RestStringArgument";
19+
20+
type TranslateCommandArgs = {
21+
text: string;
22+
};
23+
24+
@TakesArgument<TranslateCommandArgs>({
25+
names: ["text"],
26+
types: [RestStringArgument],
27+
optional: false,
28+
errorMessages: [
29+
{
30+
[ErrorType.Required]: "You must provide text to translate."
31+
}
32+
]
33+
})
34+
class TranslateCommand extends Command<ContextType, true> {
35+
public override readonly name: string = "translate";
36+
public override readonly description: string = "Translate text to another language.";
37+
public override readonly defer = true;
38+
public override readonly aliases = ["Translate to English"];
39+
public override readonly usage = ["<text: String>"];
40+
public override readonly supportedContexts = [
41+
ContextType.ChatInput,
42+
ContextType.Legacy,
43+
ContextType.MessageContextMenu
44+
];
45+
protected readonly displayNames = new Intl.DisplayNames(["en"], {
46+
type: "language"
47+
});
48+
protected readonly supportedLocales = Intl.DisplayNames.supportedLocalesOf();
49+
private languages: Record<string, string> = {};
50+
51+
@Inject()
52+
private readonly translationService!: TranslationService;
53+
54+
public override build(): Buildable[] {
55+
return [
56+
this.buildChatInput()
57+
.addStringOption(option =>
58+
option
59+
.setName("text")
60+
.setDescription("The text to translate.")
61+
.setRequired(true)
62+
)
63+
.addStringOption(option =>
64+
option
65+
.setName("to")
66+
.setDescription("The language to translate to.")
67+
.setAutocomplete(true)
68+
)
69+
.addStringOption(option =>
70+
option
71+
.setName("from")
72+
.setDescription("The language to translate from.")
73+
.setAutocomplete(true)
74+
)
75+
];
76+
}
77+
78+
public override async initialize(): Promise<void> {
79+
using file = await ClassLoader.getInstance(this.application).getResource("languages.json");
80+
81+
if (!file) {
82+
throw new FatalError("Failed to load languages.json.");
83+
}
84+
85+
this.languages = await file.readJson();
86+
}
87+
88+
@GatewayEventListener("interactionCreate")
89+
public async onInteractionCreate(interaction: Interaction) {
90+
if (!interaction.isAutocomplete() || interaction.commandName !== this.name) {
91+
return;
92+
}
93+
94+
const focused = interaction.options.getFocused();
95+
const matches: ApplicationCommandOptionChoiceData[] = [];
96+
97+
for (const code in this.languages) {
98+
if (matches.length >= 25) {
99+
break;
100+
}
101+
102+
if (code === focused || this.languages[code].includes(focused)) {
103+
matches.push({
104+
name: this.languages[code],
105+
value: code
106+
});
107+
}
108+
}
109+
110+
if (matches.length < 25) {
111+
for (const locale of this.supportedLocales) {
112+
if (matches.length >= 25) {
113+
break;
114+
}
115+
116+
if (this.languages[locale]) {
117+
continue;
118+
}
119+
120+
const displayName = this.displayNames.of(locale);
121+
122+
if (!displayName) {
123+
continue;
124+
}
125+
126+
if (locale === focused || displayName.includes(focused)) {
127+
matches.push({
128+
name: displayName,
129+
value: locale
130+
});
131+
}
132+
}
133+
}
134+
135+
interaction.respond(matches).catch(this.application.logger.error);
136+
}
137+
138+
public override async execute(
139+
context: Context,
140+
args: TranslateCommandArgs | undefined
141+
): Promise<void> {
142+
const text = context.isMessageContextMenu()
143+
? context.commandMessage.targetMessage.content
144+
: args?.text;
145+
146+
if (!text) {
147+
await context.error(
148+
context.isMessageContextMenu()
149+
? "The message does not contain any plain text."
150+
: "You must provide text to translate."
151+
);
152+
return;
153+
}
154+
155+
const to = (context.isChatInput() ? context.options.getString("to") : null) ?? "en";
156+
const from = (context.isChatInput() ? context.options.getString("from") : null) ?? "auto";
157+
158+
try {
159+
if (from !== "auto" && !this.languages[from] && !this.displayNames.of(from)) {
160+
throw new Error();
161+
}
162+
}
163+
catch {
164+
await context.error("Invalid language specified in the `from` option");
165+
return;
166+
}
167+
168+
try {
169+
if (to !== "auto" && !this.languages[to] && !this.displayNames.of(to)) {
170+
throw new Error();
171+
}
172+
}
173+
catch {
174+
await context.error("Invalid language specified in the `to` option");
175+
return;
176+
}
177+
178+
const toString = this.displayNames.of(to);
179+
const { error, translation, response } = await this.translationService.translate(
180+
text,
181+
from,
182+
to
183+
);
184+
185+
if (error) {
186+
await context.reply({
187+
embeds: [
188+
new EmbedBuilder({
189+
color: 0xf14a60,
190+
author: {
191+
name: "Translation Failed"
192+
},
193+
description: `${context.emoji("error") ?? ""} Couldn't translate that due to an internal error.`,
194+
footer: {
195+
text: "Powered by Google Translate"
196+
}
197+
}).setTimestamp()
198+
]
199+
});
200+
201+
return;
202+
}
203+
204+
const fromString = this.displayNames.of(response!.data.src);
205+
206+
await context.reply({
207+
embeds: [
208+
new EmbedBuilder({
209+
color: 0x007bff,
210+
author: {
211+
name: context.isMessageContextMenu()
212+
? (context.commandMessage.targetMessage.author as User).username
213+
: "Translation",
214+
iconURL: context.isMessageContextMenu()
215+
? (
216+
context.commandMessage.targetMessage.author as User
217+
).displayAvatarURL()
218+
: undefined
219+
},
220+
description: translation,
221+
footer: {
222+
text: `Translated from ${fromString ?? this.languages[response!.data.src] ?? response!.data.src} to ${
223+
toString ?? this.languages[to] ?? to
224+
} • Powered by Google Translate`
225+
}
226+
})
227+
]
228+
});
229+
}
230+
}
231+
232+
export default TranslateCommand;

src/main/typescript/core/DiscordKernel.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import ConfigurationManager from "../services/ConfigurationManager";
3434
import LogStreamingService from "../services/LogStreamingService";
3535
import { systemPrefix } from "../utils/utils";
3636
import Client from "./Client";
37+
import TranslationService from "@main/services/TranslationService";
3738

3839
type Binding = {
3940
key: string;
@@ -83,6 +84,7 @@ class DiscordKernel extends Kernel {
8384
"@services/ImageRecognitionService",
8485
"@services/DirectiveParsingService",
8586
"@services/SnippetManagerService",
87+
"@services/TranslationService",
8688
"@root/framework/typescript/api/APIServer"
8789
];
8890

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
class FatalError extends Error {}
2+
3+
export default FatalError;

0 commit comments

Comments
 (0)