1 |
/** |
2 |
* This file is part of SudoBot. |
3 |
* |
4 |
* Copyright (C) 2021-2023 OSN Developers. |
5 |
* |
6 |
* SudoBot is free software; you can redistribute it and/or modify it |
7 |
* under the terms of the GNU Affero General Public License as published by |
8 |
* the Free Software Foundation, either version 3 of the License, or |
9 |
* (at your option) any later version. |
10 |
* |
11 |
* SudoBot is distributed in the hope that it will be useful, but |
12 |
* WITHOUT ANY WARRANTY; without even the implied warranty of |
13 |
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 |
* GNU Affero General Public License for more details. |
15 |
* |
16 |
* You should have received a copy of the GNU Affero General Public License |
17 |
* along with SudoBot. If not, see <https://www.gnu.org/licenses/>. |
18 |
*/ |
19 |
|
20 |
import { |
21 |
APIEmbed, |
22 |
APIMessage, |
23 |
Embed, |
24 |
EmbedBuilder, |
25 |
GuildMember, |
26 |
JSONEncodable, |
27 |
Message, |
28 |
MessageCreateOptions, |
29 |
MessageEditOptions, |
30 |
TextBasedChannel, |
31 |
User |
32 |
} from "discord.js"; |
33 |
import JSON5 from "json5"; |
34 |
import { z } from "zod"; |
35 |
import { log } from "./logger"; |
36 |
|
37 |
type EmbedType = Embed | APIEmbed; |
38 |
type GetMessageOptions = MessageCreateOptions | APIMessage | MessageEditOptions; |
39 |
|
40 |
export default class EmbedSchemaParser { |
41 |
private static readonly embedZodSchema = z.object({ |
42 |
title: z.string().max(256).optional(), |
43 |
description: z.string().max(4096).optional(), |
44 |
url: z.string().url().optional(), |
45 |
timestamp: z.string().datetime().optional(), |
46 |
color: z.number().min(0).max(0xffffff).optional(), |
47 |
provider: z |
48 |
.union([ |
49 |
z.object({ |
50 |
name: z.string(), |
51 |
url: z.string().url() |
52 |
}), |
53 |
z.object({ |
54 |
name: z.string().optional(), |
55 |
url: z.string().url() |
56 |
}), |
57 |
z.object({ |
58 |
name: z.string(), |
59 |
url: z.string().url().optional() |
60 |
}) |
61 |
]) |
62 |
.optional(), |
63 |
author: z |
64 |
.object({ |
65 |
name: z.string().max(256), |
66 |
icon_url: z.string().url().optional(), |
67 |
url: z.string().url().optional(), |
68 |
proxy_icon_url: z.string().url().optional() |
69 |
}) |
70 |
.optional(), |
71 |
footer: z |
72 |
.object({ |
73 |
text: z.string().max(2048), |
74 |
icon_url: z.string().url().optional(), |
75 |
proxy_icon_url: z.string().url().optional() |
76 |
}) |
77 |
.optional(), |
78 |
image: z |
79 |
.object({ |
80 |
url: z.string().url(), |
81 |
proxy_url: z.string().url().optional(), |
82 |
height: z.number().int().optional(), |
83 |
width: z.number().int().optional() |
84 |
}) |
85 |
.optional(), |
86 |
thumbnail: z |
87 |
.object({ |
88 |
url: z.string().url(), |
89 |
proxy_url: z.string().url().optional(), |
90 |
height: z.number().int().optional(), |
91 |
width: z.number().int().optional() |
92 |
}) |
93 |
.optional(), |
94 |
video: z |
95 |
.object({ |
96 |
url: z.string().url(), |
97 |
proxy_url: z.string().url().optional(), |
98 |
height: z.number().int().optional(), |
99 |
width: z.number().int().optional() |
100 |
}) |
101 |
.optional(), |
102 |
fields: z |
103 |
.array( |
104 |
z.object({ |
105 |
name: z.string().max(256), |
106 |
value: z.string().max(1024), |
107 |
inline: z.boolean().optional() |
108 |
}) |
109 |
) |
110 |
.optional() |
111 |
}); |
112 |
|
113 |
private static readonly embedRequiredFieldNames = [ |
114 |
"title", |
115 |
"description", |
116 |
"author", |
117 |
"footer", |
118 |
"image", |
119 |
"video", |
120 |
"thumbnail", |
121 |
"fields" |
122 |
]; |
123 |
|
124 |
static parseString(string: string): [EmbedBuilder[], string] { |
125 |
const length = string.length; |
126 |
const embeds = []; |
127 |
let outString = string; |
128 |
|
129 |
for (let i = 0; i < length; i++) { |
130 |
if (i + 8 < length && (i === 0 || [" ", "\n"].includes(string[i - 1])) && string.substring(i, i + 8) === "embed::{") { |
131 |
const pos = i; |
132 |
i += 7; |
133 |
|
134 |
let jsonStream = ""; |
135 |
|
136 |
while (string.substring(i, i + 3) !== "}::") { |
137 |
if (string[i] === "\n") { |
138 |
i = pos; |
139 |
break; |
140 |
} |
141 |
|
142 |
jsonStream += string[i]; |
143 |
i++; |
144 |
} |
145 |
|
146 |
jsonStream += "}"; |
147 |
|
148 |
if (i !== pos) { |
149 |
try { |
150 |
const parsedJSON = JSON5.parse(jsonStream); |
151 |
|
152 |
if (typeof parsedJSON.color === "string") { |
153 |
parsedJSON.color = parsedJSON.color.startsWith("#") |
154 |
? parseInt(parsedJSON.color.substring(1), 16) |
155 |
: parseInt(parsedJSON.color); |
156 |
} |
157 |
|
158 |
if (!this.validate(parsedJSON)) { |
159 |
continue; |
160 |
} |
161 |
|
162 |
embeds.push(new EmbedBuilder(parsedJSON)); |
163 |
outString = outString.replace(new RegExp(`(\\s*)embed::(.{${jsonStream.length}})::(\\s*)`, "gm"), ""); |
164 |
} catch (e) { |
165 |
console.error(e); |
166 |
continue; |
167 |
} |
168 |
} |
169 |
} |
170 |
} |
171 |
|
172 |
return [embeds, outString]; |
173 |
} |
174 |
|
175 |
private static validate(parsedJSON: object) { |
176 |
log(parsedJSON); |
177 |
|
178 |
const { success } = this.embedZodSchema.safeParse(parsedJSON); |
179 |
|
180 |
if (!success) { |
181 |
log("Embed validation failed"); |
182 |
return false; |
183 |
} |
184 |
|
185 |
for (const key of this.embedRequiredFieldNames) { |
186 |
if (key in parsedJSON) { |
187 |
return true; |
188 |
} |
189 |
} |
190 |
|
191 |
log("Embed required key validation failed"); |
192 |
|
193 |
return false; |
194 |
} |
195 |
|
196 |
private static toSchemaStringSingle(embed: EmbedType) { |
197 |
return `embed::${JSON.stringify(embed)}::`; |
198 |
} |
199 |
|
200 |
static toSchemaString(embed: EmbedType): string; |
201 |
static toSchemaString(embeds: EmbedType[]): string; |
202 |
|
203 |
static toSchemaString(embed: EmbedType | EmbedType[]) { |
204 |
if (embed instanceof Array) { |
205 |
return embed.map(this.toSchemaStringSingle.bind(this)); |
206 |
} |
207 |
|
208 |
return this.toSchemaStringSingle(embed); |
209 |
} |
210 |
|
211 |
static getMessageOptions<T extends GetMessageOptions>(payload: T, withContent = true) { |
212 |
const { content, embeds = [], ...options } = payload; |
213 |
|
214 |
type GetMessageOptionsResult = (T extends MessageCreateOptions ? MessageCreateOptions : MessageEditOptions) & { |
215 |
embeds: (APIEmbed | JSONEncodable<APIEmbed>)[]; |
216 |
}; |
217 |
|
218 |
if (!content) { |
219 |
return { |
220 |
content, |
221 |
embeds, |
222 |
...options |
223 |
} as unknown as GetMessageOptionsResult; |
224 |
} |
225 |
|
226 |
const [parsedEmbeds, strippedContent] = EmbedSchemaParser.parseString(content); |
227 |
|
228 |
return { |
229 |
...options, |
230 |
embeds: [...embeds, ...parsedEmbeds.slice(0, 10)], |
231 |
content: withContent ? strippedContent : undefined |
232 |
} as unknown as (T extends MessageCreateOptions ? MessageCreateOptions : MessageEditOptions) & { |
233 |
embeds: (APIEmbed | JSONEncodable<APIEmbed>)[]; |
234 |
}; |
235 |
} |
236 |
|
237 |
static sendMessage(sendable: TextBasedChannel | User | GuildMember, options: MessageCreateOptions) { |
238 |
return sendable.send(EmbedSchemaParser.getMessageOptions(options)); |
239 |
} |
240 |
|
241 |
static editMessage(message: Message, options: MessageEditOptions) { |
242 |
return message.edit(EmbedSchemaParser.getMessageOptions(options)); |
243 |
} |
244 |
} |