Add Roll Web View
This commit is contained in:
parent
5e94056fc5
commit
1052e7d2e2
|
@ -32,6 +32,7 @@
|
||||||
"Mult",
|
"Mult",
|
||||||
"nojs",
|
"nojs",
|
||||||
"noodp",
|
"noodp",
|
||||||
|
"noopener",
|
||||||
"noydir",
|
"noydir",
|
||||||
"oldcnt",
|
"oldcnt",
|
||||||
"oper",
|
"oper",
|
||||||
|
|
|
@ -20,13 +20,15 @@
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"proseWrap": "preserve"
|
"proseWrap": "preserve"
|
||||||
},
|
},
|
||||||
|
"nodeModulesDir": "none",
|
||||||
"imports": {
|
"imports": {
|
||||||
"@discordeno": "https://deno.land/x/discordeno@12.0.1/mod.ts",
|
"@discordeno": "https://deno.land/x/discordeno@12.0.1/mod.ts",
|
||||||
|
"@imagescript": "https://deno.land/x/imagescript@1.3.0/mod.ts",
|
||||||
"@Log4Deno": "https://raw.githubusercontent.com/Burn-E99/Log4Deno/V2.1.1/mod.ts",
|
"@Log4Deno": "https://raw.githubusercontent.com/Burn-E99/Log4Deno/V2.1.1/mod.ts",
|
||||||
"@mysql": "https://deno.land/x/mysql@v2.12.1/mod.ts",
|
"@mysql": "https://deno.land/x/mysql@v2.12.1/mod.ts",
|
||||||
"@std/http": "jsr:@std/http@1.0.15",
|
|
||||||
"@nanoid": "https://deno.land/x/nanoid@v3.0.0/mod.ts",
|
"@nanoid": "https://deno.land/x/nanoid@v3.0.0/mod.ts",
|
||||||
"@imagescript": "https://deno.land/x/imagescript@1.3.0/mod.ts",
|
"@showdown": "npm:showdown@2.1.0",
|
||||||
|
"@std/http": "jsr:@std/http@1.0.15",
|
||||||
"~config": "./config.ts",
|
"~config": "./config.ts",
|
||||||
"~flags": "./flags.ts",
|
"~flags": "./flags.ts",
|
||||||
"artigen/": "./src/artigen/",
|
"artigen/": "./src/artigen/",
|
||||||
|
|
17
deno.lock
17
deno.lock
|
@ -10,7 +10,8 @@
|
||||||
"jsr:@std/media-types@^1.1.0": "1.1.0",
|
"jsr:@std/media-types@^1.1.0": "1.1.0",
|
||||||
"jsr:@std/net@^1.0.4": "1.0.4",
|
"jsr:@std/net@^1.0.4": "1.0.4",
|
||||||
"jsr:@std/path@^1.0.9": "1.0.9",
|
"jsr:@std/path@^1.0.9": "1.0.9",
|
||||||
"jsr:@std/streams@^1.0.9": "1.0.9"
|
"jsr:@std/streams@^1.0.9": "1.0.9",
|
||||||
|
"npm:showdown@2.1.0": "2.1.0"
|
||||||
},
|
},
|
||||||
"jsr": {
|
"jsr": {
|
||||||
"@std/cli@1.0.17": {
|
"@std/cli@1.0.17": {
|
||||||
|
@ -54,6 +55,17 @@
|
||||||
"integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035"
|
"integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"npm": {
|
||||||
|
"commander@9.5.0": {
|
||||||
|
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ=="
|
||||||
|
},
|
||||||
|
"showdown@2.1.0": {
|
||||||
|
"integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==",
|
||||||
|
"dependencies": [
|
||||||
|
"commander"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"redirects": {
|
"redirects": {
|
||||||
"https://deno.land/std/hash/mod.ts": "https://deno.land/std@0.224.0/hash/mod.ts"
|
"https://deno.land/std/hash/mod.ts": "https://deno.land/std@0.224.0/hash/mod.ts"
|
||||||
},
|
},
|
||||||
|
@ -837,7 +849,8 @@
|
||||||
},
|
},
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"jsr:@std/http@1.0.15"
|
"jsr:@std/http@1.0.15",
|
||||||
|
"npm:showdown@2.1.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -172,6 +172,8 @@ const start = () => {
|
||||||
return endpoints.get.apiKey(query);
|
return endpoints.get.apiKey(query);
|
||||||
case '/heatmap.png':
|
case '/heatmap.png':
|
||||||
return endpoints.get.heatmapPng();
|
return endpoints.get.heatmapPng();
|
||||||
|
case '/webview':
|
||||||
|
return endpoints.get.generateWebView(query);
|
||||||
default:
|
default:
|
||||||
// Alert API user that they messed up
|
// Alert API user that they messed up
|
||||||
return stdResp.NotFound('NoAuth Get');
|
return stdResp.NotFound('NoAuth Get');
|
||||||
|
|
|
@ -11,7 +11,7 @@ import { removeWorker } from 'artigen/managers/countManager.ts';
|
||||||
import { QueuedRoll } from 'artigen/managers/manager.d.ts';
|
import { QueuedRoll } from 'artigen/managers/manager.d.ts';
|
||||||
import { ApiResolveMap, TestResolveMap } from 'artigen/managers/resolveManager.ts';
|
import { ApiResolveMap, TestResolveMap } from 'artigen/managers/resolveManager.ts';
|
||||||
|
|
||||||
import { generateCountDetailsEmbed, generateDMFailed, generateRollDistsEmbed, generateRollEmbed } from 'artigen/utils/embeds.ts';
|
import { generateCountDetailsEmbed, generateDMFailed, generateRollDistsEmbed, generateRollEmbed, toggleWebView } from 'artigen/utils/embeds.ts';
|
||||||
import { loggingEnabled } from 'artigen/utils/logFlag.ts';
|
import { loggingEnabled } from 'artigen/utils/logFlag.ts';
|
||||||
|
|
||||||
import dbClient from 'db/client.ts';
|
import dbClient from 'db/client.ts';
|
||||||
|
@ -188,23 +188,29 @@ export const onWorkerComplete = async (workerMessage: MessageEvent<SolvedRoll>,
|
||||||
const respMessage: Embed[] = [
|
const respMessage: Embed[] = [
|
||||||
{
|
{
|
||||||
color: infoColor1,
|
color: infoColor1,
|
||||||
description: `This message contains information for a previous roll.\nPlease click on "<@${botId}> *Click to see attachment*" above this message to see the previous roll.`,
|
description: `This message contains information for a previous roll.\nPlease click on "<@${botId}> *Click to see attachment*" above this message to see the previous roll.
|
||||||
|
|
||||||
|
As anyone with the Web View link can view the roll, Web View is disabled by default for privacy. Click the button below to enable Web View and generate a link for this roll.`,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (pubAttachments.map((file) => file.blob.size).reduce(basicReducer, 0) < config.maxFileSize) {
|
if (pubAttachments.map((file) => file.blob.size).reduce(basicReducer, 0) < config.maxFileSize) {
|
||||||
// All attachments will fit in one message
|
// All attachments will fit in one message
|
||||||
newMsg.reply({
|
newMsg
|
||||||
|
.reply({
|
||||||
embeds: respMessage,
|
embeds: respMessage,
|
||||||
file: pubAttachments,
|
file: pubAttachments,
|
||||||
});
|
})
|
||||||
|
.then((attachmentMsg) => toggleWebView(attachmentMsg, getUserIdForEmbed(rollRequest).toString(), false));
|
||||||
} else {
|
} else {
|
||||||
pubAttachments.forEach((file) => {
|
pubAttachments.forEach((file) => {
|
||||||
newMsg &&
|
newMsg &&
|
||||||
newMsg.reply({
|
newMsg
|
||||||
|
.reply({
|
||||||
embeds: respMessage,
|
embeds: respMessage,
|
||||||
file,
|
file,
|
||||||
});
|
})
|
||||||
|
.then((attachmentMsg) => toggleWebView(attachmentMsg, getUserIdForEmbed(rollRequest).toString(), false));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { CreateMessage, EmbedField } from '@discordeno';
|
import { ButtonStyles, CreateMessage, DiscordenoMessage, EmbedField, MessageComponentTypes } from '@discordeno';
|
||||||
import { log, LogTypes as LT } from '@Log4Deno';
|
import { log, LogTypes as LT } from '@Log4Deno';
|
||||||
|
|
||||||
import config from '~config';
|
import config from '~config';
|
||||||
|
@ -8,9 +8,14 @@ import { ArtigenEmbedNoAttachment, ArtigenEmbedWithAttachment, SolvedRoll } from
|
||||||
import { CountDetails, RollDistributionMap, RollModifiers } from 'artigen/dice/dice.d.ts';
|
import { CountDetails, RollDistributionMap, RollModifiers } from 'artigen/dice/dice.d.ts';
|
||||||
|
|
||||||
import { loggingEnabled } from 'artigen/utils/logFlag.ts';
|
import { loggingEnabled } from 'artigen/utils/logFlag.ts';
|
||||||
import { failColor, infoColor1, infoColor2 } from 'embeds/colors.ts';
|
|
||||||
import { basicReducer } from 'artigen/utils/reducers.ts';
|
import { basicReducer } from 'artigen/utils/reducers.ts';
|
||||||
|
|
||||||
|
import { failColor, infoColor1, infoColor2 } from 'embeds/colors.ts';
|
||||||
|
|
||||||
|
import { InteractionValueSeparator } from 'events/interactionCreate.ts';
|
||||||
|
|
||||||
|
import utils from 'utils/utils.ts';
|
||||||
|
|
||||||
export const rollingEmbed: CreateMessage = {
|
export const rollingEmbed: CreateMessage = {
|
||||||
embeds: [
|
embeds: [
|
||||||
{
|
{
|
||||||
|
@ -130,9 +135,10 @@ export const generateRollDistsEmbed = (rollDists: RollDistributionMap): ArtigenE
|
||||||
const totalSize = fields.map((field) => field.name.length + field.value.length).reduce(basicReducer, 0);
|
const totalSize = fields.map((field) => field.name.length + field.value.length).reduce(basicReducer, 0);
|
||||||
if (totalSize > 4_000 || fields.length > 25 || fields.some((field) => field.name.length > 256 || field.value.length > 1024)) {
|
if (totalSize > 4_000 || fields.length > 25 || fields.some((field) => field.name.length > 256 || field.value.length > 1024)) {
|
||||||
const rollDistBlob = new Blob([fields.map((field) => `# ${field.name}\n${field.value}`).join('\n\n') as BlobPart], { type: 'text' });
|
const rollDistBlob = new Blob([fields.map((field) => `# ${field.name}\n${field.value}`).join('\n\n') as BlobPart], { type: 'text' });
|
||||||
|
let rollDistErrDesc = 'The roll distribution was omitted from this message as it was over 4,000 characters, ';
|
||||||
if (rollDistBlob.size > config.maxFileSize) {
|
if (rollDistBlob.size > config.maxFileSize) {
|
||||||
const rollDistErrDesc =
|
rollDistErrDesc +=
|
||||||
'The roll distribution was too large to be included and could not be attached below. If you would like to see the roll distribution details, please send the rolls in multiple messages.';
|
'and was too large to be attached as the file would be too large for Discord to handle. If you would like to see the roll distribution details, please simplify or send the rolls in multiple messages.';
|
||||||
return {
|
return {
|
||||||
charCount: rollDistTitle.length + rollDistErrDesc.length,
|
charCount: rollDistTitle.length + rollDistErrDesc.length,
|
||||||
embed: {
|
embed: {
|
||||||
|
@ -143,7 +149,7 @@ export const generateRollDistsEmbed = (rollDists: RollDistributionMap): ArtigenE
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const rollDistErrDesc = 'The roll distribution was too large to be included and has been attached below.';
|
rollDistErrDesc += 'and has been attached to a followup message as a formatted `.md` file.';
|
||||||
return {
|
return {
|
||||||
charCount: rollDistTitle.length + rollDistErrDesc.length,
|
charCount: rollDistTitle.length + rollDistErrDesc.length,
|
||||||
embed: {
|
embed: {
|
||||||
|
@ -225,28 +231,31 @@ export const generateRollEmbed = (
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseDesc = `${line1Details}**${line2Details.shift()}:**\n${line2Details.join(': ')}`;
|
const baseDesc = `${line1Details}**${line2Details.shift()}:**\n${line2Details.join(': ')}`;
|
||||||
|
const fullDesc = `${baseDesc}\n\n${details}`;
|
||||||
|
|
||||||
|
const formattingCount = (fullDesc.match(/(\*\*)|(__)|(~~)|(`)/g) ?? []).length / 2 + (fullDesc.match(/(<@)|(<#)/g) ?? []).length;
|
||||||
|
|
||||||
// Embed desc limit is 4096
|
// Embed desc limit is 4096
|
||||||
if (baseDesc.length + details.length < 4_000) {
|
// Discord only formats 200 items per message
|
||||||
|
if (fullDesc.length < 4_000 && formattingCount <= 200) {
|
||||||
// Response is valid size
|
// Response is valid size
|
||||||
const desc = `${baseDesc}\n\n${details}`;
|
|
||||||
return {
|
return {
|
||||||
charCount: desc.length,
|
charCount: fullDesc.length,
|
||||||
embed: {
|
embed: {
|
||||||
color: infoColor2,
|
color: infoColor2,
|
||||||
description: desc,
|
description: fullDesc,
|
||||||
},
|
},
|
||||||
hasAttachment: false,
|
hasAttachment: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Response is too big, collapse it into a .txt file and send that instead.
|
// Response is too big, collapse it into a .md file and send that instead.
|
||||||
const b = new Blob([`${baseDesc}\n\n${details}` as BlobPart], { type: 'text' });
|
const b = new Blob([fullDesc as BlobPart], { type: 'text' });
|
||||||
details = `${baseDesc}\n\nDetails have been omitted from this message for being over 4000 characters.`;
|
details = `${baseDesc}\n\nDetails have been omitted from this message for ${fullDesc.length < 4_000 ? 'being over 4,000 characters' : 'having over 200 formatted items'}.`;
|
||||||
if (b.size > config.maxFileSize) {
|
if (b.size > config.maxFileSize) {
|
||||||
// blob is too big, don't attach it
|
// blob is too big, don't attach it
|
||||||
details +=
|
details +=
|
||||||
'\n\nFull details could not be attached to this messaged as a `.txt` file as the file would be too large for Discord to handle. If you would like to see the details of rolls, please send the rolls in multiple messages.';
|
'\n\nFull details could not be attached as the file would be too large for Discord to handle. If you would like to see the details of rolls, please simplify or send the rolls in multiple messages.';
|
||||||
return {
|
return {
|
||||||
charCount: details.length,
|
charCount: details.length,
|
||||||
embed: {
|
embed: {
|
||||||
|
@ -258,7 +267,7 @@ export const generateRollEmbed = (
|
||||||
}
|
}
|
||||||
|
|
||||||
// blob is small enough, attach it
|
// blob is small enough, attach it
|
||||||
details += '\n\nFull details have been attached to this messaged as a `.txt` file for verification purposes.';
|
details += '\n\nFull details have been attached to a followup message as a formatted `.md` file for verification purposes.';
|
||||||
return {
|
return {
|
||||||
charCount: details.length,
|
charCount: details.length,
|
||||||
embed: {
|
embed: {
|
||||||
|
@ -268,7 +277,40 @@ export const generateRollEmbed = (
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
attachment: {
|
attachment: {
|
||||||
blob: b,
|
blob: b,
|
||||||
name: 'rollDetails.txt',
|
name: 'rollDetails.md',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const webViewCustomId = 'webview';
|
||||||
|
export const disabledStr = 'disabled';
|
||||||
|
export const toggleWebView = (attachmentMessage: DiscordenoMessage, ownerId: string, enableWebView: boolean) => {
|
||||||
|
attachmentMessage
|
||||||
|
.edit({
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
...attachmentMessage.embeds[0],
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Web View:',
|
||||||
|
value: enableWebView ? `[Open Web View](${config.api.publicDomain}api/webview?c=${attachmentMessage.channelId}&m=${attachmentMessage.id})` : `Web View is ${disabledStr}.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: MessageComponentTypes.ActionRow,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: MessageComponentTypes.Button,
|
||||||
|
label: enableWebView ? 'Disable Web View' : 'Enable Web View',
|
||||||
|
customId: `${webViewCustomId}${InteractionValueSeparator}${ownerId}${InteractionValueSeparator}${enableWebView ? 'disable' : 'enable'}`,
|
||||||
|
style: ButtonStyles.Secondary,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.catch((e) => utils.commonLoggers.messageEditError('embeds.ts:304', attachmentMessage, e));
|
||||||
|
};
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { apiChannel } from 'endpoints/gets/apiChannel.ts';
|
||||||
import { apiKey } from 'endpoints/gets/apiKey.ts';
|
import { apiKey } from 'endpoints/gets/apiKey.ts';
|
||||||
import { apiKeyAdmin } from 'endpoints/gets/apiKeyAdmin.ts';
|
import { apiKeyAdmin } from 'endpoints/gets/apiKeyAdmin.ts';
|
||||||
import { apiRoll } from 'endpoints/gets/apiRoll.ts';
|
import { apiRoll } from 'endpoints/gets/apiRoll.ts';
|
||||||
|
import { generateWebView } from 'endpoints/gets/webView.ts';
|
||||||
import { heatmapPng } from 'endpoints/gets/heatmapPng.ts';
|
import { heatmapPng } from 'endpoints/gets/heatmapPng.ts';
|
||||||
|
|
||||||
import { apiChannelAdd } from 'endpoints/posts/apiChannelAdd.ts';
|
import { apiChannelAdd } from 'endpoints/posts/apiChannelAdd.ts';
|
||||||
|
@ -21,6 +22,7 @@ export default {
|
||||||
apiRoll,
|
apiRoll,
|
||||||
apiKeyAdmin,
|
apiKeyAdmin,
|
||||||
apiChannel,
|
apiChannel,
|
||||||
|
generateWebView,
|
||||||
heatmapPng,
|
heatmapPng,
|
||||||
},
|
},
|
||||||
post: {
|
post: {
|
||||||
|
|
|
@ -2,12 +2,12 @@ import { STATUS_CODE, STATUS_TEXT } from '@std/http';
|
||||||
|
|
||||||
export const heatmapPng = (): Response => {
|
export const heatmapPng = (): Response => {
|
||||||
const file = Deno.readFileSync('./src/endpoints/gets/heatmap.png');
|
const file = Deno.readFileSync('./src/endpoints/gets/heatmap.png');
|
||||||
const imageHeaders = new Headers();
|
const headers = new Headers();
|
||||||
imageHeaders.append('Content-Type', 'image/png');
|
headers.append('Content-Type', 'image/png');
|
||||||
// Send basic OK to indicate key has been sent
|
// Send basic OK to indicate key has been sent
|
||||||
return new Response(file, {
|
return new Response(file, {
|
||||||
status: STATUS_CODE.OK,
|
status: STATUS_CODE.OK,
|
||||||
statusText: STATUS_TEXT[STATUS_CODE.OK],
|
statusText: STATUS_TEXT[STATUS_CODE.OK],
|
||||||
headers: imageHeaders,
|
headers,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
@ -0,0 +1,222 @@
|
||||||
|
import { DiscordenoMember, getChannel, getMember, getMessage, getRoles } from '@discordeno';
|
||||||
|
import { log, LogTypes as LT } from '@Log4Deno';
|
||||||
|
import showdown from '@showdown';
|
||||||
|
import { STATUS_CODE, STATUS_TEXT } from '@std/http/status';
|
||||||
|
|
||||||
|
import config from '~config';
|
||||||
|
|
||||||
|
import { disabledStr } from 'artigen/utils/embeds.ts';
|
||||||
|
|
||||||
|
import utils from 'utils/utils.ts';
|
||||||
|
|
||||||
|
// globalName is added with discord's new username system
|
||||||
|
interface ModernMemberHOTFIX extends DiscordenoMember {
|
||||||
|
globalName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const converter = new showdown.Converter({
|
||||||
|
emoji: true,
|
||||||
|
underline: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utilize the pre-existing stylesheets, do a little tweaking to make it ours
|
||||||
|
const wrapBasic = (str: string) =>
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||||
|
<title>The Artificer Roll Web View</title>
|
||||||
|
<meta name="distribution" content="web">
|
||||||
|
<meta name="web_author" content="Ean Milligan (ean@milligan.dev)">
|
||||||
|
<meta name="author" content="Ean Milligan (ean@milligan.dev)">
|
||||||
|
<meta name="designer" content="Ean Milligan (ean@milligan.dev)">
|
||||||
|
<meta name="publisher" content="Ean Milligan (ean@milligan.dev)">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<link rel="shortcut icon" href="https://discord.burne99.com/TheArtificer/favicon.ico">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=swap">
|
||||||
|
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Cinzel|Play">
|
||||||
|
<link rel="stylesheet" href="https://discord.burne99.com/TheArtificer/theme.css">
|
||||||
|
<link rel="stylesheet" href="https://discord.burne99.com/TheArtificer/main.css">
|
||||||
|
<style>
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
font-family: 'Play', sans-serif;
|
||||||
|
height: 2.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--header-font-color);
|
||||||
|
background-color: var(--footer-bg-color);
|
||||||
|
border: 2px solid var(--slug-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--code-bg);
|
||||||
|
}
|
||||||
|
.selected {
|
||||||
|
background-color: var(--page-bg-color);
|
||||||
|
}
|
||||||
|
strong {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body id="page">
|
||||||
|
${str}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
const centerHTML = (str: string) => `<center>${str}</center>`;
|
||||||
|
|
||||||
|
const badRequestMD = '# Invalid URL for Web View!';
|
||||||
|
const badRequestHTML = wrapBasic(centerHTML(converter.makeHtml(badRequestMD)));
|
||||||
|
|
||||||
|
const notAuthorizedMD = '# Web View is Disabled for this roll!';
|
||||||
|
const notAuthorizedHTML = wrapBasic(centerHTML(converter.makeHtml(notAuthorizedMD)));
|
||||||
|
|
||||||
|
const failedToGetAttachmentMD = '# Failed to get attachment from Discord!';
|
||||||
|
const failedToGetAttachmentHTML = wrapBasic(centerHTML(converter.makeHtml(failedToGetAttachmentMD)));
|
||||||
|
|
||||||
|
interface HtmlResp {
|
||||||
|
name: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerHeight = '3rem';
|
||||||
|
const generatePage = (files: HtmlResp[]): string =>
|
||||||
|
wrapBasic(`<header id="fileBtns" style="display: flex; align-items: center; height: ${headerHeight}; line-height: ${headerHeight}; font-size: 1.5rem;">
|
||||||
|
<a href="https://discord.burne99.com/TheArtificer/" target="_blank" rel="noopener">${config.name} Roll Web View</a>
|
||||||
|
<span style="margin-left: auto; font-family: 'Play', sans-serif; font-size: 1rem;">Available Files:</span>
|
||||||
|
${
|
||||||
|
files
|
||||||
|
.map(
|
||||||
|
(f, idx) =>
|
||||||
|
`<button style="margin-left: 1rem;" id="${f.name}-btn" class="${
|
||||||
|
idx === 0 ? 'selected' : ''
|
||||||
|
}" onclick="for (var child of document.getElementById('fileBody').children) {child.style.display = 'none'} document.getElementById('${f.name}').style.display = 'block'; for (var child of document.getElementById('fileBtns').children) {child.className = ''} document.getElementById('${f.name}-btn').className = 'selected';">${f.name}</button>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
<button style="margin-left: auto;" onclick="document.getElementById('fileBody').style.whiteSpace = (document.getElementById('fileBody').style.whiteSpace === 'pre' ? 'pre-wrap' : 'pre')">Toggle Word Wrap</button>
|
||||||
|
</header>
|
||||||
|
<div id="fileBody" style="height: calc(100vh - ${headerHeight}); margin: 0 0.5rem; overflow: auto; white-space: pre-wrap; font-family: 'Roboto', sans-serif; font-weight: 300;">
|
||||||
|
${files.map((f, idx) => `<div id="${f.name}" style="display: ${idx === 0 ? 'block' : 'none'};">${f.html}</div>`).join('')}
|
||||||
|
</div>`);
|
||||||
|
|
||||||
|
const colorShade = (col: string, amt: number) => {
|
||||||
|
col = col.replace(/^#/, '');
|
||||||
|
if (col.length === 3) col = col[0] + col[0] + col[1] + col[1] + col[2] + col[2];
|
||||||
|
|
||||||
|
const parts = col.match(/.{2}/g) ?? [];
|
||||||
|
let r = parts.shift() ?? '00';
|
||||||
|
let g = parts.shift() ?? '00';
|
||||||
|
let b = parts.shift() ?? '00';
|
||||||
|
const [rInt, gInt, bInt] = [parseInt(r, 16) + amt, parseInt(g, 16) + amt, parseInt(b, 16) + amt];
|
||||||
|
|
||||||
|
r = Math.max(Math.min(255, rInt), 0).toString(16);
|
||||||
|
g = Math.max(Math.min(255, gInt), 0).toString(16);
|
||||||
|
b = Math.max(Math.min(255, bInt), 0).toString(16);
|
||||||
|
|
||||||
|
const rr = (r.length < 2 ? '0' : '') + r;
|
||||||
|
const gg = (g.length < 2 ? '0' : '') + g;
|
||||||
|
const bb = (b.length < 2 ? '0' : '') + b;
|
||||||
|
|
||||||
|
return `#${rr}${gg}${bb}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeMention = (mentionType: string, name: string, backgroundColor: string, color = 'var(--page-font-color)') =>
|
||||||
|
`<span style="background-color: ${backgroundColor}; color: ${color}; padding: 2px 4px; border-radius: 4px;">${mentionType}${name}</span>`;
|
||||||
|
|
||||||
|
export const generateWebView = async (query: Map<string, string>): Promise<Response> => {
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.append('Content-Type', 'text/html');
|
||||||
|
|
||||||
|
const messageId = BigInt(query.get('m') ?? '0');
|
||||||
|
const channelId = BigInt(query.get('c') ?? '0');
|
||||||
|
|
||||||
|
if (!messageId || !channelId) {
|
||||||
|
return new Response(badRequestHTML, {
|
||||||
|
status: STATUS_CODE.BadRequest,
|
||||||
|
statusText: STATUS_TEXT[STATUS_CODE.BadRequest],
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentMessage = await getMessage(channelId, messageId).catch((e) => utils.commonLoggers.messageGetError('webView.ts:23', channelId, messageId, e));
|
||||||
|
const discordAttachments = attachmentMessage?.attachments ?? [];
|
||||||
|
const embed = attachmentMessage?.embeds.shift();
|
||||||
|
const webViewField = embed?.fields?.shift();
|
||||||
|
|
||||||
|
if (!attachmentMessage || discordAttachments.length === 0 || !embed || !webViewField) {
|
||||||
|
return new Response(badRequestHTML, {
|
||||||
|
status: STATUS_CODE.BadRequest,
|
||||||
|
statusText: STATUS_TEXT[STATUS_CODE.BadRequest],
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webViewField.value.includes(disabledStr)) {
|
||||||
|
return new Response(notAuthorizedHTML, {
|
||||||
|
status: STATUS_CODE.Forbidden,
|
||||||
|
statusText: STATUS_TEXT[STATUS_CODE.Forbidden],
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const htmlArr: HtmlResp[] = [];
|
||||||
|
for (const discordAttachment of discordAttachments) {
|
||||||
|
const attachment = await fetch(discordAttachment.url).catch((e) => log(LT.LOG, `Failed to get attachment: ${discordAttachment}`, e));
|
||||||
|
const bodyText = (await attachment?.text()) ?? '';
|
||||||
|
|
||||||
|
htmlArr.push({
|
||||||
|
name: discordAttachment.filename,
|
||||||
|
html: bodyText ? converter.makeHtml(bodyText) : failedToGetAttachmentHTML,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let fullPage = generatePage(htmlArr);
|
||||||
|
|
||||||
|
if (fullPage.indexOf('<@&')) {
|
||||||
|
const guildRoles = (await getRoles(attachmentMessage.guildId).catch((e) => log(LT.LOG, `Failed to get Guild Roles: ${attachmentMessage.guildId}`, e))) ?? [];
|
||||||
|
const rolesToReplace = fullPage.matchAll(/<@&(\d+)>/g);
|
||||||
|
for (const roleToReplace of rolesToReplace) {
|
||||||
|
const role = guildRoles.filter((r) => r.id === BigInt(roleToReplace[1] ?? '-1')).shift() ?? { name: 'unknown-role', color: 4211819 };
|
||||||
|
fullPage = fullPage.replaceAll(
|
||||||
|
roleToReplace[0],
|
||||||
|
makeMention('@', role.name, colorShade(`#${role.color.toString(16)}`, -100), colorShade(`#${role.color.toString(16)}`, 50)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullPage.indexOf('<#')) {
|
||||||
|
const channelsToReplace = fullPage.matchAll(/<#(\d+)>/g);
|
||||||
|
for (const channelToReplace of channelsToReplace) {
|
||||||
|
const channel = (await getChannel(BigInt(channelToReplace[1] ?? '-1')).catch((e) => log(LT.LOG, `Failed to get Channel: ${channelToReplace[1]}`, e))) ?? {
|
||||||
|
name: 'unknown',
|
||||||
|
};
|
||||||
|
fullPage = fullPage.replaceAll(channelToReplace[0], makeMention('#', channel.name ?? 'unknown', '#40446b'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullPage.indexOf('<@')) {
|
||||||
|
const usersToReplace = fullPage.matchAll(/<@(\d+)>/g);
|
||||||
|
for (const userToReplace of usersToReplace) {
|
||||||
|
const rawUser = await getMember(attachmentMessage.guildId, BigInt(userToReplace[1] ?? '-1')).catch((e) => log(LT.LOG, `Failed to get Channel: ${userToReplace[1]}`, e));
|
||||||
|
const user = rawUser ? (rawUser as ModernMemberHOTFIX) : {
|
||||||
|
name: (_gId: bigint) => 'unknown-user',
|
||||||
|
username: 'unknown-user',
|
||||||
|
globalName: 'unknown-user',
|
||||||
|
};
|
||||||
|
const nickName = user.name(attachmentMessage.guildId);
|
||||||
|
const name = nickName === user.username ? user.globalName : nickName ?? user.globalName;
|
||||||
|
fullPage = fullPage.replaceAll(userToReplace[0], makeMention('@', name ?? user.username, '#40446b'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(fullPage, {
|
||||||
|
status: STATUS_CODE.OK,
|
||||||
|
statusText: STATUS_TEXT[STATUS_CODE.OK],
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
};
|
|
@ -1,31 +1,76 @@
|
||||||
import { ButtonData, DiscordMessageComponentTypes, editMessage, Interaction, InteractionResponseTypes, SelectMenuData, sendInteractionResponse } from '@discordeno';
|
import {
|
||||||
|
ButtonData,
|
||||||
|
DiscordenoMessage,
|
||||||
|
DiscordMessageComponentTypes,
|
||||||
|
editMessage,
|
||||||
|
Interaction,
|
||||||
|
InteractionResponseTypes,
|
||||||
|
MessageFlags,
|
||||||
|
SelectMenuData,
|
||||||
|
sendInteractionResponse,
|
||||||
|
structures,
|
||||||
|
} from '@discordeno';
|
||||||
import { log, LogTypes as LT } from '@Log4Deno';
|
import { log, LogTypes as LT } from '@Log4Deno';
|
||||||
|
|
||||||
|
import { toggleWebView, webViewCustomId } from 'artigen/utils/embeds.ts';
|
||||||
|
|
||||||
import { generateHelpMessage, helpCustomId } from 'commands/helpLibrary/generateHelpMessage.ts';
|
import { generateHelpMessage, helpCustomId } from 'commands/helpLibrary/generateHelpMessage.ts';
|
||||||
|
|
||||||
|
import { failColor } from 'embeds/colors.ts';
|
||||||
|
|
||||||
import utils from 'utils/utils.ts';
|
import utils from 'utils/utils.ts';
|
||||||
|
|
||||||
export const InteractionValueSeparator = '\u205a';
|
export const InteractionValueSeparator = '\u205a';
|
||||||
|
|
||||||
export const interactionCreateHandler = (interaction: Interaction) => {
|
const ackInteraction = (interaction: Interaction) =>
|
||||||
|
sendInteractionResponse(interaction.id, interaction.token, {
|
||||||
|
type: InteractionResponseTypes.DeferredUpdateMessage,
|
||||||
|
}).catch((e: Error) => utils.commonLoggers.messageSendError('interactionCreate.ts:26', interaction, e));
|
||||||
|
|
||||||
|
export const interactionCreateHandler = async (interaction: Interaction) => {
|
||||||
try {
|
try {
|
||||||
if (interaction.data) {
|
if (interaction.data) {
|
||||||
const parsedData = JSON.parse(JSON.stringify(interaction.data)) as SelectMenuData | ButtonData;
|
const parsedData = JSON.parse(JSON.stringify(interaction.data)) as SelectMenuData | ButtonData;
|
||||||
|
|
||||||
if (parsedData.customId.startsWith(helpCustomId) && parsedData.componentType === DiscordMessageComponentTypes.SelectMenu) {
|
if (parsedData.customId.startsWith(helpCustomId) && parsedData.componentType === DiscordMessageComponentTypes.SelectMenu) {
|
||||||
// Acknowledge the request since we're editing the original message
|
// Acknowledge the request since we're editing the original message
|
||||||
sendInteractionResponse(interaction.id, interaction.token, {
|
ackInteraction(interaction);
|
||||||
type: InteractionResponseTypes.DeferredUpdateMessage,
|
|
||||||
}).catch((e: Error) => utils.commonLoggers.messageEditError('interactionCreate.ts:26', interaction, e));
|
|
||||||
|
|
||||||
// Edit original message
|
// Edit original message
|
||||||
editMessage(BigInt(interaction.channelId ?? '0'), BigInt(interaction.message?.id ?? '0'), generateHelpMessage(parsedData.values[0])).catch((e: Error) =>
|
editMessage(BigInt(interaction.channelId ?? '0'), BigInt(interaction.message?.id ?? '0'), generateHelpMessage(parsedData.values[0])).catch((e) =>
|
||||||
utils.commonLoggers.messageEditError('interactionCreate.ts:30', interaction, e)
|
utils.commonLoggers.messageEditError('interactionCreate.ts:30', interaction, e)
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log(LT.WARN, `UNHANDLED INTERACTION!!! data: ${JSON.stringify(interaction.data)}`);
|
if (parsedData.customId.startsWith(webViewCustomId) && parsedData.componentType === DiscordMessageComponentTypes.Button && interaction.message) {
|
||||||
|
const ownerId = parsedData.customId.split(InteractionValueSeparator)[1] ?? 'missingOwnerId';
|
||||||
|
const userInteractingId = interaction.member?.user.id ?? interaction.user?.id ?? 'missingUserId';
|
||||||
|
if (ownerId === userInteractingId) {
|
||||||
|
ackInteraction(interaction);
|
||||||
|
const enableWebView = parsedData.customId.split(InteractionValueSeparator)[2] === 'enable';
|
||||||
|
const ddMsg: DiscordenoMessage = await structures.createDiscordenoMessage(interaction.message);
|
||||||
|
|
||||||
|
toggleWebView(ddMsg, ownerId, enableWebView);
|
||||||
|
} else {
|
||||||
|
sendInteractionResponse(interaction.id, interaction.token, {
|
||||||
|
type: InteractionResponseTypes.ChannelMessageWithSource,
|
||||||
|
data: {
|
||||||
|
flags: MessageFlags.Empheral,
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
color: failColor,
|
||||||
|
title: 'Not Allowed!',
|
||||||
|
description: 'Only the original user that requested this roll can disable/enable Web View.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}).catch((e) => utils.commonLoggers.messageSendError('interactionCreate.ts:57', interaction, e));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(LT.WARN, `UNHANDLED INTERACTION!!! data: ${JSON.stringify(interaction.data)} | Full Interaction: ${JSON.stringify(interaction)}`);
|
||||||
} else {
|
} else {
|
||||||
log(LT.WARN, `UNHANDLED INTERACTION!!! Missing data! ${JSON.stringify(interaction)}`);
|
log(LT.WARN, `UNHANDLED INTERACTION!!! Missing data! ${JSON.stringify(interaction)}`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,9 +7,11 @@ import { log, LogTypes as LT } from '@Log4Deno';
|
||||||
import { DiscordenoMessage, Interaction } from '@discordeno';
|
import { DiscordenoMessage, Interaction } from '@discordeno';
|
||||||
|
|
||||||
const genericLogger = (level: LT, message: string) => log(level, message);
|
const genericLogger = (level: LT, message: string) => log(level, message);
|
||||||
|
const messageGetError = (location: string, channelId: bigint | string, messageId: bigint | string, err: Error) =>
|
||||||
|
genericLogger(LT.ERROR, `${location} | Failed to edit message: ${channelId}-${messageId} | Error: ${err.name} - ${err.message}`);
|
||||||
const messageEditError = (location: string, message: DiscordenoMessage | Interaction | string, err: Error) =>
|
const messageEditError = (location: string, message: DiscordenoMessage | Interaction | string, err: Error) =>
|
||||||
genericLogger(LT.ERROR, `${location} | Failed to edit message: ${JSON.stringify(message)} | Error: ${err.name} - ${err.message}`);
|
genericLogger(LT.ERROR, `${location} | Failed to edit message: ${JSON.stringify(message)} | Error: ${err.name} - ${err.message}`);
|
||||||
const messageSendError = (location: string, message: DiscordenoMessage | string, err: Error) =>
|
const messageSendError = (location: string, message: DiscordenoMessage | Interaction | string, err: Error) =>
|
||||||
genericLogger(LT.ERROR, `${location} | Failed to send message: ${JSON.stringify(message)} | Error: ${err.name} - ${err.message}`);
|
genericLogger(LT.ERROR, `${location} | Failed to send message: ${JSON.stringify(message)} | Error: ${err.name} - ${err.message}`);
|
||||||
const messageDeleteError = (location: string, message: DiscordenoMessage | string, err: Error) =>
|
const messageDeleteError = (location: string, message: DiscordenoMessage | string, err: Error) =>
|
||||||
genericLogger(LT.ERROR, `${location} | Failed to delete message: ${JSON.stringify(message)} | Error: ${err.name} - ${err.message}`);
|
genericLogger(LT.ERROR, `${location} | Failed to delete message: ${JSON.stringify(message)} | Error: ${err.name} - ${err.message}`);
|
||||||
|
@ -18,8 +20,9 @@ const dbError = (location: string, type: string, err: Error) => genericLogger(LT
|
||||||
export default {
|
export default {
|
||||||
commonLoggers: {
|
commonLoggers: {
|
||||||
dbError,
|
dbError,
|
||||||
messageEditError,
|
|
||||||
messageSendError,
|
|
||||||
messageDeleteError,
|
messageDeleteError,
|
||||||
|
messageEditError,
|
||||||
|
messageGetError,
|
||||||
|
messageSendError,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
body {
|
body {
|
||||||
font-family: "Play", sans-serif;
|
font-family: 'Play', sans-serif;
|
||||||
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -11,21 +11,22 @@ body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto;
|
grid-template-columns: auto;
|
||||||
grid-template-rows: 3rem calc(100vh - 5rem) 2rem;
|
grid-template-rows: 3rem calc(100vh - 5rem) 2rem;
|
||||||
grid-template-areas: "header" "page-contents" "footer";
|
grid-template-areas: 'header' 'page-contents' 'footer';
|
||||||
|
|
||||||
color: var(--page-font-color);
|
color: var(--page-font-color);
|
||||||
background-color: var(--page-bg-color);
|
background-color: var(--page-bg-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
header,
|
||||||
#header {
|
#header {
|
||||||
grid-area: header;
|
grid-area: header;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
grid-template-areas: "header-left header-right";
|
grid-template-areas: 'header-left header-right';
|
||||||
|
|
||||||
font-family: "Cinzel", serif;
|
font-family: 'Cinzel', serif;
|
||||||
font-size: 2rem;
|
font-size: 2rem;
|
||||||
line-height: 3rem;
|
line-height: 3rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
@ -59,7 +60,7 @@ body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1.5fr 1.5fr 1fr;
|
grid-template-columns: 1fr 1.5fr 1.5fr 1fr;
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
grid-template-areas: ". footer-left footer-right .";
|
grid-template-areas: '. footer-left footer-right .';
|
||||||
|
|
||||||
line-height: 2rem;
|
line-height: 2rem;
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
@ -84,7 +85,7 @@ body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto;
|
grid-template-columns: auto;
|
||||||
grid-template-rows: fit-content(5rem) fit-content(10rem) fit-content(37rem) auto 1rem;
|
grid-template-rows: fit-content(5rem) fit-content(10rem) fit-content(37rem) auto 1rem;
|
||||||
grid-template-areas: "slogan" "logo-desc" "examples" "api" "final";
|
grid-template-areas: 'slogan' 'logo-desc' 'examples' 'api' 'final';
|
||||||
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
@ -105,7 +106,7 @@ body {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 11rem auto;
|
grid-template-columns: 11rem auto;
|
||||||
grid-template-rows: auto;
|
grid-template-rows: auto;
|
||||||
grid-template-areas: "logo description";
|
grid-template-areas: 'logo description';
|
||||||
}
|
}
|
||||||
|
|
||||||
#logo {
|
#logo {
|
||||||
|
|
|
@ -20,7 +20,10 @@
|
||||||
--slug-border: rgb(0, 0, 0);
|
--slug-border: rgb(0, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#header a {
|
header a,
|
||||||
|
header a:visited,
|
||||||
|
#header a,
|
||||||
|
#header a:visited {
|
||||||
color: var(--header-font-color);
|
color: var(--header-font-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
@ -29,11 +32,13 @@ a {
|
||||||
color: var(--link-new-color);
|
color: var(--link-new-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:active, a:visited {
|
a:active,
|
||||||
|
a:visited {
|
||||||
color: var(--link-visited-color);
|
color: var(--link-visited-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
a:hover,
|
a:hover,
|
||||||
|
header a:hover,
|
||||||
#header a:hover {
|
#header a:hover {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--link-hover-color);
|
color: var(--link-hover-color);
|
||||||
|
|
Loading…
Reference in New Issue