@spyglassmc/discord-bot 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.mts ADDED
@@ -0,0 +1,327 @@
1
+ import { SlashCommandBooleanOption, SlashCommandBuilder, SlashCommandStringOption } from '@discordjs/builders'
2
+ import { REST } from '@discordjs/rest'
3
+ import type { ColorToken, ColorTokenType, LanguageError } from '@spyglassmc/core'
4
+ import { ErrorSeverity, FileNode, fileUtil, ProfilerFactory, Range, Service } from '@spyglassmc/core'
5
+ import * as je from '@spyglassmc/java-edition'
6
+ import * as nbtdoc from '@spyglassmc/nbtdoc'
7
+ import { Routes } from 'discord-api-types/rest/v9'
8
+ import type { Snowflake } from 'discord.js'
9
+ import { Client, Intents, MessageActionRow, MessageButton, MessageEmbed } from 'discord.js'
10
+ import { dirname, join } from 'path'
11
+ import { fileURLToPath } from 'url'
12
+
13
+ export declare const __dirname: undefined // Not defined in ES module scope
14
+ const MaxContentLength = 2000
15
+
16
+ const ProfilerId = 'discord-bot#startup'
17
+ const profilers = new ProfilerFactory(console, [ProfilerId])
18
+ const __profiler = profilers.get(ProfilerId)
19
+
20
+ const parentPath = dirname(fileURLToPath(import.meta.url))
21
+ const cacheRoot = join(parentPath, 'cache')
22
+ const projectPath = join(parentPath, 'project-root')
23
+ await fileUtil.ensureDir(projectPath)
24
+ console.log(`cacheRoot = ${cacheRoot}`)
25
+ console.log(`projectPath = ${projectPath}`)
26
+
27
+ const config = await loadConfig()
28
+ const rest = new REST({ version: '9' }).setToken(config.token)
29
+ const client = new Client({
30
+ intents: [
31
+ Intents.FLAGS.GUILDS,
32
+ ],
33
+ })
34
+ const service = new Service({
35
+ cacheRoot,
36
+ initializers: [
37
+ nbtdoc.initialize,
38
+ je.initialize,
39
+ ],
40
+ logger: console,
41
+ projectPath,
42
+ profilers,
43
+ })
44
+ const DocumentUri = 'spyglassmc://discord-bot/file.mcfunction'
45
+
46
+ interface InteractionInfo {
47
+ content: string,
48
+ errors: LanguageError[],
49
+ activeErrorIndex: number,
50
+ tokens: readonly ColorToken[],
51
+ showRaw: boolean,
52
+ }
53
+ const activeInteractions = new Map<Snowflake, InteractionInfo>()
54
+
55
+ client.on('interactionCreate', async i => {
56
+ try {
57
+ if (i.isButton()) {
58
+ const info = activeInteractions.get(i.message.id)
59
+ if (!info) {
60
+ await i.update({
61
+ embeds: [new MessageEmbed().setDescription('The interaction has expired!')],
62
+ components: [],
63
+ })
64
+ return
65
+ }
66
+
67
+ if (i.customId === 'previous') {
68
+ info.activeErrorIndex--
69
+ } else if (i.customId === 'next') {
70
+ info.activeErrorIndex++
71
+ }
72
+ await i.update(getReplyOptions(info))
73
+ } else if (i.isCommand()) {
74
+ switch (i.commandName) {
75
+ case 'ping':
76
+ await i.reply({ content: `Pong! Bot to Discord ping ${client.ws.ping} ms`, ephemeral: true })
77
+ break
78
+ case 'spy':
79
+ const command = i.options.getString('command', true)
80
+ const showRaw = i.options.getBoolean('showraw', false) ?? false
81
+ const info = await getInteractionInfo(command, showRaw)
82
+ const reply = await i.reply(getReplyOptions(info))
83
+ activeInteractions.set(reply.id, info)
84
+ break
85
+ }
86
+ }
87
+ } catch (e) {
88
+ console.error('[interactionCreate]', e)
89
+ }
90
+ })
91
+
92
+ await service.project.ready()
93
+ __profiler.task('Service Ready')
94
+ await service.project.cacheService.save()
95
+ __profiler.task('Save Cache')
96
+ await client.login(config.token)
97
+ __profiler.task('Login Discord Bot')
98
+ await registerCommands()
99
+ __profiler.task('Register Commands').finalize()
100
+
101
+ interface Config {
102
+ clientId: string,
103
+ guildId: string,
104
+ token: string,
105
+ }
106
+
107
+ /**
108
+ * @throws
109
+ */
110
+ async function loadConfig(): Promise<Config> {
111
+ const path = join(parentPath, 'config.json')
112
+ const config = await fileUtil.readJson<Config>(path)
113
+ if (!(typeof config.clientId === 'string' &&
114
+ typeof config.guildId === 'string' &&
115
+ typeof config.token === 'string')) {
116
+ throw new Error(`Bad config: ${JSON.stringify(config)}`)
117
+ }
118
+ return config
119
+ }
120
+
121
+ /**
122
+ * @throws
123
+ */
124
+ async function registerCommands(): Promise<unknown> {
125
+ const pingCommand = new SlashCommandBuilder()
126
+ .setName('ping')
127
+ .setDescription('Ping the Spyglass Bot')
128
+ .toJSON()
129
+ const spyCommand = new SlashCommandBuilder()
130
+ .setName('spy')
131
+ .setDescription('Renders a mcfunction command. Error reporting coming soon™')
132
+ .addStringOption(new SlashCommandStringOption()
133
+ .setName('command')
134
+ .setDescription('Put a single mcfunction command here')
135
+ .setRequired(true)
136
+ )
137
+ .addBooleanOption(new SlashCommandBooleanOption()
138
+ .setName('showraw')
139
+ .setDescription('Whether to show the result ANSI code in raw code blocks')
140
+ .setRequired(false)
141
+ )
142
+ .toJSON()
143
+
144
+ return rest.put(Routes.applicationGuildCommands(config.clientId, config.guildId), { body: [pingCommand, spyCommand] })
145
+ }
146
+
147
+ async function getInteractionInfo(content: string, showRaw: boolean): Promise<InteractionInfo> {
148
+ if (activeInteractions.has(content)) {
149
+ return activeInteractions.get(content)!
150
+ }
151
+
152
+ service.project.onDidOpen(DocumentUri, 'mcfunction', 0, content)
153
+ const docAndNode = await service.project.ensureParsedAndChecked(DocumentUri)
154
+ service.project.onDidClose(DocumentUri)
155
+ if (!docAndNode) {
156
+ throw new Error('docAndNode is undefined')
157
+ }
158
+
159
+ const { node, doc } = docAndNode
160
+ const errors = FileNode.getErrors(node)
161
+ const tokens = service.colorize(node, doc)
162
+ const activeErrorIndex = errors.length ? 0 : -1
163
+
164
+ return {
165
+ content,
166
+ errors,
167
+ activeErrorIndex,
168
+ tokens,
169
+ showRaw,
170
+ }
171
+ }
172
+
173
+ function getReplyOptions(info: InteractionInfo): { content: string, components: MessageActionRow[], fetchReply: true } {
174
+ const content = getReplyContent(info)
175
+ return {
176
+ content: content.length > MaxContentLength
177
+ ? `Skipped colorizing due to Discord length limit.\n\`\`\`\n${info.content}\n\`\`\``
178
+ : content,
179
+ components: info.errors.length > 1 ? [new MessageActionRow().addComponents(
180
+ new MessageButton().setCustomId('previous').setLabel('Previous Error').setStyle('PRIMARY').setDisabled(info.activeErrorIndex <= 0),
181
+ new MessageButton().setCustomId('next').setLabel('Next Error').setStyle('PRIMARY').setDisabled(info.activeErrorIndex >= info.errors.length - 1)
182
+ )] : [],
183
+ fetchReply: true,
184
+ }
185
+ }
186
+
187
+ type RenderFormat =
188
+ | 'background_orange'
189
+ | 'foreground_blue'
190
+ | 'foreground_cyan'
191
+ | 'foreground_green'
192
+ | 'foreground_pink'
193
+ | 'foreground_red'
194
+ | 'foreground_yellow'
195
+ | 'foreground_white'
196
+ | 'reset'
197
+ | 'underline'
198
+ interface RenderToken {
199
+ formats: Set<RenderFormat>,
200
+ range: Range
201
+ }
202
+ const AnsiCodeMap: Record<RenderFormat, number> = {
203
+ background_orange: 41,
204
+ foreground_blue: 34,
205
+ foreground_cyan: 36,
206
+ foreground_green: 32,
207
+ foreground_pink: 35,
208
+ foreground_red: 31,
209
+ foreground_white: 37,
210
+ foreground_yellow: 33,
211
+ reset: 0,
212
+ underline: 4,
213
+ }
214
+
215
+ const ColorTokenTypeLegend: Record<ColorTokenType, Set<RenderFormat>> = {
216
+ comment: new Set(['foreground_green']),
217
+ enum: new Set(['foreground_white']),
218
+ enumMember: new Set(['foreground_white']),
219
+ error: new Set(['foreground_red', 'underline']),
220
+ function: new Set(['foreground_yellow']),
221
+ keyword: new Set(['foreground_pink']),
222
+ literal: new Set(['foreground_blue']),
223
+ modifier: new Set(['foreground_pink']),
224
+ number: new Set(['foreground_green']),
225
+ operator: new Set(['reset']),
226
+ property: new Set(['foreground_cyan']),
227
+ resourceLocation: new Set(['foreground_yellow']),
228
+ string: new Set(['foreground_green']),
229
+ struct: new Set(['foreground_white']),
230
+ type: new Set(['foreground_white']),
231
+ vector: new Set(['foreground_green', 'underline']),
232
+ variable: new Set(['foreground_white']),
233
+ }
234
+
235
+ function getReplyContent(info: InteractionInfo): string {
236
+ const { content, tokens, errors, activeErrorIndex } = info
237
+ const ansiCode = getAnsiCode(content, toRenderTokens(info))
238
+
239
+ const activeError: LanguageError | undefined = errors[activeErrorIndex]
240
+
241
+ return `\`\`\`${info.showRaw ? '' : 'ansi'}\n${ansiCode}\n\`\`\`${activeError
242
+ ? `\n\`${errorSeverityToChar(activeError.severity)} ${Range.toString(activeError.range)} ${activeError.message}\``
243
+ : ''}`
244
+ }
245
+
246
+ /**
247
+ * @returns Unsorted tokens.
248
+ */
249
+ function toRenderTokens({ tokens, errors, activeErrorIndex }: InteractionInfo): RenderToken[] {
250
+ const ans: RenderToken[] = tokens.map(t => ({ formats: ColorTokenTypeLegend[t.type], range: t.range }))
251
+ const activeError: LanguageError | undefined = errors[activeErrorIndex]
252
+ if (activeError) {
253
+ ans.push({ formats: new Set(['background_orange', 'foreground_white']), range: activeError.range })
254
+ }
255
+ return ans
256
+ }
257
+
258
+ function getAnsiCode(content: string, tokens: RenderToken[]): string {
259
+ let ans: string = toAnsiEscapeCode(['reset'])
260
+ tokens = tokens
261
+ .map(t => t.range.end - t.range.start === 0
262
+ ? { range: { start: t.range.start, end: t.range.start + 1 }, formats: t.formats }
263
+ : t
264
+ )
265
+ .sort((a, b) => a.range.start - b.range.start)
266
+
267
+ for (let i = 0; i < tokens.length - 1; i++) {
268
+ const current = tokens[i]
269
+ const next = tokens[i + 1]
270
+
271
+ // Handle overlapped render tokens.
272
+ if (next.range.start < current.range.end) {
273
+ // [current] | [current] | [current] | [current ] | [current] | [current]
274
+ // [next] | [next ] | [next ] | [next] | [next ] | [next ]
275
+ // [ ][ ] | [ ] | [ ][ ] | [][ ][] | [][ ] | [][ ][]
276
+ const insertedTokens: RenderToken[] = []
277
+ if (current.range.start < next.range.start) {
278
+ insertedTokens.push({
279
+ formats: current.formats,
280
+ range: { start: current.range.start, end: current.range.end },
281
+ })
282
+ }
283
+ insertedTokens.push({
284
+ formats: new Set([...current.formats, ...next.formats]),
285
+ range: { start: next.range.start, end: Math.min(current.range.end, next.range.end) },
286
+ })
287
+ if (current.range.end !== next.range.end) {
288
+ insertedTokens.push({
289
+ formats: current.range.end < next.range.end ? next.formats : current.formats,
290
+ range: {
291
+ start: Math.min(current.range.end, next.range.end),
292
+ end: Math.max(current.range.end, next.range.end),
293
+ },
294
+ })
295
+ }
296
+ tokens.splice(i, 2, ...insertedTokens)
297
+ }
298
+ }
299
+
300
+ let lastOffset = 0
301
+ for (const token of tokens) {
302
+ ans += content.slice(lastOffset, token.range.start)
303
+ ans += toAnsiEscapeCode(token.formats)
304
+ ans += content.slice(token.range.start, token.range.end)
305
+ ans += toAnsiEscapeCode(['reset'])
306
+ lastOffset = token.range.end
307
+ }
308
+ ans += content.slice(lastOffset)
309
+ return ans
310
+ }
311
+
312
+ function toAnsiEscapeCode(formats: Iterable<RenderFormat>): `\u001b[${string}m` {
313
+ return `\u001b[${[...formats].map(v => AnsiCodeMap[v]).join(';')}m`
314
+ }
315
+
316
+ function errorSeverityToChar(severity: ErrorSeverity): string {
317
+ switch (severity) {
318
+ case ErrorSeverity.Hint:
319
+ return 'H'
320
+ case ErrorSeverity.Information:
321
+ return 'I'
322
+ case ErrorSeverity.Warning:
323
+ return 'W'
324
+ case ErrorSeverity.Error:
325
+ return 'E'
326
+ }
327
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "../../tsconfig-base",
3
+ "compilerOptions": {
4
+ "module": "ES2022",
5
+ "outDir": "../lib"
6
+ }
7
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "extends": "../tsconfig-base",
3
+ "files": [],
4
+ "references": [
5
+ {
6
+ "path": "../core"
7
+ },
8
+ {
9
+ "path": "../java-edition"
10
+ },
11
+ {
12
+ "path": "../locales"
13
+ },
14
+ {
15
+ "path": "../nbtdoc"
16
+ },
17
+ {
18
+ "path": "src"
19
+ }
20
+ ]
21
+ }