@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/README.md +9 -0
- package/lib/cache/mc-je/1.18.1/blocks.json.gz +0 -0
- package/lib/cache/mc-je/1.18.1/commands.json.gz +0 -0
- package/lib/cache/mc-je/1.18.1/mc-nbtdoc.json +1 -0
- package/lib/cache/mc-je/1.18.1/mc-nbtdoc.tar.gz +0 -0
- package/lib/cache/mc-je/1.18.1/mcdata.json +1 -0
- package/lib/cache/mc-je/1.18.1/registries.json.gz +0 -0
- package/lib/cache/mc-je/1.18.1/vanilla-datapack.json +1 -0
- package/lib/cache/mc-je/1.18.1/vanilla-datapack.tar.gz +0 -0
- package/lib/cache/mc-je/version_manifest.json +1 -0
- package/lib/cache/symbols/77d6d174bc767c771287c11db11cd6197025bb89.json.gz +0 -0
- package/lib/config.json +6 -0
- package/lib/index.d.mts +2 -0
- package/lib/index.d.mts.map +1 -0
- package/lib/index.mjs +266 -0
- package/lib/index.mjs.map +1 -0
- package/lib/tsconfig.tsbuildinfo +1 -0
- package/package.json +40 -0
- package/src/index.mts +327 -0
- package/src/tsconfig.json +7 -0
- package/tsconfig.json +21 -0
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
|
+
}
|
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
|
+
}
|