Skip to content

Commit 1873d77

Browse files
authored
Merge pull request #63 from NullDev/feat/sagecell
Feat/sagecell
2 parents 3c908be + 7235c9b commit 1873d77

File tree

6 files changed

+313
-2
lines changed

6 files changed

+313
-2
lines changed

locales/commands/translations.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,16 @@ export default {
571571
},
572572
},
573573
},
574+
sage: {
575+
desc: "Ask SageMath / SageCell",
576+
translations: {
577+
de: "Frage SageMath / SageCell",
578+
fr: "Demander à SageMath / SageCell",
579+
ru: "Спросить SageMath / SageCell",
580+
ja: "SageMath / SageCellに質問する",
581+
"es-ES": "Preguntar a SageMath / SageCell",
582+
},
583+
},
574584
rounding: {
575585
desc: "Enable/Disable rounding of numbers.",
576586
translations: {

src/commands/user/sage.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { SlashCommandBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, ActionRowBuilder, InteractionContextType } from "discord.js";
2+
import translations from "../../../locales/commands/translations.js";
3+
4+
// ========================= //
5+
// = Copyright (c) NullDev = //
6+
// ========================= //
7+
8+
const commandName = import.meta.url.split("/").pop()?.split(".").shift() ?? "";
9+
10+
export default {
11+
data: new SlashCommandBuilder()
12+
.setName(commandName)
13+
.setDescription(translations.sage.desc)
14+
.setDescriptionLocalizations(translations.sage.translations)
15+
.setContexts([InteractionContextType.Guild]),
16+
/**
17+
* @param {import("discord.js").CommandInteraction} interaction
18+
*/
19+
async execute(interaction){
20+
const modal = new ModalBuilder()
21+
.setCustomId("sage_math")
22+
.setTitle("Ask SageMath / SageCell");
23+
24+
const codeInput = new TextInputBuilder()
25+
.setCustomId("sage_code")
26+
.setPlaceholder("integrate(sin(x)^2, x)")
27+
.setStyle(TextInputStyle.Paragraph)
28+
.setLabel("SageMath Code")
29+
.setRequired(true);
30+
31+
const first = /** @type {ActionRowBuilder<import("discord.js").ModalActionRowComponentBuilder>} */ (new ActionRowBuilder()).addComponents(codeInput);
32+
33+
modal.addComponents(first);
34+
35+
return await interaction.showModal(modal);
36+
},
37+
};

src/events/interactionCreate.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from "node:path";
22
import { QuickDB } from "quick.db";
33
import executeCode from "../service/codeExecution.js";
4+
import executeSage from "../service/sageExecution.js";
45
import Log from "../util/log.js";
56
import __ from "../service/i18n.js";
67

@@ -52,6 +53,10 @@ const handleModalSubmit = async function(interaction){
5253
if (interaction.customId === "run_code"){
5354
await executeCode(interaction);
5455
}
56+
57+
if (interaction.customId === "sage_math"){
58+
await executeSage(interaction);
59+
}
5560
};
5661

5762
/**

src/service/codeExecution.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ const executeCode = async function(interaction){
4848
const cli = interaction.fields.getTextInputValue("cli_args");
4949
const stdin = interaction.fields.getTextInputValue("stdin");
5050

51+
await interaction.deferReply();
52+
5153
const languages = await fetch("https://emkc.org/api/v2/piston/runtimes").then((res) => res.json())
5254
.catch((err) => Log.error("Error during fetching of languages: " + err));
5355

@@ -56,7 +58,7 @@ const executeCode = async function(interaction){
5658
.sort((a, b) => compareVersions(a.version, b.version))[0];
5759

5860
if (!language){
59-
return interaction.reply({ content: "Invalid language. Allowed: `" + languages.map(e => e.language).join(", ") + "`", ephemeral: true });
61+
return interaction.editReply({ content: "Invalid language. Allowed: `" + languages.map(e => e.language).join(", ") + "`", ephemeral: true });
6062
}
6163

6264
const data = {
@@ -88,7 +90,7 @@ const executeCode = async function(interaction){
8890
},
8991
};
9092

91-
return interaction.reply({ embeds: [embed]});
93+
return interaction.editReply({ embeds: [embed]});
9294
};
9395

9496
export default executeCode;

src/service/sageExecution.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import SageCell from "../util/SageCell.js";
2+
import Log from "../util/log.js";
3+
import defaults from "../util/defaults.js";
4+
5+
// ========================= //
6+
// = Copyright (c) NullDev = //
7+
// ========================= //
8+
9+
const client = new SageCell({ timeoutMs: 20000 });
10+
11+
/**
12+
* Execute code
13+
*
14+
* @param {import("discord.js").ModalSubmitInteraction} interaction
15+
*/
16+
const executeSage = async function(interaction){
17+
const code = interaction.fields.getTextInputValue("sage_code");
18+
19+
const embed = {
20+
color: defaults.embed_color,
21+
title: ":computer:┃SageMath Output",
22+
description: "Processing...",
23+
footer: {
24+
text: `Requested by ${interaction.user.displayName ?? interaction.user.tag}`,
25+
icon_url: interaction.user.displayAvatarURL(),
26+
},
27+
};
28+
29+
await interaction.deferReply();
30+
31+
return client.askSage(code)
32+
.then(res => {
33+
embed.description = "Output:\n```\n" + (res?.result?.["text/plain"]?.trim() || res?.stdout?.trim() || "No Output") + "\n```";
34+
return interaction.editReply({ embeds: [embed]});
35+
})
36+
.catch(() => {
37+
embed.description = "An error occurred while executing the code.";
38+
Log.error("Error during SageMath code execution.");
39+
return interaction.editReply({ embeds: [embed]});
40+
});
41+
};
42+
43+
export default executeSage;

src/util/SageCell.js

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import WebSocket from "ws";
2+
import { randomUUID } from "node:crypto";
3+
4+
// ========================= //
5+
// = Copyright (c) NullDev = //
6+
// ========================= //
7+
8+
/* eslint-disable camelcase */
9+
10+
export default class SageCell {
11+
/**
12+
* @param {Object} options
13+
* @param {string} [options.baseUrl] Base URL of SageCell
14+
* @param {number} [options.timeoutMs] How long to wait for a single execution
15+
*/
16+
constructor(options = {}){
17+
this.baseUrl = options.baseUrl ?? "https://sagecell.sagemath.org";
18+
this.timeoutMs = options.timeoutMs ?? 30000;
19+
}
20+
21+
/**
22+
* High-level API: create kernel, run code, collect output, delete kernel.
23+
* @param {string} code
24+
* @returns {Promise<{ stdout: string, stderr: string, result: any }>}
25+
*/
26+
async askSage(code){
27+
let kernelId = null;
28+
try {
29+
const { wsUrl, kernelId: id } = await this.#startKernel();
30+
kernelId = id;
31+
32+
const result = await this.#runOnKernel(wsUrl, kernelId, code);
33+
return result;
34+
}
35+
finally {
36+
if (kernelId){
37+
try {
38+
await this.#deleteKernel(kernelId);
39+
}
40+
catch { /* noop */ }
41+
}
42+
}
43+
}
44+
45+
/**
46+
* Starts a new SageCell kernel.
47+
*
48+
* @returns {Promise<{ wsUrl: string, kernelId: string }>}
49+
*/
50+
async #startKernel(){
51+
const res = await fetch(
52+
`${this.baseUrl}/kernel?accepted_tos=true&timeout=0`,
53+
{ method: "POST" },
54+
);
55+
56+
if (!res.ok){
57+
throw new Error(`Failed to start kernel: HTTP ${res.status}`);
58+
}
59+
60+
const { ws_url, id } = await res.json();
61+
if (!ws_url || !id){
62+
throw new Error("Kernel start response missing ws_url or id");
63+
}
64+
65+
return { wsUrl: ws_url, kernelId: id };
66+
}
67+
68+
/**
69+
* Deletes a SageCell kernel.
70+
*
71+
* @param {string} kernelId
72+
* @returns {Promise<void>}
73+
*/
74+
async #deleteKernel(kernelId){
75+
const res = await fetch(`${this.baseUrl}/kernel/${kernelId}`, {
76+
method: "DELETE",
77+
});
78+
79+
if (!res.ok && res.status !== 404){
80+
throw new Error(`Failed to delete kernel: HTTP ${res.status}`);
81+
}
82+
}
83+
84+
/**
85+
* Creates an execute_request message.
86+
*
87+
* @param {string} code
88+
* @returns {Object}
89+
*/
90+
#makeExecuteRequest(code){
91+
const session = randomUUID();
92+
const msgId = randomUUID();
93+
return {
94+
header: {
95+
msg_id: msgId,
96+
username: "user",
97+
session,
98+
msg_type: "execute_request",
99+
version: "5.3",
100+
},
101+
parent_header: {},
102+
metadata: {},
103+
content: {
104+
code,
105+
silent: false,
106+
store_history: false,
107+
user_expressions: {},
108+
allow_stdin: false,
109+
stop_on_error: true,
110+
},
111+
channel: "shell",
112+
buffers: [],
113+
};
114+
}
115+
116+
/**
117+
* Connects WS, sends execute_request, waits until status: idle.
118+
*
119+
* @param {string} wsUrl
120+
* @param {string} kernelId
121+
* @param {string} code
122+
* @returns {Promise<{ stdout: string, stderr: string, result: any }>}
123+
*/
124+
async #runOnKernel(wsUrl, kernelId, code){
125+
const ws = new WebSocket(`${wsUrl}kernel/${kernelId}/channels`);
126+
127+
const execMsg = this.#makeExecuteRequest(code);
128+
129+
return new Promise((resolve, reject) => {
130+
let stdout = "";
131+
let stderr = "";
132+
let result = "";
133+
let done = false;
134+
// @ts-ignore
135+
// eslint-disable-next-line prefer-const
136+
let timer;
137+
138+
/**
139+
* Finishes the execution.
140+
*
141+
* @param {any} value
142+
* @param {boolean} isError
143+
*/
144+
const finish = (value, isError = false) => {
145+
if (done) return;
146+
done = true; // @ts-ignore
147+
clearTimeout(timer);
148+
try {
149+
ws.close();
150+
}
151+
catch {
152+
// ignore
153+
}
154+
isError ? reject(value) : resolve(value);
155+
};
156+
157+
timer = setTimeout(() => {
158+
finish(new Error(`SageCell execution timed out after ${this.timeoutMs}ms`), true);
159+
}, this.timeoutMs);
160+
161+
ws.on("open", () => {
162+
ws.send(JSON.stringify(execMsg));
163+
});
164+
165+
ws.on("message", data => {
166+
let msg;
167+
try {
168+
msg = JSON.parse(data.toString());
169+
}
170+
catch (e){
171+
const errMsg = e instanceof Error ? e.message : String(e);
172+
return finish(new Error("Failed to parse message from SageCell " + errMsg), true);
173+
}
174+
175+
const msgType = msg.msg_type || msg.header?.msg_type;
176+
177+
if (msgType === "stream" && msg.content?.text){
178+
if (msg.content.name === "stderr"){
179+
stderr += msg.content.text;
180+
}
181+
else {
182+
stdout += msg.content.text;
183+
}
184+
}
185+
186+
if (msgType === "execute_result"){
187+
result = msg.content?.data ?? null;
188+
}
189+
190+
if (msgType === "status" &&
191+
msg.content?.execution_state === "idle"){
192+
finish({ stdout, stderr, result });
193+
}
194+
195+
if (msgType === "error"){
196+
const errText = msg.content?.evalue || "SageCell error";
197+
finish(new Error(errText), true);
198+
}
199+
200+
return null;
201+
});
202+
203+
ws.on("error", err => {
204+
finish(err, true);
205+
});
206+
207+
ws.on("close", () => {
208+
if (!done){
209+
finish(new Error("WebSocket closed before completion"), true);
210+
}
211+
});
212+
});
213+
}
214+
}

0 commit comments

Comments
 (0)