├── config.example.json ├── .gitignore ├── pm2.json ├── src ├── assets.js ├── utils │ ├── lateDefine.js │ ├── copy.js │ ├── index.js │ ├── contextMenu.js │ ├── createElement.js │ ├── createTooltip.js │ ├── showLargerImage.js │ └── createUserPopout.js ├── elements │ ├── MessageMarkup.js │ ├── MessageSpoiler.js │ ├── MessageEmoji.js │ ├── MessageHeader.js │ ├── ThemeSwitch.js │ ├── MessageMention.js │ ├── DiscordMessage.js │ ├── MessageCodeblock.js │ ├── MessageVideo.js │ ├── MessageAvatar.js │ ├── DiscordMessages.js │ ├── MessageImage.js │ ├── MessageDate.js │ ├── DiscordInvite.js │ └── MessageAttachment.js ├── commons │ └── fit.js ├── index.js ├── markdown.js └── formatter.js ├── README.md ├── style ├── style.scss ├── _font.scss ├── _footer.scss ├── _context.scss ├── _spinner.scss ├── _header.scss ├── _tooltip.scss ├── _popout.scss ├── _message.scss ├── _invite.scss ├── _main.scss ├── _attachment.scss ├── _modal.scss ├── _embed.scss ├── _chat.scss └── _include.scss ├── .github └── dependabot.yml ├── .eslintrc ├── dl-font.js ├── package.json ├── views ├── header.ejs ├── index.ejs ├── attachment.ejs ├── messages.ejs └── embed.ejs ├── index.js ├── LICENSE └── example.json /config.example.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 1234 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # JetBrains memes 2 | .idea 3 | 4 | # NodeJS memes 5 | node_modules 6 | 7 | # prod memes 8 | config.json 9 | dist 10 | -------------------------------------------------------------------------------- /pm2.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "name": "Discord Chat Replica", 5 | "script": "./index.js" 6 | } 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /src/assets.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | const { readFileSync } = require('fs') 7 | const { join } = require('path') 8 | 9 | const script = readFileSync(join(__dirname, '..', 'dist', 'script.js')) 10 | const style = readFileSync(join(__dirname, '..', 'dist', 'style.css')) 11 | 12 | module.exports = { style, script } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discord Chat Replica 2 | [![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=Naila/Discord-chat-replica)](https://dependabot.com) 3 | 4 | A small microservice to generate a Discord-like chat section. 5 | 6 | ## installation 7 | ~~you dont~~ 8 | 9 | - git clone 10 | - pnpm i 11 | - pnpm run build 12 | - create config.json 13 | - pm2 start pm2.json 14 | 15 | ## request format 16 | see example.json 17 | -------------------------------------------------------------------------------- /style/style.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | @import 'font'; 7 | @import 'main'; 8 | @import 'header'; 9 | @import 'chat'; 10 | @import 'message'; 11 | @import 'invite'; 12 | @import 'attachment'; 13 | @import 'embed'; 14 | @import 'footer'; 15 | @import 'modal'; 16 | @import 'popout'; 17 | @import 'tooltip'; 18 | @import 'context'; 19 | @import 'spinner'; 20 | -------------------------------------------------------------------------------- /src/utils/lateDefine.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | function lateDefine (str, element, opts) { 7 | if (document.readyState === 'loading') { 8 | document.addEventListener('DOMContentLoaded', () => customElements.define(str, element, opts)) 9 | } else { 10 | customElements.define(str, element, opts) 11 | } 12 | } 13 | 14 | export default lateDefine 15 | -------------------------------------------------------------------------------- /src/utils/copy.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | function copy (text) { 7 | const textarea = document.createElement('textarea') 8 | textarea.value = text 9 | textarea.style.opacity = '0' 10 | textarea.style.position = 'absolute' 11 | textarea.style.pointerEvents = 'none' 12 | document.body.appendChild(textarea) 13 | textarea.select() 14 | document.execCommand('copy') 15 | textarea.remove() 16 | } 17 | 18 | export default copy 19 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | export { default as lateDefine } from './lateDefine' 7 | export { default as createElement } from './createElement' 8 | export { default as createTooltip } from './createTooltip' 9 | export { default as createUserPopout } from './createUserPopout' 10 | export { default as showLargerImage } from './showLargerImage' 11 | export { default as contextMenu } from './contextMenu' 12 | export { default as copy } from './copy' 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "13:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: esbuild 11 | versions: 12 | - 0.10.0 13 | - 0.10.1 14 | - 0.11.0 15 | - 0.11.10 16 | - 0.11.11 17 | - 0.11.12 18 | - 0.11.13 19 | - 0.11.15 20 | - 0.11.2 21 | - 0.11.3 22 | - 0.11.5 23 | - 0.11.6 24 | - 0.11.9 25 | - 0.9.0 26 | - 0.9.1 27 | - 0.9.2 28 | - 0.9.3 29 | - 0.9.4 30 | - 0.9.6 31 | -------------------------------------------------------------------------------- /src/elements/MessageMarkup.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine } from '../utils' 7 | 8 | class MessageMarkup extends HTMLElement { 9 | connectedCallback () { 10 | const actualNodes = [ ...this.childNodes ].filter(n => !(n instanceof HTMLBRElement)) 11 | if (actualNodes.length < 28 && !actualNodes.find(n => !n.classList || !n.classList.contains('emoji'))) { 12 | actualNodes.forEach(n => n.classList.add('jumbo')) 13 | } 14 | } 15 | } 16 | 17 | lateDefine('message-markup', MessageMarkup) 18 | -------------------------------------------------------------------------------- /src/elements/MessageSpoiler.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | class MessageSpoiler extends HTMLSpanElement { 7 | constructor () { 8 | super() 9 | this.onClick = this.onClick.bind(this) 10 | } 11 | 12 | connectedCallback () { 13 | this.addEventListener('click', this.onClick) 14 | } 15 | 16 | onClick () { 17 | this.classList.add('revealed') 18 | this.removeEventListener('click', this.onClick) 19 | } 20 | } 21 | 22 | customElements.define('message-spoiler', MessageSpoiler, { extends: 'span' }) 23 | -------------------------------------------------------------------------------- /style/_font.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | @font-face { 7 | font-family: Whitney; 8 | font-weight: 400; 9 | src: url(/dist/Whitney400.woff) format("woff") 10 | } 11 | 12 | @font-face { 13 | font-family: Whitney; 14 | font-weight: 500; 15 | src: url(/dist/Whitney500.woff) format("woff") 16 | } 17 | 18 | @font-face { 19 | font-family: Whitney; 20 | font-weight: 600; 21 | src: url(/dist/Whitney600.woff) format("woff") 22 | } 23 | 24 | @font-face { 25 | font-family: Whitney; 26 | font-weight: 700; 27 | src: url(/dist/Whitney700.woff) format("woff") 28 | } 29 | -------------------------------------------------------------------------------- /src/elements/MessageEmoji.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, createTooltip } from '../utils' 7 | 8 | class MessageEmoji extends HTMLImageElement { 9 | constructor () { 10 | super() 11 | this.onError = this.onError.bind(this) 12 | } 13 | 14 | connectedCallback () { 15 | this.addEventListener('error', this.onError) 16 | createTooltip(this, this.alt) 17 | } 18 | 19 | onError () { 20 | this.parentNode.replaceChild(document.createTextNode(this.alt), this) 21 | } 22 | } 23 | 24 | lateDefine('message-emoji', MessageEmoji, { extends: 'img' }) 25 | -------------------------------------------------------------------------------- /src/commons/fit.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | module.exports = (width, height, maxWidth, maxHeight) => { 7 | if (width !== maxWidth || height !== maxHeight) { 8 | const widthRatio = width > maxWidth ? maxWidth / width : 1 9 | width = Math.max(Math.round(width * widthRatio), 0) 10 | height = Math.max(Math.round(height * widthRatio), 0) 11 | 12 | const heightRatio = height > maxHeight ? maxHeight / height : 1 13 | width = Math.max(Math.round(width * heightRatio), 0) 14 | height = Math.max(Math.round(height * heightRatio), 0) 15 | } 16 | 17 | return { 18 | width, 19 | height 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /style/_footer.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | footer { 7 | flex-shrink: 0; 8 | display: flex; 9 | align-items: center; 10 | height: 52px; 11 | border-radius: 5px; 12 | margin: 0 16px 24px; 13 | padding: 16px; 14 | width: calc(100% - 32px); 15 | background-color: var(--background-very-dark); 16 | position: relative; 17 | z-index: 10; 18 | user-select: none; 19 | 20 | img { 21 | height: 60px; 22 | margin-right: 12px; 23 | margin-bottom: -8px; 24 | align-self: flex-end; 25 | } 26 | 27 | span { 28 | font-size: 16px; 29 | line-height: 20px; 30 | font-weight: 600; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/elements/MessageHeader.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, createUserPopout } from '../utils' 7 | 8 | class MessageHeader extends HTMLElement { 9 | connectedCallback () { 10 | const image = this.parentElement.previousElementSibling.previousElementSibling 11 | createUserPopout(this.querySelector('.name'), { 12 | id: this.parentElement.parentElement.dataset.author, 13 | username: this.querySelector('.name').textContent, 14 | discriminator: image.dataset.discriminator, 15 | avatar: image.src, 16 | badge: this.querySelector('.badge').textContent 17 | }) 18 | } 19 | } 20 | 21 | lateDefine('message-header', MessageHeader) 22 | -------------------------------------------------------------------------------- /src/elements/ThemeSwitch.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | class ThemeSwitch extends HTMLElement { 7 | constructor () { 8 | super() 9 | this.toggle = this.toggle.bind(this) 10 | } 11 | 12 | connectedCallback () { 13 | this.addEventListener('click', this.toggle) 14 | } 15 | 16 | toggle () { 17 | if (document.body.classList.contains('theme-dark')) { 18 | document.body.classList.remove('theme-dark') 19 | document.body.classList.add('theme-light') 20 | } else { 21 | document.body.classList.remove('theme-light') 22 | document.body.classList.add('theme-dark') 23 | } 24 | } 25 | } 26 | 27 | customElements.define('theme-switch', ThemeSwitch) 28 | -------------------------------------------------------------------------------- /style/_context.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .context-menu { 7 | position: absolute; 8 | min-width: 188px; 9 | max-width: 320px; 10 | border-radius: 4px; 11 | padding: 6px 8px; 12 | background-color: var(--background-extreme-dark); 13 | box-shadow: var(--elevation-high); 14 | font-weight: 500; 15 | font-size: 14px; 16 | line-height: 18px; 17 | color: var(--color-light-gray); 18 | z-index: 6969; 19 | 20 | .item { 21 | margin: 2px 0; 22 | padding: 0 8px; 23 | display: flex; 24 | align-items: center; 25 | border-radius: 2px; 26 | min-height: 32px; 27 | cursor: pointer; 28 | } 29 | 30 | .item:hover { 31 | color: var(--color); 32 | background-color: var(--background-context-hover); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/elements/MessageMention.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { copy, contextMenu, createUserPopout } from '../utils' 7 | 8 | class MessageMention extends HTMLElement { 9 | connectedCallback () { 10 | contextMenu(this, [ { 11 | name: `Copy ${this.dataset.type[0].toUpperCase() + this.dataset.type.slice(1)} ID`, 12 | callback: () => copy(this.dataset.id) 13 | } ]) 14 | if (this.dataset.type === 'user') { 15 | createUserPopout(this, { 16 | id: this.dataset.id || '', 17 | username: this.dataset.username || '', 18 | discriminator: this.dataset.discriminator || '', 19 | avatar: this.dataset.avatar || '', 20 | badge: this.dataset.badge || '' 21 | }) 22 | } 23 | } 24 | } 25 | 26 | customElements.define('message-mention', MessageMention) 27 | -------------------------------------------------------------------------------- /src/elements/DiscordMessage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, copy, contextMenu } from '../utils' 7 | 8 | class DiscordMessage extends HTMLElement { 9 | connectedCallback () { 10 | contextMenu(this, [ { 11 | name: 'Copy Message ID', 12 | callback: () => copy(this.dataset.id) 13 | } ]) 14 | contextMenu(this.querySelector('.avatar'), [ { 15 | name: 'Copy Avatar URL', 16 | callback: () => copy(this.querySelector('.avatar').src) 17 | }, { 18 | name: 'Copy User ID', 19 | callback: () => copy(this.dataset.author) 20 | } ]) 21 | contextMenu(this.querySelector('message-header .name'), [ { 22 | name: 'Copy User ID', 23 | callback: () => copy(this.dataset.author) 24 | } ]) 25 | } 26 | } 27 | 28 | lateDefine('discord-message', DiscordMessage) 29 | -------------------------------------------------------------------------------- /style/_spinner.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .spinner { 7 | position: relative; 8 | display: inline-block; 9 | height: 32px; 10 | width: 32px; 11 | 12 | &:before, &:after { 13 | content: ''; 14 | animation: spinner 1.8s infinite ease-in-out; 15 | background-color: #7289da; 16 | position: absolute; 17 | height: 10px; 18 | width: 10px; 19 | left: 0; 20 | top: 0; 21 | } 22 | 23 | &:after { 24 | animation-delay: -.9s; 25 | } 26 | } 27 | 28 | @keyframes spinner { 29 | 25% { 30 | transform: translateX(22px) rotate(-90deg) scale(.5) 31 | } 32 | 50% { 33 | transform: translateX(22px) translateY(22px) rotate(-180deg) 34 | } 35 | 75% { 36 | transform: translateX(0) translateY(22px) rotate(-270deg) scale(.5) 37 | } 38 | 100% { 39 | transform: rotate(-1turn) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "env": { 4 | "node": true, 5 | "browser": true 6 | }, 7 | "globals": { 8 | "twemoji": true 9 | }, 10 | "rules": { 11 | "no-void": "off", 12 | "no-undefined": "error", 13 | "prefer-const": "error", 14 | "prefer-destructuring": [ 15 | "error", 16 | { 17 | "VariableDeclarator": { 18 | "array": false, 19 | "object": true 20 | }, 21 | "AssignmentExpression": { 22 | "array": true, 23 | "object": true 24 | } 25 | } 26 | ], 27 | "prefer-template": "error", 28 | "array-bracket-newline": [ 29 | "error", 30 | "consistent" 31 | ], 32 | "array-bracket-spacing": [ 33 | "error", 34 | "always" 35 | ], 36 | "multiline-ternary": [ 37 | "error", 38 | "always-multiline" 39 | ], 40 | "object-property-newline": "error" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /dl-font.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | const http2 = require('http2') 7 | const path = require('path') 8 | const fs = require('fs') 9 | 10 | const fonts = Object.entries({ 11 | Whitney400: 'e8acd7d9bf6207f99350ca9f9e23b168', 12 | Whitney500: '3bdef1251a424500c1b3a78dea9b7e57', 13 | Whitney600: 'be0060dafb7a0e31d2a1ca17c0708636', 14 | Whitney700: '8e12fb4f14d9c4592eb8ec9f22337b04' 15 | }) 16 | 17 | const client = http2.connect('https://discord.com:443') 18 | Promise.all( 19 | fonts.map(([ font, hash ]) => new Promise(resolve => { 20 | const chunks = [] 21 | const dest = path.join(__dirname, `/dist/${font}.woff`) 22 | const req = client.request({ ':path': `/assets/${hash}.woff` }) 23 | req.on('data', chk => chunks.push(chk)) 24 | req.on('end', () => fs.writeFileSync(dest, Buffer.concat(chunks)) | resolve()) 25 | req.end() 26 | })) 27 | ).then(() => console.log('Downloaded fonts.') | client.close()) 28 | -------------------------------------------------------------------------------- /src/elements/MessageCodeblock.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, copy } from '../utils' 7 | 8 | class MessageCodeblock extends HTMLPreElement { 9 | constructor () { 10 | super() 11 | this.onClick = this.onClick.bind(this) 12 | this.animation = false 13 | } 14 | 15 | connectedCallback () { 16 | this.copyElement = this.querySelector('.copy') 17 | this.copyElement.addEventListener('click', this.onClick) 18 | } 19 | 20 | onClick () { 21 | if (this.animation) return 22 | this.animation = true 23 | this.copyElement.classList.add('success') 24 | this.copyElement.innerText = 'Copied!' 25 | copy(this.querySelector('code').textContent) 26 | 27 | setTimeout(() => { 28 | this.copyElement.classList.remove('success') 29 | this.copyElement.innerText = 'Copy' 30 | }, 3e3) 31 | } 32 | } 33 | 34 | lateDefine('message-codeblock', MessageCodeblock, { extends: 'pre' }) 35 | -------------------------------------------------------------------------------- /src/elements/MessageVideo.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine } from '../utils' 7 | 8 | class MessageVideo extends HTMLDivElement { 9 | constructor () { 10 | super() 11 | this.onClick = this.onClick.bind(this) 12 | } 13 | 14 | connectedCallback () { 15 | this.querySelector('.play').addEventListener('click', this.onClick) 16 | } 17 | 18 | onClick () { 19 | this.innerHTML = '' 20 | const iframe = document.createElement('iframe') 21 | iframe.width = parseInt(this.style.width).toString() 22 | iframe.height = parseInt(this.style.height).toString() 23 | iframe.frameBorder = '0' 24 | iframe.allowFullscreen = true 25 | const url = new URL(this.dataset.url) 26 | url.searchParams.append('autoplay', '1') 27 | url.searchParams.append('auto_play', '1') 28 | iframe.src = url.href 29 | this.appendChild(iframe) 30 | } 31 | } 32 | 33 | lateDefine('message-video', MessageVideo, { extends: 'div' }) 34 | -------------------------------------------------------------------------------- /style/_header.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | header { 7 | display: flex; 8 | flex-shrink: 0; 9 | align-items: center; 10 | user-select: none; 11 | height: 48px; 12 | font-weight: 600; 13 | font-size: 16px; 14 | box-shadow: var(--elevation); 15 | 16 | svg { 17 | margin-left: 16px; 18 | margin-right: 8px; 19 | margin-top: 1px; 20 | color: var(--color-gray) 21 | } 22 | 23 | .topic, .date { 24 | margin-left: 16px; 25 | text-overflow: ellipsis; 26 | overflow: hidden; 27 | font-size: 14px; 28 | line-height: 18px; 29 | font-weight: 500; 30 | color: var(--color-light-gray); 31 | } 32 | 33 | .topic { 34 | padding-left: 16px; 35 | border-left: 1px var(--spacer) solid; 36 | flex: 1; 37 | } 38 | 39 | // noinspection CssInvalidHtmlTagReference 40 | theme-switch { 41 | width: 24px; 42 | height: 24px; 43 | background-image: var(--theme); 44 | margin-right: 16px; 45 | cursor: pointer; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-chat-replica", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "author": "Cynthia ", 6 | "license": "OSL-3.0", 7 | "scripts": { 8 | "build-scss": "sass -s compressed style/style.scss:dist/style.css && node dl-font.js", 9 | "build-js": "esbuild src/index.js --outfile=dist/script.js --bundle --minify --target=chrome85,firefox81,safari13,edge86 --define:process.env.NODE_ENV='\"production\"'", 10 | "build": "pnpm run build-js && pnpm run build-scss" 11 | }, 12 | "dependencies": { 13 | "ejs": "^3.1.5", 14 | "highlight.js": "^10.4.1", 15 | "html-minifier": "^4.0.0", 16 | "mime-types": "^2.1.27", 17 | "punycode": "^2.1.1", 18 | "simple-markdown": "^0.7.2", 19 | "twemoji": "^13.0.1" 20 | }, 21 | "devDependencies": { 22 | "esbuild": "^0.8.26", 23 | "eslint": "^7.16.0", 24 | "eslint-config-standard": "^16.0.2", 25 | "eslint-plugin-import": "^2.22.1", 26 | "eslint-plugin-node": "^11.1.0", 27 | "eslint-plugin-promise": "^4.2.1", 28 | "eslint-plugin-standard": "^4.1.0", 29 | "sass": "^1.30.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/elements/MessageAvatar.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, createUserPopout } from '../utils' 7 | 8 | class MessageAvatar extends HTMLImageElement { 9 | constructor () { 10 | super() 11 | this.onError = this.onError.bind(this) 12 | } 13 | 14 | connectedCallback () { 15 | this.addEventListener('error', this.onError) 16 | const contents = this.nextElementSibling.nextElementSibling 17 | createUserPopout(this, { 18 | id: this.parentElement.dataset.author, 19 | username: contents.querySelector('.name').textContent, 20 | discriminator: this.dataset.discriminator, 21 | avatar: this.src, 22 | badge: contents.querySelector('.badge').textContent 23 | }) 24 | } 25 | 26 | onError () { 27 | this.removeEventListener('error', this.onError) 28 | const discriminator = parseInt(this.dataset.discriminator) || 0 29 | this.src = `https://cdn.discordapp.com/embed/avatars/${discriminator % 4}.png` 30 | } 31 | } 32 | 33 | lateDefine('message-avatar', MessageAvatar, { extends: 'img' }) 34 | -------------------------------------------------------------------------------- /src/elements/DiscordMessages.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, createElement } from '../utils' 7 | 8 | const months = [ 9 | 'January', 'February', 'March', 10 | 'April', 'May', 'June', 'July', 11 | 'August', 'September', 'October', 12 | 'November', 'December' 13 | ] 14 | 15 | class DiscordMessages extends HTMLElement { 16 | connectedCallback () { 17 | let before = -1 18 | this.querySelectorAll('discord-message').forEach(msg => { 19 | const time = parseInt(msg.querySelector('message-date').dataset.timestamp) 20 | if (before > 0) { 21 | if (Math.floor((time - before) / 1000 / 60 / 60 / 24) > 0) { 22 | if (!msg.classList.contains('group-start')) msg.classList.add('group-start') 23 | const date = new Date(time) 24 | this.insertBefore( 25 | createElement('div', { class: 'divider' }, `${date.getDate().toString().padStart(2, '0')} ${months[date.getMonth()]} ${date.getFullYear()}`), 26 | msg 27 | ) 28 | } 29 | } 30 | before = time 31 | }) 32 | } 33 | } 34 | 35 | lateDefine('discord-messages', DiscordMessages) 36 | -------------------------------------------------------------------------------- /src/elements/MessageImage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { showLargerImage } from '../utils' 7 | 8 | class MessageImage extends HTMLImageElement { 9 | constructor () { 10 | super() 11 | this.onClick = () => showLargerImage(this) 12 | this.onError = this.onError.bind(this) 13 | } 14 | 15 | connectedCallback () { 16 | this.addEventListener('error', this.onError) 17 | if (this.dataset.clickable !== void 0) { 18 | this.addEventListener('click', this.onClick) 19 | } 20 | } 21 | 22 | onError () { 23 | this.removeEventListener('error', this.onError) 24 | this.removeEventListener('click', this.onClick) 25 | this.removeAttribute('data-clickable') 26 | this.src = 'https://discord.com/assets/e0c782560fd96acd7f01fda1f8c6ff24.svg' 27 | } 28 | } 29 | 30 | class MessageGifv extends HTMLVideoElement { 31 | constructor () { 32 | super() 33 | this.onClick = () => showLargerImage(this) 34 | } 35 | 36 | connectedCallback () { 37 | this.addEventListener('click', this.onClick) 38 | } 39 | } 40 | 41 | customElements.define('message-image', MessageImage, { extends: 'img' }) 42 | customElements.define('message-gifv', MessageGifv, { extends: 'video' }) 43 | -------------------------------------------------------------------------------- /style/_tooltip.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .tooltip { 7 | position: absolute; 8 | user-select: none; 9 | pointer-events: none; 10 | z-index: 6969; 11 | background-color: #000; 12 | border-radius: 5px; 13 | font-weight: 500; 14 | font-size: 14px; 15 | line-height: 16px; 16 | padding: 8px 12px; 17 | word-wrap: break-word; 18 | animation-fill-mode: both; 19 | animation-duration: .15s; 20 | animation-timing-function: cubic-bezier(0.54, 0.01, 0.45, 1.6); 21 | 22 | &:before { 23 | content: ''; 24 | position: absolute; 25 | width: 10px; 26 | height: 10px; 27 | background-color: inherit; 28 | } 29 | 30 | &.top:before { 31 | bottom: 0; 32 | left: 50%; 33 | transform: translate(-50%, 4px) rotate(45deg); 34 | } 35 | 36 | &.right:before { 37 | top: 50%; 38 | left: 0; 39 | transform: translate(-4px, -50%) rotate(45deg); 40 | } 41 | 42 | &.entering { 43 | animation-name: tooltip; 44 | } 45 | 46 | &.leaving { 47 | animation-name: tooltip; 48 | animation-direction: reverse; 49 | } 50 | } 51 | 52 | @keyframes tooltip { 53 | from { 54 | opacity: 0; 55 | transform: scale(.9); 56 | } 57 | 58 | to { 59 | opacity: 1; 60 | transform: scale(1); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/utils/contextMenu.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import e from './createElement' 7 | 8 | function contextMenu (element, options) { 9 | if (!element) return 10 | const context = e('div', { class: 'context-menu' }, 11 | options.map(opt => e('div', { 12 | class: 'item', 13 | bindEvents: { 14 | click: () => { 15 | context.remove() 16 | opt.callback() 17 | } 18 | } 19 | }, opt.name)) 20 | ) 21 | 22 | element.addEventListener('contextmenu', event => { 23 | // Event memes 24 | event.preventDefault() 25 | event.stopPropagation() 26 | 27 | // Remove any previous context menu 28 | const el = document.querySelector('.context-menu') 29 | if (el) el.remove() 30 | 31 | // Add element 32 | document.body.appendChild(context) 33 | 34 | // Place element 35 | const contextRect = context.getBoundingClientRect() 36 | context.style.left = `${event.x}px` 37 | context.style.top = `${Math.min(event.y, window.innerHeight - contextRect.height - 15)}px` 38 | 39 | // Window events 40 | setTimeout(() => { 41 | const callback = () => { 42 | context.remove() 43 | window.removeEventListener('click', callback) 44 | } 45 | window.addEventListener('click', callback) 46 | }, 0) 47 | }) 48 | } 49 | 50 | export default contextMenu 51 | -------------------------------------------------------------------------------- /src/utils/createElement.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | function createElement (element, attributes = {}, children = null) { 7 | if (!attributes) attributes = {} 8 | 9 | if (typeof element === 'string') { 10 | const node = document.createElement(element) 11 | for (const attr in attributes) { 12 | if (attr === 'bindEvents') { 13 | for (const event of Object.keys(attributes.bindEvents)) { 14 | node.addEventListener(event, attributes.bindEvents[event]) 15 | } 16 | } else { 17 | node.setAttribute(attr, attributes[attr]) 18 | } 19 | } 20 | if (Array.isArray(children)) { 21 | for (const child of children) { 22 | if (child instanceof Node) { 23 | // noinspection JSUnfilteredForInLoop 24 | node.appendChild(child) 25 | } else if (typeof child === 'string') { 26 | // noinspection JSUnfilteredForInLoop 27 | node.appendChild(document.createTextNode(child)) 28 | } 29 | } 30 | } else if (children instanceof Node) { 31 | node.appendChild(children) 32 | } else if (typeof children === 'string') { 33 | node.appendChild(document.createTextNode(children)) 34 | } 35 | return node 36 | } 37 | throw new Error('Failed to create component. You may only pass a string or a Component.') 38 | } 39 | 40 | export default createElement 41 | -------------------------------------------------------------------------------- /views/header.ejs: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 | 8 | 10 | 11 | <%= data.channel_name %> 12 | Messages: <%= data.messages.length %> 13 | 14 | 15 |
16 | -------------------------------------------------------------------------------- /src/utils/createTooltip.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import e from './createElement' 7 | 8 | function createTooltip (element, text, placement = 'top') { 9 | const tooltip = e('div', { class: `tooltip ${placement}` }, text) 10 | let timeout = null 11 | 12 | element.addEventListener('mouseenter', () => { 13 | timeout = setTimeout(() => { 14 | timeout = null 15 | document.body.appendChild(tooltip) 16 | const tooltipRect = tooltip.getBoundingClientRect() 17 | const elementRect = element.getBoundingClientRect() 18 | if (placement === 'top') { 19 | tooltip.style.left = `${elementRect.x + (elementRect.width / 2) - (tooltipRect.width / 2)}px` 20 | tooltip.style.top = `${elementRect.y - tooltipRect.height - 5}px` 21 | } else if (placement === 'right') { 22 | tooltip.style.left = `${elementRect.x + elementRect.width + 10}px` 23 | tooltip.style.top = `${elementRect.y + (elementRect.height / 2) - (tooltipRect.height / 2)}px` 24 | } 25 | 26 | tooltip.classList.add('entering') 27 | setTimeout(() => tooltip.classList.remove('entering'), 150) 28 | }, 1e3) 29 | }) 30 | 31 | element.addEventListener('mouseleave', () => { 32 | tooltip.classList.add('leaving') 33 | setTimeout(() => { 34 | tooltip.remove() 35 | tooltip.classList.remove('leaving') 36 | }, 150) 37 | if (timeout) { 38 | clearTimeout(timeout) 39 | } 40 | }) 41 | } 42 | 43 | export default createTooltip 44 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import './elements/ThemeSwitch' 7 | import './elements/DiscordMessages' 8 | import './elements/DiscordMessage' 9 | import './elements/MessageHeader' 10 | import './elements/MessageAvatar' 11 | import './elements/MessageDate' 12 | import './elements/MessageMarkup' 13 | import './elements/MessageEmoji' 14 | import './elements/MessageMention' 15 | import './elements/MessageSpoiler' 16 | import './elements/MessageCodeblock' 17 | import './elements/MessageImage' 18 | import './elements/MessageVideo' 19 | import './elements/MessageAttachment' 20 | import './elements/DiscordInvite' 21 | import { copy, contextMenu } from './utils' 22 | 23 | // Context menus 24 | window.addEventListener('contextmenu', e => { 25 | e.preventDefault() 26 | // Remove any previous context menu 27 | const el = document.querySelector('.context-menu') 28 | if (el) el.remove() 29 | }) 30 | 31 | document.querySelectorAll('img[data-clickable], message-markup a, .embed a').forEach(link => { 32 | contextMenu(link, [ 33 | { 34 | name: 'Copy Link', 35 | callback: () => copy(link.src || link.href) 36 | }, 37 | { 38 | name: 'Open Link', 39 | callback: () => open(link.src || link.href) 40 | } 41 | ]) 42 | }) 43 | 44 | // User Agents 45 | function onLoad () { 46 | if (navigator.userAgent.toLowerCase().indexOf('firefox') !== -1) { 47 | document.body.classList.add('firefox') 48 | } 49 | 50 | if (navigator.userAgent.toLowerCase().indexOf('chrome') !== -1) { 51 | document.body.classList.add('chrome') 52 | } 53 | } 54 | 55 | document.readyState === 'loading' 56 | ? document.addEventListener('DOMContentLoaded', onLoad) 57 | : onLoad() 58 | -------------------------------------------------------------------------------- /src/utils/showLargerImage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import e from './createElement' 7 | import fit from '../commons/fit' 8 | 9 | function showLargerImage (element) { 10 | const url = new URL(element.src) 11 | const size = zoomFit(parseInt(element.dataset.width), parseInt(element.dataset.height)) 12 | url.searchParams.set('width', size.width) 13 | url.searchParams.set('height', size.height) 14 | document.body.appendChild( 15 | e('div', { 16 | class: 'modal-container entering', 17 | bindEvents: { 18 | click: () => { 19 | const el = document.querySelector('.modal-container') 20 | el.classList.add('leaving') 21 | setTimeout(() => el.remove(), 150) 22 | } 23 | } 24 | }, e('div', { 25 | class: 'modal-inner image', 26 | bindEvents: { click: e => e.stopPropagation() } 27 | }, [ 28 | element.classList.contains('video') 29 | ? e('video', { 30 | src: element.src, 31 | autoplay: true, 32 | muted: true, 33 | loop: true, 34 | style: `width: ${size.width}px; height: ${size.height}px;` 35 | }) 36 | : e('img', { 37 | src: url.href, 38 | alt: 'Preview', 39 | style: `width: ${size.width}px; height: ${size.height}px;` 40 | }), 41 | e('a', { 42 | href: element.dataset.url, 43 | target: '_blank' 44 | }, 'Open original') 45 | ])) 46 | ) 47 | setTimeout(() => document.querySelector('.modal-container').classList.remove('entering'), 350) 48 | } 49 | 50 | function zoomFit (width, height) { 51 | const maxWidth = Math.min(Math.round(0.65 * window.innerWidth), 2e3) 52 | const maxHeight = Math.min(Math.round(0.65 * window.innerHeight), 2e3) 53 | return fit(width, height, maxWidth, maxHeight) 54 | } 55 | 56 | export default showLargerImage 57 | -------------------------------------------------------------------------------- /src/utils/createUserPopout.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import e from './createElement' 7 | import MessageDate from '../elements/MessageDate' 8 | 9 | function createUserPopout (element, user) { 10 | const popout = renderPopout(user) 11 | popout.addEventListener('click', e => e.stopPropagation()) 12 | element.addEventListener('click', () => { 13 | // Add element 14 | document.body.appendChild(popout) 15 | 16 | // Place element 17 | const elementRect = element.getBoundingClientRect() 18 | const popoutRect = popout.getBoundingClientRect() 19 | popout.style.left = `${elementRect.x + elementRect.width + 10}px` 20 | popout.style.top = `${Math.min(elementRect.y, window.innerHeight - popoutRect.height - 15)}px` 21 | popout.classList.add('mounted') 22 | 23 | // Window events 24 | setTimeout(() => { 25 | const callback = () => { 26 | popout.remove() 27 | popout.classList.remove('mounted') 28 | window.removeEventListener('click', callback) 29 | } 30 | window.addEventListener('click', callback) 31 | }, 0) 32 | }) 33 | } 34 | 35 | function renderPopout (user) { 36 | // eslint-disable-next-line no-undef 37 | const date = new Date(Number((BigInt(user.id) >> BigInt(22)) + BigInt(1420070400000))) 38 | return e('div', { class: 'user-popout' }, [ 39 | e('div', { class: 'header' }, [ 40 | e('img', { 41 | src: user.avatar, 42 | alt: 'avatar' 43 | }), 44 | e('div', { class: 'details' }, [ 45 | e('div', { class: 'username' }, user.username), 46 | e('div', { class: 'discriminator' }, [ '#', user.discriminator ]), 47 | e('div', { class: 'badge' }, user.badge) 48 | ]) 49 | ]), 50 | e('div', { class: 'body' }, [ 51 | e('div', { class: 'field' }, [ 52 | e('div', { class: 'title' }, 'Account Creation Date'), 53 | e('div', { class: 'value' }, MessageDate.formatDate(date)) 54 | ]), 55 | e('div', { class: 'field' }, [ 56 | e('div', { class: 'title' }, 'Messages Count'), 57 | e('div', { class: 'value' }, document.querySelectorAll(`discord-message[data-author="${user.id}"]`).length.toString()) 58 | ]) 59 | ]) 60 | ]) 61 | } 62 | 63 | export default createUserPopout 64 | -------------------------------------------------------------------------------- /style/_popout.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .user-popout { 7 | position: absolute; 8 | z-index: 6969; 9 | background-color: var(--background); 10 | box-shadow: 0 2px 10px 0 rgba(0, 0, 0, .2), 0 0 0 1px rgba(32, 34, 37, .6); 11 | width: 250px; 12 | border-radius: 5px; 13 | overflow: hidden; 14 | animation-fill-mode: forwards; 15 | animation-duration: .2s; 16 | animation-timing-function: ease-out; 17 | 18 | .header { 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | background-color: var(--background-very-dark); 24 | padding: 20px 10px; 25 | 26 | img { 27 | user-select: none; 28 | border-radius: 50%; 29 | margin-bottom: 10px; 30 | position: relative; 31 | width: 80px; 32 | height: 80px; 33 | } 34 | 35 | .details { 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | flex-wrap: wrap; 40 | } 41 | 42 | .username { 43 | font-weight: 600; 44 | color: var(--color); 45 | text-overflow: ellipsis; 46 | overflow: hidden; 47 | } 48 | 49 | .discriminator { 50 | font-weight: 500; 51 | color: var(--color); 52 | opacity: .6; 53 | } 54 | 55 | .badge { 56 | margin-left: 1ch; 57 | } 58 | } 59 | 60 | .body { 61 | padding: 10px; 62 | } 63 | 64 | .field { 65 | .title { 66 | font-weight: 700; 67 | color: var(--color-gray); 68 | text-transform: uppercase; 69 | font-size: 12px; 70 | margin-bottom: 5px; 71 | } 72 | 73 | .value { 74 | font-size: 12px; 75 | line-height: 14px; 76 | } 77 | 78 | + .field { 79 | margin-top: 15px; 80 | } 81 | } 82 | 83 | &.mounted { 84 | animation-name: popout; 85 | } 86 | } 87 | 88 | .theme-light .user-popout { 89 | background-color: #f6f6f7; 90 | box-shadow: 0 2px 10px 0 rgba(0, 0, 0, .2), 0 0 0 1px rgba(185, 187, 190, .3); 91 | } 92 | 93 | @keyframes popout { 94 | from { 95 | transform: translateX(10px); 96 | } 97 | 98 | to { 99 | transform: translateX(0); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/elements/MessageDate.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, createTooltip } from '../utils' 7 | 8 | class MessageDate extends HTMLElement { 9 | connectedCallback () { 10 | const date = new Date(parseInt(this.dataset.timestamp)) 11 | if (this.dataset.type !== 'full') { 12 | createTooltip(this, new Intl.DateTimeFormat('en-GB', { 13 | year: 'numeric', 14 | weekday: 'long', 15 | month: 'long', 16 | day: '2-digit', 17 | hour: '2-digit', 18 | minute: '2-digit' 19 | }).format(date), this.dataset.type === 'time' ? 'right' : 'top') 20 | } 21 | 22 | if (this.dataset.type === 'date') { 23 | this.innerText = MessageDate.formatDate(date) 24 | } else if (this.dataset.type === 'time') { 25 | this.innerText = `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}` 26 | } else if (this.dataset.type === 'full') { 27 | this.innerText = new Intl.DateTimeFormat('en-GB', { 28 | year: 'numeric', 29 | month: 'long', 30 | day: '2-digit', 31 | hour: '2-digit', 32 | minute: '2-digit', 33 | second: '2-digit' 34 | }).format(date) 35 | } else { 36 | console.warn(`MessageDate: Cannot parse date: unknown format ${this.dataset.type}`) 37 | } 38 | } 39 | 40 | static formatDate (date) { 41 | const days = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday' ] 42 | const today = new Date() 43 | const daysBetween = (today.getUTCFullYear() - date.getUTCFullYear()) * 365 + 44 | (today.getUTCMonth() - date.getUTCMonth()) * 30 + 45 | (today.getUTCDate() - date.getUTCDate()) 46 | 47 | const hours = date.getHours().toString().padStart(2, '0') 48 | const minutes = date.getMinutes().toString().padStart(2, '0') 49 | if (daysBetween === 0) { 50 | return `Today at ${hours}:${minutes}` 51 | } else if (daysBetween === 1) { 52 | return `Yesterday at ${hours}:${minutes}` 53 | } else if (daysBetween < 7) { 54 | return `Last ${days[date.getDay()]} at ${hours}:${minutes}` 55 | } else { 56 | return `${date.getDate().toString().padStart(2, '0')}/` + 57 | `${date.getMonth().toString().padStart(2, '0')}/` + 58 | `${date.getFullYear().toString()}` 59 | } 60 | } 61 | } 62 | 63 | lateDefine('message-date', MessageDate) 64 | export default MessageDate 65 | -------------------------------------------------------------------------------- /style/_message.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | @import 'include'; 7 | 8 | // noinspection CssInvalidHtmlTagReference 9 | discord-message { 10 | &:not(.system) { 11 | // noinspection CssInvalidHtmlTagReference 12 | .contents, message-markup { 13 | width: 100%; 14 | } 15 | } 16 | 17 | .spoiler { 18 | border-radius: 3px; 19 | transition: all .1s ease; 20 | background-color: var(--spoiler-revealed); 21 | 22 | &:not(.revealed) { 23 | cursor: pointer; 24 | background-color: var(--spoiler); 25 | 26 | span { 27 | opacity: 0; 28 | pointer-events: none; 29 | } 30 | } 31 | } 32 | 33 | .emoji { 34 | width: 1.375em; 35 | height: 1.375em; 36 | object-fit: contain; 37 | margin-top: 0; 38 | margin-bottom: -5px; 39 | 40 | &.jumbo { 41 | width: 3rem; 42 | height: 3rem; 43 | min-height: 3rem; 44 | } 45 | } 46 | 47 | blockquote { 48 | display: flex; 49 | margin: 0; 50 | 51 | .side { 52 | width: 4px; 53 | border-radius: 4px; 54 | background-color: #4f545c; 55 | } 56 | 57 | .content { 58 | padding: 0 8px 0 12px; 59 | } 60 | } 61 | 62 | // noinspection CssInvalidHtmlTagReference 63 | &:not(.system) message-mention { 64 | color: #7289da; 65 | background-color: rgba(114, 137, 218, .1); 66 | transition: background-color 50ms ease-out, color 50ms ease-out; 67 | cursor: pointer; 68 | padding: 0 2px; 69 | font-weight: 500; 70 | 71 | &:hover { 72 | color: #fff; 73 | background-color: rgba(114, 137, 218, .7); 74 | } 75 | 76 | &[data-type='role'] { 77 | color: var(--role-color); 78 | background-color: var(--role-bg); 79 | 80 | &:hover { 81 | color: var(--role-color); 82 | background-color: var(--role-bg-h); 83 | } 84 | } 85 | } 86 | 87 | img[data-clickable], video[is='message-gifv'] { 88 | cursor: pointer; 89 | } 90 | 91 | code:not(.hljs) { 92 | width: auto; 93 | height: auto; 94 | padding: .2em; 95 | margin: -.2em 0; 96 | border-radius: 3px; 97 | font-size: 85%; 98 | font-family: Consolas,Andale Mono WT,Andale Mono,Lucida Console,Lucida Sans Typewriter,DejaVu Sans Mono,Bitstream Vera Sans Mono,Liberation Mono,Nimbus Mono L,Monaco,Courier New,Courier,monospace; 99 | text-indent: 0; 100 | border: none; 101 | white-space: pre-wrap; 102 | background: var(--background-dark); 103 | line-height: 1.125rem; 104 | } 105 | 106 | .codeblock { 107 | @extend %codeblock; 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /style/_invite.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | // noinspection CssInvalidHtmlTagReference 7 | discord-invite { 8 | background-color: var(--background-dark); 9 | border-radius: 4px; 10 | padding: 16px; 11 | width: 432px; 12 | user-select: none; 13 | display: block; 14 | 15 | .header { 16 | font-weight: 700; 17 | margin-bottom: 12px; 18 | white-space: nowrap; 19 | overflow: hidden; 20 | text-overflow: ellipsis; 21 | color: var(--color-light-gray); 22 | font-size: 12px; 23 | line-height: 16px; 24 | text-transform: uppercase; 25 | height: 16px; 26 | } 27 | 28 | .resolving { 29 | height: 48px; // TODO: Animation once Discord fixed it on their end 30 | } 31 | 32 | .guild { 33 | display: flex; 34 | align-items: center; 35 | 36 | .name, .invalid { 37 | font-size: 16px; 38 | line-height: 20px; 39 | font-weight: 600; 40 | color: var(--color); 41 | } 42 | 43 | .name { 44 | margin-bottom: 2px; 45 | } 46 | 47 | .invalid { 48 | color: #f04747; 49 | display: flex; 50 | align-items: center; 51 | } 52 | } 53 | 54 | .details { 55 | flex: 1; 56 | display: flex; 57 | flex-direction: column; 58 | justify-content: center; 59 | align-items: stretch; 60 | } 61 | 62 | img { 63 | border-radius: 12px; 64 | background-position: 50%; 65 | width: 48px; 66 | height: 48px; 67 | object-fit: cover; 68 | margin-right: 16px; 69 | } 70 | 71 | .counts { 72 | display: flex; 73 | align-items: center; 74 | color: var(--color-light-gray); 75 | font-size: 14px; 76 | line-height: 16px; 77 | } 78 | 79 | .online, .offline { 80 | margin-right: 4px; 81 | width: 8px; 82 | height: 8px; 83 | border-radius: 50%; 84 | background-color: #747f8d; 85 | } 86 | 87 | .online { 88 | background-color: #43b581; 89 | } 90 | 91 | .count { 92 | margin-right: 8px; 93 | 94 | b { 95 | margin-right: 3px; 96 | } 97 | 98 | &:last-child { 99 | margin-right: 0; 100 | } 101 | } 102 | 103 | .button { 104 | flex-shrink: 0; 105 | height: 40px; 106 | padding: 2px 20px; 107 | color: #fff; 108 | background-color: #43b581; 109 | cursor: pointer; 110 | margin-left: 10px; 111 | transition: background-color .17s ease; 112 | display: flex; 113 | border-radius: 3px; 114 | font-size: 14px; 115 | font-weight: 500; 116 | align-items: center; 117 | justify-content: center; 118 | 119 | &:hover { 120 | text-decoration: none; 121 | background-color: #3ca374; 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /views/index.ejs: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | #<%= data.channel_name %> • Discord Chat Archive 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 32 | 33 | 34 | <%- include('header.ejs', { data: data }) %> 35 |
36 |
37 |
38 |
39 |

Welcome to #<%= data.channel_name %>!

40 |
This is the start of the #<%= data.channel_name %> channel.
41 |
42 | 43 | 44 | <%- include('messages.ejs', { data: data, markdown: markdown }) %> 45 | 46 |
47 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /style/_main.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .theme-dark { 7 | --background: #36393f; 8 | --background-dark: #2f3136; 9 | --background-very-dark: #202225; 10 | --background-extreme-dark: #18191c; 11 | --background-hover: rgba(4, 4, 5, 0.07); 12 | --background-context-hover: rgba(79, 84, 92, .16); 13 | --background-accent: #4f545c; 14 | --color: #fff; 15 | --color-light-gray: #b9bbbe; 16 | --color-gray: #72767d; 17 | --color-lighter-gray: #8e9297; 18 | --elevation: 0 1px 0 rgba(4, 4, 5, 0.2), 0 1.5px 0 rgba(6, 6, 7, 0.05), 0 2px 0 rgba(4, 4, 5, 0.05); 19 | --elevation-high: 0 8px 16px rgba(0, 0, 0, .24); 20 | --spacer: hsla(0, 0%, 100%, 0.06); 21 | --spacer-light: hsla(0, 0%, 100%, 0.02); 22 | --spoiler: #202225; 23 | --spoiler-revealed: hsla(0, 0%, 100%, .1); 24 | --theme: url(https://discord.com/assets/522c8314225487737f7dd4ead8d4a731.svg); 25 | --link: #00b0f4; 26 | } 27 | 28 | .theme-light { 29 | --background: #fff; 30 | --background-dark: #f2f3f5; 31 | --background-very-dark: #e3e5e8; 32 | --background-extreme-dark: #fff; 33 | --background-hover: rgba(6, 6, 7, 0.02); 34 | --background-context-hover: rgba(116, 127, 141, .08); 35 | --background-accent: #747f8d; 36 | --color: #2e3338; 37 | --color-light-gray: #4f5660; 38 | --color-gray: #747f8d; 39 | --color-lighter-gray: #6a7480; 40 | --elevation: 0 1px 0 rgba(6, 6, 7, 0.1), 0 1.5px 0 rgba(6, 6, 7, 0.025), 0 2px 0 rgba(6, 6, 7, 0.025); 41 | --elevation-high: 0 8px 16px rgba(0, 0, 0, .16); 42 | --spacer: rgba(6, 6, 7, 0.08); 43 | --spacer-light: #eceeef; 44 | --spoiler: #b9bbbe; 45 | --spoiler-revealed: rgba(0, 0, 0, .1); 46 | --theme: url(https://discord.com/assets/6c8c8654d649bee316b88e2342b6c8fa.svg); 47 | --link: #0067e0; 48 | } 49 | 50 | * { 51 | box-sizing: border-box; 52 | } 53 | 54 | html, 55 | body { 56 | width: 100%; 57 | height: 100%; 58 | margin: 0; 59 | padding: 0; 60 | color: var(--color); 61 | background-color: var(--background); 62 | font-family: Whitney, sans-serif; 63 | overflow: hidden; 64 | } 65 | 66 | a { 67 | color: var(--link); 68 | text-decoration: none; 69 | 70 | &:hover { 71 | text-decoration: underline; 72 | } 73 | } 74 | 75 | main { 76 | display: flex; 77 | overflow-y: auto; 78 | height: calc(100% - 126px); 79 | flex-direction: column; 80 | } 81 | 82 | // Scrollbars 83 | // noinspection CssUnknownProperty 84 | html { 85 | scrollbar-color: var(--background-very-dark) var(--background); 86 | scrollbar-width: thin; 87 | } 88 | 89 | ::-webkit-scrollbar { 90 | background-color: transparent; 91 | width: 8px; 92 | } 93 | 94 | ::-webkit-scrollbar-thumb { 95 | background-color: var(--background-very-dark); 96 | border: 0 var(--background) solid; 97 | border-left-width: 1px; 98 | border-right-width: 1px; 99 | } 100 | -------------------------------------------------------------------------------- /src/elements/DiscordInvite.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { createElement as e } from '../utils' 7 | 8 | class DiscordInvite extends HTMLElement { 9 | async connectedCallback () { 10 | this.render('RESOLVING') 11 | const invite = await this.fetchInvite() 12 | if (invite) return this.render('RESOLVED', invite) 13 | this.render('INVALID') 14 | } 15 | 16 | render (state, invite) { 17 | this.innerHTML = '' 18 | let elements = [] 19 | switch (state) { 20 | case 'RESOLVING': 21 | elements = [ 22 | e('div', { class: 'header' }, 'Resolving Invite'), 23 | e('div', { class: 'resolving' }) // Discord's animation is borken sadpepe 24 | ] 25 | break 26 | case 'INVALID': 27 | elements = [ 28 | e('div', { class: 'header' }, 'You received an invite, but...'), 29 | e('div', { class: 'guild' }, [ 30 | e('img', { 31 | src: 'https://discord.com/assets/e0c782560fd96acd7f01fda1f8c6ff24.svg', 32 | alt: 'poop' 33 | }), 34 | e('div', { class: 'invalid' }, 'Invalid Invite') 35 | ]) 36 | ] 37 | break 38 | case 'RESOLVED': 39 | elements = [ 40 | e('div', { class: 'header' }, 'You\'ve been invited to join a server'), 41 | e('div', { class: 'guild' }, [ 42 | e('img', { 43 | src: `https://cdn.discordapp.com/icons/${invite.guild.id}/${invite.guild.icon}.${invite.guild.icon.startsWith('a_') ? 'gif' : 'png'}`, 44 | alt: 'Guild Icon' 45 | }), 46 | e('div', { class: 'details' }, [ 47 | e('div', { class: 'name' }, invite.guild.name), 48 | e('div', { class: 'counts' }, [ 49 | e('div', { class: 'online' }), 50 | e('span', { class: 'count' }, [ 51 | e('b', null, invite.approximate_presence_count.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,')), 52 | 'Online' 53 | ]), 54 | e('div', { class: 'offline' }), 55 | e('span', { class: 'count' }, [ 56 | e('b', null, invite.approximate_member_count.toString().replace(/(\d)(?=(\d{3})+(?!\d))/g, '$1,')), 57 | 'Members' 58 | ]) 59 | ]) 60 | ]), 61 | e('a', { 62 | href: `https://discord.gg/${invite.code}`, 63 | target: '_blank', 64 | class: 'button' 65 | }, 'Join') 66 | ]) 67 | ] 68 | break 69 | } 70 | elements.forEach(e => this.appendChild(e)) 71 | } 72 | 73 | async fetchInvite () { 74 | const res = await fetch(`https://discord.com/api/v6/invite/${this.dataset.code}?with_counts=true`) 75 | if (res.ok) return res.json() 76 | } 77 | } 78 | 79 | customElements.define('discord-invite', DiscordInvite) 80 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | const { existsSync, createReadStream } = require('fs') 7 | const { resolve } = require('path') 8 | const mime = require('mime-types') 9 | const https = require('https') 10 | const ejs = require('ejs') 11 | const { minify } = require('html-minifier') 12 | 13 | const markdown = require('./src/markdown') 14 | const Formatter = require('./src/formatter') 15 | 16 | // Stuff 17 | const assets = require('./src/assets') 18 | const config = require('./config') 19 | const testData = require('./example') 20 | 21 | require('http') 22 | .createServer((req, res) => { 23 | if (![ 'GET', 'POST' ].includes(req.method)) { 24 | res.writeHead(405) 25 | return res.end() 26 | } 27 | 28 | // Assets 29 | if (req.url.startsWith('/dist/')) { 30 | const target = req.url.split('/')[2] 31 | const file = resolve(__dirname, 'dist', target) 32 | if (existsSync(file) && target && target !== '.' && target !== '..') { 33 | res.setHeader('content-type', mime.lookup(file) || 'application/octet-stream') 34 | return createReadStream(file).pipe(res) 35 | } 36 | } 37 | 38 | // Attachments 39 | if (req.url.startsWith('/attachments/')) { 40 | const headers = {} 41 | if (req.headers.range) { 42 | headers.range = req.headers.range 43 | } 44 | 45 | https.get({ 46 | host: 'cdn.discordapp.com', 47 | path: req.url, 48 | port: 443, 49 | headers 50 | }, resp => { 51 | delete resp.headers['content-disposition'] 52 | res.writeHead(resp.statusCode, { 53 | ...resp.headers, 54 | 'Access-Control-Allow-Origin': '*' 55 | }) 56 | resp.pipe(res) 57 | }) 58 | return 59 | } 60 | 61 | // Serve 62 | const handler = async (data) => { 63 | const fm = new Formatter(data) 64 | const formatted = await fm.format() 65 | if (!formatted) { 66 | res.writeHead(400) 67 | return res.end() 68 | } 69 | const hostname = config.hostname ? config.hostname : `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}` 70 | ejs.renderFile('./views/index.ejs', { 71 | data: formatted, 72 | assets, 73 | markdown, 74 | hostname 75 | }, null, (err, str) => { 76 | if (err) { 77 | res.writeHead(500) 78 | res.end('Internal Server Error') 79 | console.error(err) 80 | } 81 | res.end(minify(str, { 82 | collapseWhitespace: true, 83 | removeComments: true 84 | })) 85 | }) 86 | } 87 | 88 | res.setHeader('content-type', 'text/html') 89 | if (req.method === 'POST') { 90 | let data = '' 91 | req.on('data', chunk => (data += chunk)) 92 | req.on('end', () => handler(JSON.parse(data))) 93 | } else { 94 | return handler(testData) 95 | } 96 | }).listen(config.port) 97 | -------------------------------------------------------------------------------- /src/elements/MessageAttachment.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | import { lateDefine, createElement as e } from '../utils' 7 | 8 | class MessageAttachment extends HTMLElement { 9 | constructor () { 10 | super() 11 | this.onClick = this.onClick.bind(this) 12 | } 13 | 14 | connectedCallback () { 15 | const el = this.querySelector('.preview') 16 | if (el) el.addEventListener('click', this.onClick) 17 | } 18 | 19 | async onClick () { 20 | this.renderModal('LOADING') 21 | const file = this.querySelector('a').href 22 | const res = await fetch(file.replace('https://cdn.discordapp.com', window.GLOBAL_ENV.HOSTNAME)) 23 | if (!res.ok) { 24 | return this.renderModal('ERRORED') 25 | } 26 | this.renderModal('FETCHED', await res.text()) 27 | } 28 | 29 | renderModal (state, contents) { 30 | const filename = this.querySelector('a').textContent 31 | let container = document.querySelector('.modal-container') 32 | if (!container) { 33 | const close = () => { 34 | const el = document.querySelector('.modal-container') 35 | el.classList.add('leaving') 36 | setTimeout(() => el.remove(), 150) 37 | } 38 | container = e('div', { 39 | class: 'modal-container entering', 40 | bindEvents: { click: close } 41 | }, e('div', { 42 | class: 'modal-inner', 43 | bindEvents: { click: e => e.stopPropagation() } 44 | }, e('div', { class: 'modal' }, [ 45 | e('div', { class: 'modal-header' }, 'Attachment'), 46 | e('div', { class: 'modal-body' }, [ 47 | e('div', null, [ e('b', null, 'Filename:'), ` ${filename}` ]), 48 | e('div', null, [ e('b', null, 'File size:'), ` ${this.querySelector('span').textContent}` ]), 49 | e('div', { class: 'attachment-details' }) 50 | ]), 51 | e('div', { class: 'modal-footer' }, [ 52 | e('button', { bindEvents: { click: close } }, 'Got it'), 53 | e('a', { 54 | href: this.querySelector('a').href, 55 | target: '_blank' 56 | }, 'Download') 57 | ]) 58 | ]))) 59 | document.body.appendChild(container) 60 | } 61 | 62 | let el = null 63 | switch (state) { 64 | case 'LOADING': 65 | el = e('div', { class: 'spinner' }) 66 | break 67 | case 'FETCHED': 68 | el = e('div', { class: 'contents' }, [ 69 | e('div', { class: 'lang' }, filename.split('.').pop()), 70 | e('div', { class: 'shitcode' }, [ 71 | e('div', { class: 'lines' }), 72 | e('code', null, contents) 73 | ]), 74 | e('div', { class: 'copy' }, 'Copy') 75 | ]) 76 | break 77 | case 'ERRORED': 78 | el = e('div', { class: 'error' }, 'Failed to load attachment contents.') 79 | break 80 | } 81 | const inner = container.querySelector('.attachment-details') 82 | inner.innerHTML = '' 83 | inner.appendChild(el) 84 | } 85 | } 86 | 87 | lateDefine('message-attachment', MessageAttachment) 88 | -------------------------------------------------------------------------------- /style/_attachment.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .decorated-video { 7 | width: 380px; 8 | max-height: 540px; 9 | position: relative; 10 | overflow: hidden; 11 | 12 | .metadata { 13 | z-index: 666; 14 | position: absolute; 15 | top: 0; 16 | right: 0; 17 | left: 0; 18 | display: flex; 19 | background-image: linear-gradient(0deg, transparent, rgba(0, 0, 0, .9)); 20 | padding: 12px; 21 | transform: translateY(-100%); 22 | transition: transform .15s; 23 | color: #fff; 24 | 25 | .details { 26 | flex: 1; 27 | display: flex; 28 | flex-direction: column; 29 | 30 | .name, .size { 31 | font-weight: 500; 32 | white-space: nowrap; 33 | text-overflow: ellipsis; 34 | overflow: hidden; 35 | } 36 | 37 | .name { 38 | font-size: 16px; 39 | line-height: 20px; 40 | } 41 | 42 | .size { 43 | font-size: 12px; 44 | line-height: 16px; 45 | opacity: .7; 46 | } 47 | } 48 | 49 | .download { 50 | opacity: .6; 51 | color: #fff; 52 | } 53 | } 54 | 55 | video { 56 | width: 100%; 57 | } 58 | 59 | &:hover .metadata { 60 | transform: translateY(0); 61 | } 62 | } 63 | 64 | // noinspection CssInvalidHtmlTagReference 65 | message-attachment { 66 | display: block; 67 | border: 1px solid; 68 | max-width: 520px; 69 | width: 100%; 70 | padding: 10px; 71 | border-radius: 3px; 72 | 73 | &.audio { 74 | max-width: 400px; 75 | } 76 | 77 | .data { 78 | display: flex; 79 | align-items: center; 80 | 81 | .icon { 82 | width: 30px; 83 | height: 40px; 84 | margin-right: 8px; 85 | margin-top: 0; 86 | flex-shrink: 0; 87 | } 88 | 89 | .details { 90 | flex: 1; 91 | display: flex; 92 | flex-direction: column; 93 | 94 | a { 95 | opacity: .85; 96 | text-overflow: ellipsis; 97 | overflow: hidden; 98 | line-height: 16px; 99 | } 100 | 101 | span { 102 | font-weight: 300; 103 | color: #72767d; 104 | margin-right: 8px; 105 | line-height: 16px; 106 | font-size: 12px; 107 | } 108 | } 109 | 110 | .preview, .download { 111 | display: flex; 112 | align-items: center; 113 | justify-content: center; 114 | color: #4f545c; 115 | width: 24px; 116 | height: 24px; 117 | cursor: pointer; 118 | } 119 | } 120 | 121 | audio { 122 | width: 100%; 123 | margin-top: 10px; 124 | } 125 | } 126 | 127 | // noinspection CssInvalidHtmlTagReference 128 | .theme-dark message-attachment { 129 | border-color: rgba(47, 49, 54, .6); 130 | background-color: rgba(47, 49, 54, .3); 131 | } 132 | 133 | // noinspection CssInvalidHtmlTagReference 134 | .theme-light message-attachment { 135 | border-color: #f6f6f7; 136 | } 137 | 138 | audio { 139 | height: 32px; 140 | width: 380px; 141 | border-radius: 3px; 142 | 143 | // noinspection CssInvalidPseudoSelector 144 | &::-webkit-media-controls-enclosure { 145 | border-radius: 3px; 146 | } 147 | } 148 | 149 | .chrome.theme-dark audio { 150 | filter: invert(95%); 151 | } 152 | 153 | .firefox.theme-light audio { 154 | filter: invert(95%); 155 | } 156 | -------------------------------------------------------------------------------- /style/_modal.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | @import 'include'; 7 | 8 | .modal-container { 9 | position: absolute; 10 | top: 0; 11 | bottom: 0; 12 | left: 0; 13 | right: 0; 14 | background-color: rgba(0, 0, 0, .85); 15 | z-index: 1000; 16 | transform: translateZ(0px); 17 | display: flex; 18 | align-items: center; 19 | justify-content: center; 20 | 21 | &.entering { 22 | animation: modal .15s; 23 | } 24 | 25 | &.leaving { 26 | animation: modal-leaving .15s; 27 | animation-fill-mode: forwards; 28 | } 29 | } 30 | 31 | .modal-inner { 32 | display: flex; 33 | flex-direction: column; 34 | user-select: none; 35 | 36 | &.image a { 37 | font-size: 14px; 38 | font-weight: 500; 39 | color: #fff; 40 | transition: opacity .15s ease; 41 | opacity: .5; 42 | line-height: 30px; 43 | 44 | &:hover { 45 | opacity: 1; 46 | text-decoration: underline 47 | } 48 | } 49 | } 50 | 51 | .modal { 52 | background-color: var(--background); 53 | width: 540px; 54 | max-height: 660px; 55 | min-height: 200px; 56 | border-radius: 5px; 57 | overflow: hidden; 58 | display: flex; 59 | flex-direction: column; 60 | 61 | &-header { 62 | padding: 20px; 63 | font-weight: 600; 64 | text-transform: uppercase; 65 | font-size: 16px; 66 | line-height: 20px; 67 | letter-spacing: .3px; 68 | } 69 | 70 | &-body { 71 | padding: 0 20px 20px; 72 | overflow-y: scroll; 73 | } 74 | 75 | &-footer { 76 | display: flex; 77 | justify-content: flex-end; 78 | background-color: var(--background-dark); 79 | padding: 20px; 80 | 81 | button, a { 82 | height: 38px; 83 | min-width: 96px; 84 | min-height: 38px; 85 | background: none; 86 | border: none; 87 | border-radius: 3px; 88 | font-size: 14px; 89 | font-weight: 500; 90 | line-height: 16px; 91 | padding: 2px 16px; 92 | user-select: none; 93 | cursor: pointer; 94 | display: flex; 95 | justify-content: center; 96 | align-items: center; 97 | outline: none; 98 | } 99 | 100 | a { 101 | color: #fff; 102 | background-color: #7289da; 103 | text-decoration: none; 104 | } 105 | 106 | button { 107 | color: var(--color); 108 | transition: background-color .17s ease; 109 | 110 | &:hover { 111 | text-decoration: underline; 112 | } 113 | } 114 | } 115 | } 116 | 117 | .theme-dark .modal { 118 | box-shadow: 0 0 0 1px rgba(32, 34, 37, .6), 0 2px 10px 0 rgba(0, 0, 0, .2); 119 | } 120 | 121 | .theme-light .modal { 122 | box-shadow: 0 0 0 1px rgba(185, 187, 190, .3), 0 2px 10px 0 rgba(0, 0, 0, .1); 123 | } 124 | 125 | .attachment-details { 126 | margin-top: 15px; 127 | display: flex; 128 | justify-content: center; 129 | 130 | .contents { 131 | @extend %codeblock; 132 | } 133 | 134 | .error { 135 | font-weight: 700; 136 | color: #f04747; 137 | } 138 | } 139 | 140 | .entering .modal-inner, .leaving .modal-inner { 141 | animation: modal-inner .35s cubic-bezier(0.35, 1.01, 0.75, 1.04); 142 | } 143 | 144 | .leaving .modal-inner { 145 | animation-duration: .20s; 146 | animation-direction: reverse; 147 | animation-fill-mode: forwards; 148 | } 149 | 150 | @keyframes modal { 151 | from { 152 | opacity: .4; 153 | } 154 | to { 155 | opacity: 1; 156 | } 157 | } 158 | 159 | @keyframes modal-leaving { 160 | from { 161 | opacity: 1; 162 | } 163 | to { 164 | opacity: 0; 165 | } 166 | } 167 | 168 | @keyframes modal-inner { 169 | from { 170 | opacity: 0; 171 | transform: scale(0.5); 172 | } 173 | 174 | to { 175 | opacity: 1; 176 | transform: scale(1); 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /views/attachment.ejs: -------------------------------------------------------------------------------- 1 | 5 | <% if (attachment.width && attachment.width > 0 && attachment.width && attachment.width > 0) { %> 6 | <% if (/\.(png|jpe?g|webp|gif)$/i.test(attachment.filename)) { %> 7 | <% 8 | const url = new URL(attachment.proxy_url || attachment.url) 9 | url.searchParams.append('width', parseInt(attachment.displayMaxWidth).toString()) 10 | url.searchParams.append('height', parseInt(attachment.displayMaxHeight).toString()) 11 | %> 12 | 15 | <% } %> 16 | <% if (/\.(mp4|webm|mov)$/i.test(attachment.filename) && attachment.proxy_url) { %> 17 |
19 | 31 | 32 | 34 |
35 | <% } %> 36 | <% } else { %> 37 | <% const textRegex = /\.(?:txt|md|log|c\+\+|cpp|cc|c|h|hpp|mm|m|json|js|jsx|rb|rake|py|asm|fs|cgi|bat|rss|java|graphml|idb|lua|o|gml|prl|sls|conf|cmake|make|sln|vbe|cxx|wbf|vbs|r|wml|php|bash|applescript|fcgi|yaml|ex|exs|sh|ml|actionscript|html|xhtml|htm|xml|xls|xsd|css|styl|scss|go)$/i%> 38 | <% const isAudio = /\.(mp3|ogg|wav|flac)$/i.test(attachment.filename) && attachment.url %> 39 | 40 | '> 41 |
42 | 43 |
44 | <%= attachment.filename %> 45 | <%= attachment.formattedBytes %> 46 |
47 | <% if (textRegex.test(attachment.filename)) { %> 48 |
49 | 50 | 51 | 53 | 54 |
55 | <% } %> 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 | <% if (isAudio) { %> 64 | 65 | 66 | <% } %> 67 |
68 | <% } %> 69 | -------------------------------------------------------------------------------- /style/_embed.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .embeds { 7 | padding-top: .125rem; 8 | padding-bottom: .125rem; 9 | 10 | &:empty { 11 | display: none; 12 | } 13 | 14 | > * + * { 15 | margin-top: .25rem; 16 | } 17 | } 18 | 19 | .embed { 20 | border-left: 4px solid var(--background-very-dark); 21 | background: var(--background-dark); 22 | border-radius: 4px; 23 | padding: 8px 16px 16px; 24 | max-width: 520px; 25 | font-size: 14px; 26 | 27 | .inner { 28 | display: flex; 29 | } 30 | 31 | .content { 32 | flex: 1; 33 | } 34 | 35 | .provider, .author, .title, .description, .thumbnail, .fields, .field, .footer { 36 | display: block; 37 | margin-top: 8px; 38 | } 39 | 40 | .provider { 41 | font-size: 0.75rem; 42 | line-height: 1rem; 43 | font-weight: 400; 44 | color: var(--color-light-gray) 45 | } 46 | 47 | .author, .footer { 48 | display: flex; 49 | align-items: center; 50 | font-size: 0.875rem; 51 | 52 | img { 53 | width: 24px; 54 | height: 24px; 55 | border-radius: 50%; 56 | margin-right: 8px; 57 | margin-top: 0; 58 | } 59 | 60 | a, span { 61 | color: inherit; 62 | font-weight: 500; 63 | } 64 | } 65 | 66 | .title { 67 | font-weight: 600; 68 | font-size: 1rem; 69 | } 70 | 71 | .fields { 72 | &-row { 73 | display: flex; 74 | 75 | &.row-1 .field { 76 | width: 100%; 77 | } 78 | 79 | &.row-2 .field { 80 | width: 49.5%; 81 | } 82 | 83 | &.row-3 .field { 84 | width: 33%; 85 | } 86 | } 87 | 88 | .field { 89 | .title { 90 | margin-top: 0; 91 | color: var(--color-gray); 92 | font-weight: 500; 93 | margin-bottom: 2px; 94 | font-size: inherit; 95 | } 96 | } 97 | } 98 | 99 | .embed-image { 100 | margin-top: 16px; 101 | max-width: 300px; 102 | max-height: 400px; 103 | } 104 | 105 | .gallery { 106 | grid-column: 1/2; 107 | display: grid; 108 | grid-template-columns: 1fr 1fr; 109 | column-gap: 4px; 110 | overflow: hidden; 111 | border-radius: 4px; 112 | margin-top: 16px; 113 | height: 300px; 114 | 115 | .column { 116 | display: flex; 117 | justify-content: center; 118 | flex-direction: column; 119 | align-items: center; 120 | overflow: hidden; 121 | } 122 | 123 | .gallery-image { 124 | min-height: calc(50% - 2px); 125 | max-height: 100%; 126 | min-width: 100%; 127 | max-width: 100%; 128 | 129 | &:only-child { 130 | min-height: 100%; 131 | } 132 | 133 | &:nth-child(2) { 134 | margin-top: 4px; 135 | } 136 | 137 | img { 138 | width: 100%; 139 | height: 100%; 140 | object-fit: cover; 141 | } 142 | } 143 | } 144 | 145 | .embed-video { 146 | position: relative; 147 | border-radius: 4px; 148 | contain: paint; 149 | margin-top: 16px; 150 | 151 | .controls { 152 | position: absolute; 153 | top: 50%; 154 | left: 50%; 155 | transform: translate(-50%, -50%); 156 | display: flex; 157 | padding: 12px; 158 | height: 48px; 159 | border-radius: 24px; 160 | background-color: rgba(0, 0, 0, .6); 161 | color: #fff; 162 | 163 | div, a { 164 | opacity: .6; 165 | cursor: pointer; 166 | transition: opacity .25s; 167 | color: inherit; 168 | 169 | &:hover { 170 | opacity: 1; 171 | } 172 | } 173 | 174 | div svg { 175 | margin-left: 1px; 176 | margin-right: -1px; 177 | } 178 | 179 | a svg { 180 | margin-left: 2px; 181 | margin-right: 4px; 182 | } 183 | } 184 | } 185 | 186 | .thumbnail { 187 | flex-shrink: 0; 188 | width: 80px; 189 | height: 80px; 190 | margin-left: 16px; 191 | border-radius: 4px; 192 | } 193 | 194 | .footer { 195 | font-size: 12px; 196 | line-height: 1rem; 197 | font-weight: 400; 198 | color: var(--color-gray); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /views/messages.ejs: -------------------------------------------------------------------------------- 1 | 5 | <% const icons = [ 6 | 'https://discord.com/assets/b8029fe196b8f1382e90bbe81dab50dc.svg', 7 | 'https://discord.com/assets/aff52bc375fe2da876174301a5c6d09d.svg', 8 | 'https://discord.com/assets/d80d1fdc43a3c42134a31a39581160ac.svg', 9 | 'https://discord.com/assets/0c9615349350c4185381d58c69b88cbc.svg', 10 | 'https://discord.com/assets/0c9615349350c4185381d58c69b88cbc.svg', 11 | 'https://discord.com/assets/5da4cdab01d4d89c593c48c62ae0d937.svg', 12 | 'https://discord.com/assets/b8029fe196b8f1382e90bbe81dab50dc.svg', 13 | 'https://cdn.discordapp.com/emojis/585558042309820447.png', 14 | 'https://cdn.discordapp.com/emojis/585558042309820447.png', 15 | 'https://cdn.discordapp.com/emojis/585558042309820447.png', 16 | 'https://cdn.discordapp.com/emojis/585558042309820447.png', 17 | 'https://discord.com/assets/b8029fe196b8f1382e90bbe81dab50dc.svg', 18 | null, // GUILD_STREAM (seems unused?) 19 | 'https://discord.com/assets/46db4c8d56c169f418cc5c0f0d4ddf70.svg', 20 | 'https://discord.com/assets/286a546022f1e3cbe45b41090f8d6e97.svg' 21 | ] %> 22 | <% for (const group of data.grouppedMessages) { %> 23 | <% for (const msg of group) { %> 24 | <% const author = data.entities.users[msg.author] || { username: 'Deleted User', discriminator: '0000\u200B' } %> 25 | <% if (msg.type && msg.type !== 0) { %> 26 | 27 | 29 |
30 | icon 31 |
32 | 33 | <%- markdown.parse(msg.content, data.entities, false, true) %> 34 | 35 | 36 | <%= new Date(msg.time).toUTCString() %> 37 | 38 |
39 | <% } else { %> 40 | 41 | 43 | avatar 45 | 46 | 47 | <% const msgDate = new Date(msg.time) %> 48 | 49 | <%= msgDate.getHours().toString().padStart(2, '0') %>:<%= msgDate.getMinutes().toString().padStart(2, '0') %> 50 | 51 | 52 |
53 | 54 | 55 | <%= author.username %> 56 | <%= author.badge %> 57 | 58 | 59 | <%= new Date(msg.time).toUTCString() %> 60 | 61 | 62 | 63 | 64 | 65 | <%- markdown.parse(msg.content, data.entities, author.discriminator === '0000') %> 66 | 67 |
68 | <% if (typeof msg.invites !== 'undefined') { %> 69 | <% for (const invite of msg.invites) { %> 70 | 71 | 72 | <% } %> 73 | <% } %> 74 | <% if (typeof msg.attachments !== 'undefined') { %> 75 | <% for (const attachment of msg.attachments) { %> 76 | <%- include('attachment.ejs', { data: data, attachment: attachment }) %> 77 | <% } %> 78 | <% } %> 79 | <% if (typeof msg.embeds !== 'undefined') { %> 80 | <% for (const embed of msg.embeds) { %> 81 | <%- include('embed.ejs', { data: data, embed: embed, markdown: markdown }) %> 82 | <% } %> 83 | <% } %> 84 |
85 |
86 |
87 | <% } %> 88 | <% } %> 89 | <% } %> 90 | -------------------------------------------------------------------------------- /style/_chat.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | .placeholder { 7 | flex: 1; 8 | } 9 | 10 | .welcome { 11 | margin: 0 16px; 12 | padding-bottom: 12px; 13 | border-bottom: 1px var(--spacer-light) solid; 14 | display: flex; 15 | justify-content: flex-end; 16 | flex-direction: column; 17 | flex-shrink: 0; 18 | height: 171px; 19 | user-select: none; 20 | 21 | .channel-icon { 22 | width: 68px; 23 | height: 68px; 24 | margin-top: 16px; 25 | border-radius: 50%; 26 | background-color: var(--background-accent); 27 | background-image: url(https://discord.com/assets/a9847a74a2dac6e2b2958b195a8dad96.svg); 28 | background-repeat: no-repeat; 29 | background-position: 50%; 30 | } 31 | 32 | h1 { 33 | font-size: 32px; 34 | font-weight: 700; 35 | line-height: 40px; 36 | margin: 8px 0; 37 | color: var(--color); 38 | 39 | b { 40 | font-weight: 600; 41 | } 42 | } 43 | 44 | div { 45 | color: var(--color-light-gray); 46 | font-size: 16px; 47 | line-height: 20px; 48 | } 49 | } 50 | 51 | // noinspection CssInvalidHtmlTagReference 52 | discord-messages { 53 | position: relative; 54 | z-index: 5; 55 | 56 | &:after { 57 | content: ''; 58 | display: block; 59 | height: 30px; 60 | } 61 | 62 | // noinspection CssInvalidHtmlTagReference 63 | discord-message { 64 | display: flex; 65 | position: relative; 66 | padding: .125rem 48px .125rem 72px; 67 | min-height: 1.375rem; 68 | 69 | &.system { 70 | color: var(--color-lighter-gray); 71 | display: flex; 72 | align-items: center; 73 | font-weight: 400; 74 | height: 30px; 75 | 76 | message-mention { 77 | color: var(--color); 78 | font-weight: 500; 79 | cursor: pointer; 80 | 81 | &:hover { 82 | text-decoration: underline; 83 | } 84 | } 85 | 86 | strong { 87 | font-weight: 700; 88 | } 89 | } 90 | 91 | &.group-start { 92 | margin-top: 1.0625em; 93 | } 94 | 95 | &.deleted { 96 | color: #f04747; 97 | } 98 | 99 | &:not(.system).group-start { 100 | min-height: 2.75rem; 101 | 102 | .time { 103 | display: none; 104 | } 105 | } 106 | 107 | &:not(.group-start) { 108 | message-header, .avatar { 109 | display: none; 110 | } 111 | } 112 | 113 | .avatar { 114 | position: absolute; 115 | left: 16px; 116 | top: 4px; 117 | width: 40px; 118 | height: 40px; 119 | border-radius: 50%; 120 | user-select: none; 121 | cursor: pointer; 122 | } 123 | 124 | &.system .icon { 125 | padding-top: .25rem; 126 | position: absolute; 127 | user-select: none; 128 | width: 2.5rem; 129 | left: 1rem; 130 | display: flex; 131 | align-items: center; 132 | justify-content: center; 133 | 134 | img { 135 | width: 1rem; 136 | height: 1rem; 137 | } 138 | } 139 | 140 | .time { 141 | position: absolute; 142 | user-select: none; 143 | color: var(--color-gray); 144 | height: 1.370rem; 145 | line-height: 1.370rem; 146 | text-align: right; 147 | font-size: 0.6875rem; 148 | margin: 0 .25rem; 149 | width: 3.5rem; 150 | opacity: 0; 151 | left: 0; 152 | } 153 | 154 | // noinspection CssInvalidHtmlTagReference 155 | message-header { 156 | display: flex; 157 | align-items: center; 158 | line-height: 1.375rem; 159 | min-height: 1.375rem; 160 | 161 | .name { 162 | font-size: 1rem; 163 | font-weight: 500; 164 | line-height: 1.375em; 165 | margin-right: .25rem; 166 | cursor: pointer; 167 | display: inline; 168 | 169 | &:hover { 170 | text-decoration: underline; 171 | } 172 | } 173 | } 174 | 175 | .date { 176 | font-size: 0.75rem; 177 | font-weight: 500; 178 | line-height: 1.375rem; 179 | color: var(--color-gray); 180 | margin-left: .25rem; 181 | display: inline-block; 182 | } 183 | 184 | &:hover { 185 | background-color: var(--background-hover); 186 | 187 | .time { 188 | opacity: 1; 189 | } 190 | } 191 | } 192 | 193 | .divider { 194 | display: flex; 195 | align-items: center; 196 | color: var(--color-gray); 197 | line-height: 13px; 198 | font-size: 12px; 199 | font-weight: 600; 200 | margin: 1.5rem .875rem .5rem 1rem; 201 | user-select: none; 202 | 203 | &:before, &:after { 204 | content: ''; 205 | display: block; 206 | flex: 1; 207 | height: 1px; 208 | background-color: var(--spacer); 209 | } 210 | 211 | &:before { 212 | margin-right: 4px; 213 | } 214 | 215 | &:after { 216 | margin-left: 4px; 217 | } 218 | } 219 | } 220 | 221 | // noinspection CssInvalidHtmlTagReference 222 | message-header .badge, .user-popout .badge { 223 | background: #7289da; 224 | color: #fff; 225 | height: 15px; 226 | font-weight: 500; 227 | padding: .072rem .275rem; 228 | margin-right: .25rem; 229 | margin-top: .075em; 230 | border-radius: 3px; 231 | font-size: .625rem; 232 | text-transform: uppercase; 233 | flex-shrink: 0; 234 | line-height: 1.3; 235 | 236 | &:empty { 237 | display: none; 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /style/_include.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | %codeblock { 7 | background: var(--background-dark); 8 | border: 1px solid var(--background-very-dark); 9 | max-width: 90%; 10 | border-radius: 4px; 11 | margin-top: 6px; 12 | padding: 0; 13 | white-space: pre-wrap; 14 | background-clip: border-box; 15 | position: relative; 16 | flex: 1; 17 | width: auto; 18 | height: auto; 19 | font-size: 14px; 20 | font-family: Consolas, Andale Mono WT, Andale Mono, Lucida Console, Lucida Sans Typewriter, DejaVu Sans Mono, Bitstream Vera Sans Mono, Liberation Mono, Nimbus Mono L, Monaco, Courier New, Courier, monospace; 21 | text-indent: 0; 22 | 23 | .lang { 24 | border-bottom: 1px solid rgba(0, 0, 0, 0.2); 25 | line-height: 20px; 26 | padding: 0 5px; 27 | font-family: sans-serif; 28 | font-size: 11px; 29 | text-transform: uppercase; 30 | font-weight: bold; 31 | } 32 | 33 | code, pre { 34 | padding: .2em; 35 | margin: -.2em 0; 36 | border-radius: 3px; 37 | border: none; 38 | white-space: pre-wrap; 39 | background-color: inherit; 40 | } 41 | 42 | .shitcode { 43 | margin: .5em; 44 | padding-left: 35px; 45 | position: relative; 46 | overflow-y: hidden; 47 | } 48 | 49 | .lines:before { 50 | position: absolute; 51 | top: 0; 52 | bottom: 0; 53 | left: -8px; 54 | content: '1\a 2\a 3\a 4\a 5\a 6\a 7\a 8\a 9\a 10\a 11\a 12\a 13\a 14\a 15\a 16\a 17\a 18\a 19\a 20\a 21\a 22\a 23\a 24\a 25\a 26\a 27\a 28\a 29\a 30\a 31\a 32\a 33\a 34\a 35\a 36\a 37\a 38\a 39\a 40\a 41\a 42\a 43\a 44\a 45\a 46\a 47\a 48\a 49\a 50\a 51\a 52\a 53\a 54\a 55\a 56\a 57\a 58\a 59\a 60\a 61\a 62\a 63\a 64\a 65\a 66\a 67\a 68\a 69\a 70\a 71\a 72\a 73\a 74\a 75\a 76\a 77\a 78\a 79\a 80\a 81\a 82\a 83\a 84\a 85\a 86\a 87\a 88\a 89\a 90\a 91\a 92\a 93\a 94\a 95\a 96\a 97\a 98\a 99\a 100\a 101\a 102\a 103\a 104\a 105\a 106\a 107\a 108\a 109\a 110\a 111\a 112\a 113\a 114\a 115\a 116\a 117\a 118\a 119\a 120\a 121\a 122\a 123\a 124\a 125\a 126\a 127\a 128\a 129\a 130\a 131\a 132\a 133\a 134\a 135\a 136\a 137\a 138\a 139\a 140\a 141\a 142\a 143\a 144\a 145\a 146\a 147\a 148\a 149\a 150\a 151\a 152\a 153\a 154\a 155\a 156\a 157\a 158\a 159\a 160\a 161\a 162\a 163\a 164\a 165\a 166\a 167\a 168\a 169\a 170\a 171\a 172\a 173\a 174\a 175\a 176\a 177\a 178\a 179\a 180\a 181\a 182\a 183\a 184\a 185\a 186\a 187\a 188\a 189\a 190\a 191\a 192\a 193\a 194\a 195\a 196\a 197\a 198\a 199\a 200\a 201\a 202\a 203\a 204\a 205\a 206\a 207\a 208\a 209\a 210\a 211\a 212\a 213\a 214\a 215\a 216\a 217\a 218\a 219\a 220\a 221\a 222\a 223\a 224\a 225\a 226\a 227\a 228\a 229\a 230\a 231\a 232\a 233\a 234\a 235\a 236\a 237\a 238\a 239\a 240\a 241\a 242\a 243\a 244\a 245\a 246\a 247\a 248\a 249\a 250\a 251\a 252\a 253\a 254\a 255\a 256\a 257\a 258\a 259\a 260\a 261\a 262\a 263\a 264\a 265\a 266\a 267\a 268\a 269\a 270\a 271\a 272\a 273\a 274\a 275\a 276\a 277\a 278\a 279\a 280\a 281\a 282\a 283\a 284\a 285\a 286\a 287\a 288\a 289\a 290\a 291\a 292\a 293\a 294\a 295\a 296\a 297\a 298\a 299\a 300\a 301\a 302\a 303\a 304\a 305\a 306\a 307\a 308\a 309\a 310\a 311\a 312\a 313\a 314\a 315\a 316\a 317\a 318\a 319\a 320\a 321\a 322\a 323\a 324\a 325\a 326\a 327\a 328\a 329\a 330\a 331\a 332\a 333\a 334\a 335\a 336\a 337\a 338\a 339\a 340\a 341\a 342\a 343\a 344\a 345\a 346\a 347\a 348\a 349\a 350\a 351\a 352\a 353\a 354\a 355\a 356\a 357\a 358\a 359\a 360\a 361\a 362\a 363\a 364\a 365\a 366\a 367\a 368\a 369\a 370\a 371\a 372\a 373\a 374\a 375\a 376\a 377\a 378\a 379\a 380\a 381\a 382\a 383\a 384\a 385\a 386\a 387\a 388\a 389\a 390\a 391\a 392\a 393\a 394\a 395\a 396\a 397\a 398\a 399\a 400\a 401\a 402\a 403\a 404\a 405\a 406\a 407\a 408\a 409\a 410\a 411\a 412\a 413\a 414\a 415\a 416\a 417\a 418\a 419\a 420\a 421\a 422\a 423\a 424\a 425\a 426\a 427\a 428\a 429\a 430\a 431\a 432\a 433\a 434\a 435\a 436\a 437\a 438\a 439\a 440\a 441\a 442\a 443\a 444\a 445\a 446\a 447\a 448\a 449\a 450\a 451\a 452\a 453\a 454\a 455\a 456\a 457\a 458\a 459\a 460\a 461\a 462\a 463\a 464\a 465\a 466\a 467\a 468\a 469\a 470\a 471\a 472\a 473\a 474\a 475\a 476\a 477\a 478\a 479\a 480\a 481\a 482\a 483\a 484\a 485\a 486\a 487\a 488\a 489\a 490\a 491\a 492\a 493\a 494\a 495\a 496\a 497\a 498\a 499\a 500\a 501\a 502\a 503\a 504\a 505\a 506\a 507\a 508\a 509\a 510\a 511\a 512\a 513\a 514\a 515\a 516\a 517\a 518\a 519\a 520\a 521\a 522\a 523\a 524\a 525\a 526\a 527\a 528\a 529\a 530\a 531\a 532\a 533\a 534\a 535\a 536\a 537\a 538\a 539\a 540\a 541\a 542\a 543\a 544\a 545\a 546\a 547\a 548\a 549\a 550\a 551\a 552\a 553\a 554\a 555\a 556\a 557\a 558\a 559\a 560\a 561\a 562\a 563\a 564\a 565\a 566\a 567\a 568\a 569\a 570\a 571\a 572\a 573\a 574\a 575\a 576\a 577\a 578\a 579\a 580\a 581\a 582\a 583\a 584\a 585\a 586\a 587\a 588\a 589\a 590\a 591\a 592\a 593\a 594\a 595\a 596\a 597\a 598\a 599\a 600\a 601\a 602\a 603\a 604\a 605\a 606\a 607\a 608\a 609\a 610\a 611\a 612\a 613\a 614\a 615\a 616\a 617\a 618\a 619\a 620\a 621\a 622\a 623\a 624\a 625\a 626\a 627\a 628\a 629\a 630\a 631\a 632\a 633\a 634\a 635\a 636\a 637\a 638\a 639\a 640\a 641\a 642\a 643\a 644\a 645\a 646\a 647\a 648\a 649\a 650\a 651\a 652\a 653\a 654\a 655\a 656\a 657\a 658\a 659\a 660\a 661\a 662\a 663\a 664\a 665\a 666\a 667\a 668\a 669\a 670\a 671\a 672\a 673\a 674\a 675\a 676\a 677\a 678\a 679\a 680\a 681\a 682\a 683\a 684\a 685\a 686\a 687\a 688\a 689\a 690\a 691\a 692\a 693\a 694\a 695\a 696\a 697\a 698\a 699\a 700\a 701\a 702\a 703\a 704\a 705\a 706\a 707\a 708\a 709\a 710\a 711\a 712\a 713\a 714\a 715\a 716\a 717\a 718\a 719\a 720\a 721\a 722\a 723\a 724\a 725\a 726\a 727\a 728\a 729\a 730\a 731\a 732\a 733\a 734\a 735\a 736\a 737\a 738\a 739\a 740\a 741\a 742\a 743\a 744\a 745\a 746\a 747\a 748\a 749\a 750\a 751\a 752\a 753\a 754\a 755\a 756\a 757\a 758\a 759\a 760\a 761\a 762\a 763\a 764\a 765\a 766\a 767\a 768\a 769\a 770\a 771\a 772\a 773\a 774\a 775\a 776\a 777\a 778\a 779\a 780\a 781\a 782\a 783\a 784\a 785\a 786\a 787\a 788\a 789\a 790\a 791\a 792\a 793\a 794\a 795\a 796\a 797\a 798\a 799\a 800\a 801\a 802\a 803\a 804\a 805\a 806\a 807\a 808\a 809\a 810\a 811\a 812\a 813\a 814\a 815\a 816\a 817\a 818\a 819\a 820\a 821\a 822\a 823\a 824\a 825\a 826\a 827\a 828\a 829\a 830\a 831\a 832\a 833\a 834\a 835\a 836\a 837\a 838\a 839\a 840\a 841\a 842\a 843\a 844\a 845\a 846\a 847\a 848\a 849\a 850\a 851\a 852\a 853\a 854\a 855\a 856\a 857\a 858\a 859\a 860\a 861\a 862\a 863\a 864\a 865\a 866\a 867\a 868\a 869\a 870\a 871\a 872\a 873\a 874\a 875\a 876\a 877\a 878\a 879\a 880\a 881\a 882\a 883\a 884\a 885\a 886\a 887\a 888\a 889\a 890\a 891\a 892\a 893\a 894\a 895\a 896\a 897\a 898\a 899\a 900\a 901\a 902\a 903\a 904\a 905\a 906\a 907\a 908\a 909\a 910\a 911\a 912\a 913\a 914\a 915\a 916\a 917\a 918\a 919\a 920\a 921\a 922\a 923\a 924\a 925\a 926\a 927\a 928\a 929\a 930\a 931\a 932\a 933\a 934\a 935\a 936\a 937\a 938\a 939\a 940\a 941\a 942\a 943\a 944\a 945\a 946\a 947\a 948\a 949\a 950\a 951\a 952\a 953\a 954\a 955\a 956\a 957\a 958\a 959\a 960\a 961\a 962\a 963\a 964\a 965\a 966\a 967\a 968\a 969\a 970\a 971\a 972\a 973\a 974\a 975\a 976\a 977\a 978\a 979\a 980\a 981\a 982\a 983\a 984\a 985\a 986\a 987\a 988\a 989\a 990\a 991\a 992\a 993\a 994\a 995\a 996\a 997\a 998\a 999\a'; 55 | padding-right: 6px; 56 | border-right: 1px solid rgba(0, 0, 0, 0.7); 57 | text-align: right; 58 | height: 100%; 59 | width: 30px; 60 | font-size: inherit; 61 | line-height: inherit; 62 | opacity: .5; 63 | } 64 | 65 | .copy { 66 | color: #ffffff; 67 | border-radius: 4px; 68 | line-height: 20px; 69 | padding: 0 10px; 70 | cursor: pointer; 71 | font-family: sans-serif; 72 | font-size: 11px; 73 | text-transform: uppercase; 74 | margin: 3px; 75 | font-weight: bold; 76 | background: rgba(0, 0, 0, 0.5); 77 | position: absolute; 78 | right: 0; 79 | bottom: 0; 80 | opacity: 0; 81 | transition: 0.3s; 82 | 83 | &.success { 84 | background-color: mediumseagreen; 85 | } 86 | } 87 | 88 | &[is='message-codeblock']:hover .copy { 89 | opacity: 1; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /views/embed.ejs: -------------------------------------------------------------------------------- 1 | 5 | <% if (embed.provider && embed.provider.name === 'Spotify') { %> 6 |
7 | <% const url = new URL(embed.url).pathname %> 8 | 9 | 11 |
12 | <% } else { %> 13 | <% if ((embed.thumbnail || embed.video) && (embed.type === 'gifv' || (embed.type !== 'rich' && !embed.author && !embed.title))) { %> 14 | <% if (embed.video) { %> 15 | 16 | 21 | <% } else { %> 22 | <% 23 | const url = new URL(embed.thumbnail.proxy_url || embed.thumbnail.url) 24 | url.searchParams.append('width', parseInt(embed.thumbnail.displayMaxWidth).toString()) 25 | url.searchParams.append('height', parseInt(embed.thumbnail.displayMaxHeight).toString()) 26 | %> 27 | 30 | <% } %> 31 | <% } else { %> 32 |
34 |
35 |
36 | <% if (embed.provider) { %> 37 | <% if (embed.provider.url) { %> 38 | 39 | <%= embed.provider.name %> 40 | 41 | <% } else { %> 42 | <%= embed.provider.name %> 43 | <% } %> 44 | <% } %> 45 | <% if (embed.author) { %> 46 |
47 | <% if (embed.author.icon_url) { %> 48 | 50 | <% } %> 51 | <% if (embed.author.url) { %> 52 | <%= embed.author.name %> 53 | <% } else { %> 54 | <%= embed.author.name %> 55 | <% } %> 56 |
57 | <% } %> 58 | <% if (embed.title) { %> 59 | <% if (embed.url) { %> 60 | 61 | <%- markdown.parse(embed.title, data.entities) %> 62 | 63 | <% } else { %> 64 |
65 | <%- markdown.parse(embed.title, data.entities) %> 66 |
67 | <% } %> 68 | <% } %> 69 | <% if (embed.description && ![ 'image', 'video', 'gifv' ].includes(embed.type)) { %> 70 |
71 | <%- markdown.parse(embed.description, data.entities, true) %> 72 |
73 | <% } %> 74 | <% if (embed.fields) { %> 75 |
76 | <% for (const fields of embed.grouppedFields) { %> 77 |
78 | <% for (const field of fields) { %> 79 |
80 |
<%- markdown.parse(field.name, data.entities) %>
81 |
<%- markdown.parse(field.value, data.entities, true) %>
82 |
83 | <% } %> 84 |
85 | <% } %> 86 |
87 | <% } %> 88 |
89 | <% if (!embed.video && embed.thumbnail) { %> 90 | <% 91 | const url = new URL(embed.thumbnail.proxy_url || embed.thumbnail.url) 92 | url.searchParams.append('width', '80') 93 | url.searchParams.append('height', '80') 94 | %> 95 | 98 | <% } %> 99 |
100 | <% if (embed.video) { %> 101 |
103 | Thumbnail 106 |
107 |
108 | 109 | 110 | 111 | 112 |
113 | 114 | 115 | 116 | 118 | 119 | 120 |
121 |
122 | <% } else if (embed.images) { %> 123 | 141 | <% } else if (embed.image) { %> 142 | <% 143 | const url = new URL(embed.image.proxy_url || embed.image.url) 144 | url.searchParams.append('width', parseInt(embed.image.displayMaxWidth).toString()) 145 | url.searchParams.append('height', parseInt(embed.image.displayMaxHeight).toString()) 146 | %> 147 | 150 | <% } %> 151 | <% if (embed.footer || embed.timestamp) { %> 152 | 168 | <% } %> 169 |
170 | <% } %> 171 | <% } %> 172 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Open Software License ("OSL") v. 3.0 2 | 3 | This Open Software License (the "License") applies to any original work of 4 | authorship (the "Original Work") whose owner (the "Licensor") has placed the 5 | following licensing notice adjacent to the copyright notice for the Original 6 | Work: 7 | 8 | Licensed under the Open Software License version 3.0 9 | 10 | 1) Grant of Copyright License. Licensor grants You a worldwide, royalty-free, 11 | non-exclusive, sublicensable license, for the duration of the copyright, to do 12 | the following: 13 | 14 | a) to reproduce the Original Work in copies, either alone or as part of a 15 | collective work; 16 | 17 | b) to translate, adapt, alter, transform, modify, or arrange the Original 18 | Work, thereby creating derivative works ("Derivative Works") based upon the 19 | Original Work; 20 | 21 | c) to distribute or communicate copies of the Original Work and Derivative 22 | Works to the public, with the proviso that copies of Original Work or 23 | Derivative Works that You distribute or communicate shall be licensed under 24 | this Open Software License; 25 | 26 | d) to perform the Original Work publicly; and 27 | 28 | e) to display the Original Work publicly. 29 | 30 | 2) Grant of Patent License. Licensor grants You a worldwide, royalty-free, 31 | non-exclusive, sublicensable license, under patent claims owned or controlled 32 | by the Licensor that are embodied in the Original Work as furnished by the 33 | Licensor, for the duration of the patents, to make, use, sell, offer for sale, 34 | have made, and import the Original Work and Derivative Works. 35 | 36 | 3) Grant of Source Code License. The term "Source Code" means the preferred 37 | form of the Original Work for making modifications to it and all available 38 | documentation describing how to modify the Original Work. Licensor agrees to 39 | provide a machine-readable copy of the Source Code of the Original Work along 40 | with each copy of the Original Work that Licensor distributes. Licensor 41 | reserves the right to satisfy this obligation by placing a machine-readable 42 | copy of the Source Code in an information repository reasonably calculated to 43 | permit inexpensive and convenient access by You for as long as Licensor 44 | continues to distribute the Original Work. 45 | 46 | 4) Exclusions From License Grant. Neither the names of Licensor, nor the names 47 | of any contributors to the Original Work, nor any of their trademarks or 48 | service marks, may be used to endorse or promote products derived from this 49 | Original Work without express prior permission of the Licensor. Except as 50 | expressly stated herein, nothing in this License grants any license to 51 | Licensor's trademarks, copyrights, patents, trade secrets or any other 52 | intellectual property. No patent license is granted to make, use, sell, offer 53 | for sale, have made, or import embodiments of any patent claims other than the 54 | licensed claims defined in Section 2. No license is granted to the trademarks 55 | of Licensor even if such marks are included in the Original Work. Nothing in 56 | this License shall be interpreted to prohibit Licensor from licensing under 57 | terms different from this License any Original Work that Licensor otherwise 58 | would have a right to license. 59 | 60 | 5) External Deployment. The term "External Deployment" means the use, 61 | distribution, or communication of the Original Work or Derivative Works in any 62 | way such that the Original Work or Derivative Works may be used by anyone 63 | other than You, whether those works are distributed or communicated to those 64 | persons or made available as an application intended for use over a network. 65 | As an express condition for the grants of license hereunder, You must treat 66 | any External Deployment by You of the Original Work or a Derivative Work as a 67 | distribution under section 1(c). 68 | 69 | 6) Attribution Rights. You must retain, in the Source Code of any Derivative 70 | Works that You create, all copyright, patent, or trademark notices from the 71 | Source Code of the Original Work, as well as any notices of licensing and any 72 | descriptive text identified therein as an "Attribution Notice." You must cause 73 | the Source Code for any Derivative Works that You create to carry a prominent 74 | Attribution Notice reasonably calculated to inform recipients that You have 75 | modified the Original Work. 76 | 77 | 7) Warranty of Provenance and Disclaimer of Warranty. Licensor warrants that 78 | the copyright in and to the Original Work and the patent rights granted herein 79 | by Licensor are owned by the Licensor or are sublicensed to You under the 80 | terms of this License with the permission of the contributor(s) of those 81 | copyrights and patent rights. Except as expressly stated in the immediately 82 | preceding sentence, the Original Work is provided under this License on an "AS 83 | IS" BASIS and WITHOUT WARRANTY, either express or implied, including, without 84 | limitation, the warranties of non-infringement, merchantability or fitness for 85 | a particular purpose. THE ENTIRE RISK AS TO THE QUALITY OF THE ORIGINAL WORK 86 | IS WITH YOU. This DISCLAIMER OF WARRANTY constitutes an essential part of this 87 | License. No license to the Original Work is granted by this License except 88 | under this disclaimer. 89 | 90 | 8) Limitation of Liability. Under no circumstances and under no legal theory, 91 | whether in tort (including negligence), contract, or otherwise, shall the 92 | Licensor be liable to anyone for any indirect, special, incidental, or 93 | consequential damages of any character arising as a result of this License or 94 | the use of the Original Work including, without limitation, damages for loss 95 | of goodwill, work stoppage, computer failure or malfunction, or any and all 96 | other commercial damages or losses. This limitation of liability shall not 97 | apply to the extent applicable law prohibits such limitation. 98 | 99 | 9) Acceptance and Termination. If, at any time, You expressly assented to this 100 | License, that assent indicates your clear and irrevocable acceptance of this 101 | License and all of its terms and conditions. If You distribute or communicate 102 | copies of the Original Work or a Derivative Work, You must make a reasonable 103 | effort under the circumstances to obtain the express assent of recipients to 104 | the terms of this License. This License conditions your rights to undertake 105 | the activities listed in Section 1, including your right to create Derivative 106 | Works based upon the Original Work, and doing so without honoring these terms 107 | and conditions is prohibited by copyright law and international treaty. 108 | Nothing in this License is intended to affect copyright exceptions and 109 | limitations (including "fair use" or "fair dealing"). This License shall 110 | terminate immediately and You may no longer exercise any of the rights granted 111 | to You by this License upon your failure to honor the conditions in Section 112 | 1(c). 113 | 114 | 10) Termination for Patent Action. This License shall terminate automatically 115 | and You may no longer exercise any of the rights granted to You by this 116 | License as of the date You commence an action, including a cross-claim or 117 | counterclaim, against Licensor or any licensee alleging that the Original Work 118 | infringes a patent. This termination provision shall not apply for an action 119 | alleging patent infringement by combinations of the Original Work with other 120 | software or hardware. 121 | 122 | 11) Jurisdiction, Venue and Governing Law. Any action or suit relating to this 123 | License may be brought only in the courts of a jurisdiction wherein the 124 | Licensor resides or in which Licensor conducts its primary business, and under 125 | the laws of that jurisdiction excluding its conflict-of-law provisions. The 126 | application of the United Nations Convention on Contracts for the 127 | International Sale of Goods is expressly excluded. Any use of the Original 128 | Work outside the scope of this License or after its termination shall be 129 | subject to the requirements and penalties of copyright or patent law in the 130 | appropriate jurisdiction. This section shall survive the termination of this 131 | License. 132 | 133 | 12) Attorneys' Fees. In any action to enforce the terms of this License or 134 | seeking damages relating thereto, the prevailing party shall be entitled to 135 | recover its costs and expenses, including, without limitation, reasonable 136 | attorneys' fees and costs incurred in connection with such action, including 137 | any appeal of such action. This section shall survive the termination of this 138 | License. 139 | 140 | 13) Miscellaneous. If any provision of this License is held to be 141 | unenforceable, such provision shall be reformed only to the extent necessary 142 | to make it enforceable. 143 | 144 | 14) Definition of "You" in This License. "You" throughout this License, 145 | whether in upper or lower case, means an individual or a legal entity 146 | exercising rights under, and complying with all of the terms of, this License. 147 | For legal entities, "You" includes any entity that controls, is controlled by, 148 | or is under common control with you. For purposes of this definition, 149 | "control" means (i) the power, direct or indirect, to cause the direction or 150 | management of such entity, whether by contract or otherwise, or (ii) ownership 151 | of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial 152 | ownership of such entity. 153 | 154 | 15) Right to Use. You may use the Original Work in all ways not otherwise 155 | restricted or conditioned by this License or by law, and Licensor promises not 156 | to interfere with or be responsible for such uses by You. 157 | 158 | 16) Modification of This License. This License is Copyright © 2005 Lawrence 159 | Rosen. Permission is granted to copy, distribute, or communicate this License 160 | without modification. Nothing in this License permits You to modify this 161 | License as applied to the Original Work or to Derivative Works. However, You 162 | may modify the text of this License and copy, distribute or communicate your 163 | modified version (the "Modified License") and apply it to other original works 164 | of authorship subject to the following conditions: (i) You may not indicate in 165 | any way that your Modified License is the "Open Software License" or "OSL" and 166 | you may not use those names in the name of your Modified License; (ii) You 167 | must replace the notice specified in the first paragraph above with the notice 168 | "Licensed under " or with a notice of your own 169 | that is not confusingly similar to the notice in this License; and (iii) You 170 | may not claim that your original works are open source software unless your 171 | Modified License has been approved by Open Source Initiative (OSI) and You 172 | comply with its license review and certification process. 173 | -------------------------------------------------------------------------------- /src/markdown.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | const url = require('url') 7 | const twemoji = require('twemoji') 8 | const hljs = require('highlight.js') 9 | const SimpleMarkdown = require('simple-markdown') 10 | // eslint-disable-next-line node/no-deprecated-api 11 | const punycode = require('punycode') // this is from npm but eslint dumb 12 | 13 | class Markdown { 14 | constructor () { 15 | this.defaultRules = { 16 | newline: { 17 | ...SimpleMarkdown.defaultRules.newline, 18 | match: SimpleMarkdown.inlineRegex(/^\n/), 19 | html: () => '
' 20 | }, 21 | paragraph: SimpleMarkdown.defaultRules.paragraph, 22 | escape: SimpleMarkdown.defaultRules.escape, 23 | blockQuote: { 24 | ...SimpleMarkdown.defaultRules.blockQuote, 25 | match: (source, state) => { 26 | if (!/^$|\n *$/.test(state.prevCapture ? state.prevCapture[0] : '') || state.inQuote || state.nested) { 27 | return null 28 | } 29 | return /^( *>>> +([\s\S]*))|^( *>(?!>>) +[^\n]*(\n *>(?!>>) +[^\n]*)*\n?)/.exec(source) 30 | }, 31 | parse: ([ source ], parse, state) => { 32 | const multilineRegex = /^ *>>> ?/ 33 | const simpleRegex = /^ *> ?/gm 34 | const isMultiline = multilineRegex.exec(source) 35 | const cleanString = source.replace(isMultiline ? multilineRegex : simpleRegex, '') 36 | 37 | const prevInQuote = !!state.inQuote 38 | state.inQuote = true 39 | 40 | const formattedMarkup = parse(cleanString, state) 41 | state.inQuote = prevInQuote 42 | if (formattedMarkup.length === 0) { 43 | formattedMarkup.push({ 44 | type: 'text', 45 | content: ' ' 46 | }) 47 | } 48 | return { 49 | content: formattedMarkup, 50 | type: 'blockQuote' 51 | } 52 | }, 53 | html: (node, output, state) => SimpleMarkdown.htmlTag('blockquote', [ 54 | SimpleMarkdown.htmlTag('div', '', { class: 'side' }), 55 | SimpleMarkdown.htmlTag('div', output(node.content, state).replace(/\n/g, '
'), { class: 'content' }) 56 | ].join('')) 57 | }, 58 | link: { 59 | ...SimpleMarkdown.defaultRules.link, 60 | match: (source, state, prevCapture) => { 61 | if (!state.allowInlineLinks) { 62 | return null 63 | } 64 | return SimpleMarkdown.defaultRules.link.match(source, state, prevCapture) 65 | }, 66 | html: (node, output, state) => SimpleMarkdown.htmlTag('a', output(node.content, state), { 67 | href: SimpleMarkdown.sanitizeUrl(node.target), 68 | title: node.title, 69 | target: '_blank' 70 | }) 71 | }, 72 | autolink: { 73 | ...SimpleMarkdown.defaultRules.autolink, 74 | parse: this._parseLink.bind(this) 75 | }, 76 | url: { 77 | ...SimpleMarkdown.defaultRules.url, 78 | match: (source, state) => { 79 | if (!state.inline) return null 80 | const match = /^((?:https?|steam):\/\/[^\s<]+[^<.,:;"'\]\s])/.exec(source) 81 | if (match !== null) { 82 | let prevIndex = 0 83 | let fixedMatch = match[0] 84 | for (let i = fixedMatch.length - 1; i >= 0; i--) { 85 | if (fixedMatch[i] !== ')') break 86 | const index = fixedMatch.indexOf('(', prevIndex) 87 | if (index === -1) { 88 | fixedMatch = fixedMatch.slice(0, fixedMatch.length - 1) 89 | break 90 | } 91 | prevIndex = index + 1 92 | } 93 | match[0] = match[1] = fixedMatch 94 | } 95 | return match 96 | }, 97 | parse: this._parseLink.bind(this) 98 | }, 99 | strong: SimpleMarkdown.defaultRules.strong, 100 | em: SimpleMarkdown.defaultRules.em, 101 | u: SimpleMarkdown.defaultRules.u, 102 | text: { 103 | ...SimpleMarkdown.defaultRules.text, 104 | html: node => { 105 | const res = SimpleMarkdown.sanitizeText(node.content).replace(/(^ +)|( +$)/g, ' ').replace(/\n/g, '
') 106 | return node.skipEmoji ? res : this.twemoji(res) 107 | } 108 | }, 109 | inlineCode: SimpleMarkdown.defaultRules.inlineCode, 110 | codeBlock: { 111 | order: SimpleMarkdown.defaultRules.codeBlock.order, 112 | match: e => /^```(([a-z0-9_+\-.]+?)\n)?\n*([^\n][^]*?)\n*```/i.exec(e), 113 | parse: ([ , , lang, content ], _, state) => ({ 114 | lang: (lang || '').trim(), 115 | content: content || '', 116 | inQuote: !!state.inQuote 117 | }), 118 | html: (node, output, state) => { 119 | let code, lang 120 | if (node.lang) { 121 | try { 122 | const res = hljs.highlight(node.lang, node.content) 123 | code = res.value 124 | lang = res.language 125 | } catch (e) {} 126 | } 127 | if (!code) { 128 | code = output({ 129 | type: 'text', 130 | content: node.content 131 | }, state) 132 | } 133 | return SimpleMarkdown.htmlTag('pre', [ 134 | lang && SimpleMarkdown.htmlTag('div', node.lang, { class: 'lang' }), 135 | SimpleMarkdown.htmlTag('div', [ 136 | SimpleMarkdown.htmlTag('div', '', { class: 'lines' }), 137 | SimpleMarkdown.htmlTag('code', code, { class: [ 'hljs', lang ].filter(Boolean).join(' ') }) 138 | ].join(''), { class: 'shitcode' }), 139 | SimpleMarkdown.htmlTag('div', 'Copy', { class: 'copy' }) 140 | ].filter(Boolean).join(''), { 141 | class: 'codeblock', 142 | is: 'message-codeblock' 143 | }) 144 | } 145 | }, 146 | roleMention: { 147 | order: SimpleMarkdown.defaultRules.text.order, 148 | match: e => /^<@&(\d+)>/.exec(e), 149 | parse: ([ , id ], _, state) => { 150 | const role = state.entities.roles[id] 151 | if (!role) { 152 | return { 153 | type: 'text', 154 | content: `${state.noMentionPrefix ? '' : '@'}deleted-role`, 155 | skipEmoji: true 156 | } 157 | } 158 | return { 159 | id, 160 | type: 'mention', 161 | color: role.color, 162 | content: [ { 163 | type: 'text', 164 | content: `${state.noMentionPrefix ? '' : '@'}${role.name}` 165 | } ] 166 | } 167 | } 168 | }, 169 | channel: { 170 | order: SimpleMarkdown.defaultRules.text.order, 171 | match: source => /^<#(\d+)>/.exec(source), 172 | parse: ([ , id ], _, state) => { 173 | const channel = state.entities.channels[id] 174 | if (!channel) { 175 | return { 176 | type: 'text', 177 | content: `${state.noMentionPrefix ? '' : '#'}deleted-channel`, 178 | skipEmoji: true 179 | } 180 | } 181 | return { 182 | id, 183 | type: 'mention', 184 | content: [ { 185 | type: 'text', 186 | content: `${state.noMentionPrefix ? '' : '#'}${channel.name}` 187 | } ] 188 | } 189 | } 190 | }, 191 | mention: { 192 | order: SimpleMarkdown.defaultRules.text.order, 193 | match: e => /^<@!?(\d+)>|^(@(?:everyone|here))/.exec(e), 194 | parse: ([ raw, id ], _, state) => { 195 | if (id && state.entities.users[id]) { 196 | return { 197 | id, 198 | type: 'mention', 199 | user: state.entities.users[id], 200 | content: [ 201 | { 202 | type: 'text', 203 | content: `${state.noMentionPrefix ? '' : '@'}${state.entities.users[id].username}`, 204 | skipEmoji: true 205 | } 206 | ] 207 | } 208 | } 209 | 210 | return { 211 | id, 212 | type: 'mention', 213 | content: [ 214 | { 215 | type: 'text', 216 | content: raw, 217 | skipEmoji: true 218 | } 219 | ] 220 | } 221 | }, 222 | html: (node, output, state) => { 223 | const attributes = { 224 | 'data-type': 'channel', 225 | 'data-id': node.id 226 | } 227 | if (node.user) { 228 | attributes['data-type'] = 'user' 229 | for (const data in node.user) { 230 | // noinspection JSUnfilteredForInLoop 231 | attributes[`data-${data}`] = node.user[data] || '' 232 | } 233 | } 234 | if (node.color) { 235 | attributes['data-type'] = 'role' 236 | attributes.style = `--role-color:${this.int2rgba(node.color)};--role-bg:${this.int2rgba(node.color, 0.1)};--role-bg-h:${this.int2rgba(node.color, 0.3)}` 237 | } 238 | return SimpleMarkdown.htmlTag('message-mention', output(node.content, state), attributes) 239 | } 240 | }, 241 | customEmoji: { 242 | order: SimpleMarkdown.defaultRules.text.order, 243 | match: e => /^<(a?):(\w+):(\d+)>/.exec(e), 244 | parse: ([ , animated, name, id ]) => ({ 245 | type: 'customEmoji', 246 | id, 247 | name, 248 | animated: animated === 'a' 249 | }), 250 | html: ({ id, name, animated }) => SimpleMarkdown.htmlTag('img', null, { 251 | class: 'emoji', 252 | src: `https://cdn.discordapp.com/emojis/${id}.${animated ? 'gif' : 'png'}`, 253 | alt: `:${name}:`, 254 | is: 'message-emoji' 255 | }, false) 256 | }, 257 | s: { 258 | order: SimpleMarkdown.defaultRules.u.order, 259 | match: SimpleMarkdown.inlineRegex(/^~~([\s\S]+?)~~(?!_)/), 260 | parse: SimpleMarkdown.defaultRules.u.parse, 261 | html: (node, output, state) => SimpleMarkdown.htmlTag('s', output(node.content, state)) 262 | }, 263 | spoiler: { 264 | order: SimpleMarkdown.defaultRules.text.order, 265 | match: e => /^\|\|([\s\S]+?)\|\|/.exec(e), 266 | parse: ([ , content ], parse, state) => ({ content: parse(content, state) }), 267 | html: (node, output, state) => SimpleMarkdown.htmlTag('span', 268 | SimpleMarkdown.htmlTag('span', output(node.content, state)), { 269 | class: 'spoiler', 270 | is: 'message-spoiler' 271 | }) 272 | } 273 | } 274 | this.limitReached = Symbol('ast.limit') 275 | } 276 | 277 | parse (markdown, entities, allowInlineLinks, noMentionPrefix) { 278 | if (!markdown) return '' 279 | const parser = SimpleMarkdown.parserFor(this.defaultRules) 280 | const htmlOutput = SimpleMarkdown.htmlFor(SimpleMarkdown.ruleOutput(this.defaultRules, 'html')) 281 | let tree = parser(markdown, { 282 | entities, 283 | allowInlineLinks, 284 | noMentionPrefix, 285 | inline: true 286 | }) 287 | tree = this._flattenAst(tree) 288 | tree = this._constrainAst(tree) 289 | return htmlOutput(tree) 290 | } 291 | 292 | twemoji (raw) { 293 | return twemoji.parse(raw, { 294 | callback: function (icon, options) { 295 | switch (icon) { 296 | case 'a9': // © copyright 297 | case 'ae': // ® registered trademark 298 | case '2122': // ™ trademark 299 | return false 300 | case '1f52b': // gun 301 | return 'https://discord.com/assets/3071dbc60204c84ca0cf423b8b08a204.svg' 302 | case '1f440': // eyes 303 | return 'https://discord.com/assets/ccf4c733929efd9762ab02cd65175377.svg' 304 | } 305 | return ''.concat(options.base, options.size, '/', icon, options.ext) 306 | } 307 | }) 308 | } 309 | 310 | int2rgba (int, a = 1) { 311 | return `rgba(${int >> 16 & 255}, ${int >> 8 & 255}, ${255 & int}, ${a})` 312 | } 313 | 314 | _flattenAst (ast, parentAst = null) { 315 | // Walk the AST. 316 | if (Array.isArray(ast)) { 317 | const astLength = ast.length 318 | for (let i = 0; i < astLength; i++) { 319 | ast[i] = this._flattenAst(ast[i], parentAst) 320 | } 321 | return ast 322 | } 323 | 324 | // And more walking... 325 | if (ast.content != null) { 326 | ast.content = this._flattenAst(ast.content, ast) 327 | } 328 | 329 | // Flatten the AST if the parent is the same as the current node type, we can just consume the content. 330 | if (parentAst != null && ast.type === parentAst.type) { 331 | return ast.content 332 | } 333 | 334 | return ast 335 | } 336 | 337 | _constrainAst (ast, state = { limit: 200 }) { 338 | if (ast.type !== 'text') { 339 | state.limit -= 1 340 | if (state.limit <= 0) { 341 | return this.limitReached 342 | } 343 | } 344 | 345 | if (Array.isArray(ast)) { 346 | const astLength = ast.length 347 | for (let i = 0; i < astLength; i++) { 348 | const newNode = this._constrainAst(ast[i], state) 349 | if (newNode === this.limitReached) { 350 | ast.length = i 351 | break 352 | } 353 | ast[i] = newNode 354 | } 355 | } 356 | 357 | return ast 358 | } 359 | 360 | _parseLink ([ , link ]) { 361 | const target = this._depunycodeLink(link) 362 | 363 | return { 364 | type: 'link', 365 | content: [ 366 | { 367 | type: 'text', 368 | content: target 369 | } 370 | ], 371 | target 372 | } 373 | } 374 | 375 | _depunycodeLink (target) { 376 | try { 377 | const urlObject = new URL(target) 378 | urlObject.hostname = punycode.toASCII(urlObject.hostname || '') 379 | return url.format(urlObject) 380 | } catch (e) { 381 | return target 382 | } 383 | } 384 | } 385 | 386 | module.exports = new Markdown() 387 | -------------------------------------------------------------------------------- /src/formatter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2020 Cynthia K. Rey 3 | * Licensed under the Open Software License version 3.0 4 | */ 5 | 6 | const fit = require('./commons/fit') 7 | 8 | const SystemProcessed = Symbol('formatter.system') 9 | 10 | module.exports = class Formatter { 11 | constructor (payload) { 12 | this.payload = payload 13 | } 14 | 15 | async format () { 16 | if (!this._validate()) { 17 | return null 18 | } 19 | 20 | for (const user of Object.values(this.payload.entities.users)) { 21 | if (!user.avatar) { 22 | const discriminator = parseInt(user.discriminator) 23 | user.avatar = `https://cdn.discordapp.com/embed/avatars/${discriminator % 4}.png` 24 | } 25 | } 26 | await this._formatAttachments() 27 | this._mergeEmbeds() 28 | this._formatEmbeds() 29 | await this._formatMessages() 30 | return this.payload 31 | } 32 | 33 | async _formatAttachments () { 34 | for (const i1 in this.payload.messages) { 35 | // noinspection JSUnfilteredForInLoop 36 | for (const i2 in this.payload.messages[i1].attachments) { 37 | // noinspection JSUnfilteredForInLoop 38 | const attachment = this.payload.messages[i1].attachments[i2] 39 | if (attachment.width && attachment.height) { 40 | const size = fit(attachment.width, attachment.height, 400, 300) 41 | attachment.displayMaxWidth = `${size.width}px` 42 | attachment.displayMaxHeight = `${size.height}px` 43 | } 44 | 45 | attachment.formattedBytes = this._formatBytes(attachment.size) 46 | attachment.iconHash = this._computeIconHash(attachment.filename) 47 | } 48 | } 49 | } 50 | 51 | _mergeEmbeds () { 52 | for (const i1 in this.payload.messages) { 53 | // noinspection JSUnfilteredForInLoop 54 | if (this.payload.messages[i1].embeds) { 55 | // noinspection JSUnfilteredForInLoop 56 | const msg = this.payload.messages[i1] 57 | const { embeds } = msg 58 | msg.embeds = [] 59 | embeds.forEach(embed => { 60 | if (embed.url && embed.image) { 61 | const match = msg.embeds.find(e => e.url === embed.url) 62 | if (match) { 63 | if (!match.images) { 64 | match.images = [] 65 | if (match.image) match.images.push(match.image) 66 | } 67 | if (embed.image) match.images.push(embed.image) 68 | return 69 | } 70 | } 71 | msg.embeds.push(embed) 72 | }) 73 | } 74 | } 75 | } 76 | 77 | _formatEmbeds () { 78 | for (const i1 in this.payload.messages) { 79 | // noinspection JSUnfilteredForInLoop 80 | for (const i2 in this.payload.messages[i1].embeds) { 81 | // noinspection JSUnfilteredForInLoop 82 | const embed = this.payload.messages[i1].embeds[i2] 83 | 84 | // Group images 85 | if (embed.images) { 86 | embed.grouppedImages = [ [], [] ] 87 | embed.images.forEach((img, i) => embed.grouppedImages[embed.images.length - i <= 2 ? 1 : 0].push(img)) 88 | } 89 | 90 | // Group fields 91 | if (embed.fields) { 92 | let cursor = -1 93 | const limit = embed.thumbnail ? 2 : 3 94 | embed.grouppedFields = [] 95 | embed.fields.forEach(field => { 96 | const lastField = cursor !== -1 ? [ ...embed.grouppedFields[cursor] ].reverse()[0] : null 97 | if (!lastField || !lastField.inline || !field.inline || embed.grouppedFields[cursor].length === limit) { 98 | embed.grouppedFields.push([]) 99 | cursor++ 100 | } 101 | embed.grouppedFields[cursor].push(field) 102 | }) 103 | } 104 | 105 | // Compute display width 106 | embed.displayMaxWidth = '520px' 107 | const media = embed.image || embed.video 108 | if (media) { 109 | const size = fit(media.width, media.height, 400, 300) 110 | embed.displayMaxWidth = `${size.width + 32}px` 111 | embed.displayMaxHeight = `${size.height}px` 112 | } 113 | if (embed.image) { 114 | const size = fit(embed.image.width, embed.image.height, 400, 300) 115 | embed.image.displayMaxWidth = `${size.width}px` 116 | embed.image.displayMaxHeight = `${size.height}px` 117 | } 118 | if (embed.type === 'image' && embed.thumbnail) { 119 | const size = fit(embed.thumbnail.width, embed.thumbnail.height, 400, 300) 120 | embed.thumbnail.displayMaxWidth = `${size.width}px` 121 | embed.thumbnail.displayMaxHeight = `${size.height}px` 122 | } 123 | if (embed.video) { 124 | const size = fit(embed.video.width, embed.video.height, 400, 300) 125 | embed.video.displayMaxWidth = `${size.width}px` 126 | embed.video.displayMaxHeight = `${size.height}px` 127 | } 128 | } 129 | } 130 | } 131 | 132 | async _formatMessages () { 133 | let cursor = -1 134 | this.payload.grouppedMessages = [] 135 | for (const msg of this.payload.messages) { 136 | if (msg.content) { 137 | await this._parseInvites(msg) 138 | } 139 | if (msg.type && msg.type !== 0) { 140 | msg.content = this._getSystemMessageText(msg) 141 | } 142 | const lastMessage = cursor !== -1 ? [ ...this.payload.grouppedMessages[cursor] ].reverse()[0] : null 143 | if (!lastMessage || msg.deleted || lastMessage.deleted || (!((lastMessage.type || 0) !== 0 && (msg.type || 0) !== 0) && ( 144 | !((lastMessage.type || 0) === 0 && (msg.type || 0) === 0) || 145 | msg.author !== lastMessage.author || msg.time - lastMessage.time > 420000 146 | ))) { 147 | this.payload.grouppedMessages.push([]) 148 | cursor++ 149 | } 150 | this.payload.grouppedMessages[cursor].push(msg) 151 | } 152 | this.payload.grouppedMessages = this.payload.grouppedMessages.filter(a => a.length !== 0) 153 | } 154 | 155 | async _parseInvites (msg) { 156 | msg.invites = [] 157 | const regex = /(?: |^)(?:https?:\/\/)?discord\.gg\/([a-zA-Z0-9-]+)/g 158 | for (const match of msg.content.matchAll(regex)) { 159 | msg.invites.push(match[1]) 160 | } 161 | } 162 | 163 | _validate () { 164 | // Root structure 165 | if (typeof this.payload.entities !== 'object') return false 166 | if (typeof this.payload.messages !== 'object') return false 167 | if (!Array.isArray(this.payload.messages)) return false 168 | if (typeof this.payload.channel_name !== 'string') return false 169 | 170 | // Entities 171 | if (typeof this.payload.entities.users !== 'object') return false 172 | if (typeof this.payload.entities.channels !== 'object') return false 173 | if (typeof this.payload.entities.roles !== 'object') return false 174 | 175 | // Entities.Users 176 | for (const user of Object.values(this.payload.entities.users)) { 177 | if (typeof user.username !== 'string') return false 178 | if (typeof user.discriminator !== 'string') return false 179 | if (user.badge && typeof user.badge !== 'string') return false 180 | } 181 | 182 | // Entities.Channels 183 | for (const channel of Object.values(this.payload.entities.channels)) { 184 | if (typeof channel.name !== 'string') return false 185 | } 186 | 187 | // Entities.Roles 188 | for (const role of Object.values(this.payload.entities.roles)) { 189 | if (typeof role.name !== 'string') return false 190 | if (role.color && typeof role.color !== 'number') return false 191 | } 192 | 193 | // Messages 194 | for (const message of this.payload.messages) { 195 | if (typeof message.id !== 'string') return false 196 | if (message.type && (typeof message.type !== 'number' || message.type < 0 || message.type > 15)) return false 197 | if (typeof message.author !== 'string') return false 198 | if (typeof message.time !== 'number') return false 199 | if (typeof message.deleted !== 'undefined' && typeof message.deleted !== 'boolean') return false 200 | if (message.content && typeof message.content !== 'string') return false 201 | if (message.embeds && (typeof message.embeds !== 'object' || !Array.isArray(message.embeds))) return false 202 | if (message.attachments && (typeof message.attachments !== 'object' || !Array.isArray(message.attachments))) return false 203 | 204 | // For type 0, least 1 embed OR 1 attachment OR contents 205 | if ( 206 | (!message.type || message.type === 0) && 207 | !message.content && 208 | (!message.embeds || message.embeds.length === 0) && 209 | (!message.attachments || message.attachments.length === 0) 210 | ) { 211 | return false 212 | } 213 | 214 | // Messages.Embeds 215 | if (message.embeds) { 216 | for (const embed of message.embeds) { 217 | // Messages.Embeds.Timestamp 218 | if (embed.timestamp && typeof embed.timestamp !== 'string') return false 219 | 220 | // Messages.Embeds.Provider 221 | if (embed.provider && typeof embed.provider !== 'object') return false 222 | if (embed.provider) { 223 | if (embed.provider.name && typeof embed.provider.name !== 'string') return false 224 | if (embed.provider.url && typeof embed.provider.url !== 'string') return false 225 | } 226 | 227 | // Messages.Embeds.Author 228 | if (embed.author && typeof embed.author !== 'object') return false 229 | if (embed.author) { 230 | if (embed.author.name && typeof embed.author.name !== 'string') return false 231 | if (embed.author.url && typeof embed.author.url !== 'string') return false 232 | if (embed.author.icon_url && typeof embed.author.icon_url !== 'string') return false 233 | if (embed.author.icon_proxy_url && typeof embed.author.icon_proxy_url !== 'string') return false 234 | } 235 | 236 | // Messages.Embeds.Description 237 | if (embed.description && typeof embed.description !== 'string') return false 238 | 239 | // Messages.Embeds.Fields 240 | if (embed.fields && (typeof embed.fields !== 'object' || !Array.isArray(embed.fields))) return false 241 | if (embed.fields) { 242 | for (const field of embed.fields) { 243 | if (typeof field.name !== 'string') return false 244 | if (typeof field.value !== 'string') return false 245 | if (![ 'undefined', 'boolean' ].includes(typeof field.inline)) return false 246 | } 247 | } 248 | 249 | // Messages.Embeds.Thumbnail 250 | // Messages.Embeds.Image 251 | [ 'thumbnail', 'image' ].forEach(field => { 252 | if (embed[field] && typeof embed[field] !== 'object') return false 253 | if (embed[field]) { 254 | if (embed[field].url && typeof embed[field].url !== 'string') return false 255 | if (embed[field].proxy_url && typeof embed[field].proxy_url !== 'string') return false 256 | if (embed[field].width && typeof embed[field].width !== 'number') return false 257 | if (embed[field].height && typeof embed[field].height !== 'number') return false 258 | } 259 | }) 260 | 261 | // Messages.Embeds.Video 262 | if (embed.video && typeof embed.video !== 'object') return false 263 | if (embed.video) { 264 | if (embed.video.url && typeof embed.video.url !== 'string') return false 265 | if (embed.video.width && typeof embed.video.width !== 'number') return false 266 | if (embed.video.height && typeof embed.video.height !== 'number') return false 267 | } 268 | 269 | // Messages.Embeds.Url 270 | if (embed.url && typeof embed.url !== 'string') return false 271 | 272 | // Messages.Embeds.Footer 273 | if (embed.footer && typeof embed.footer !== 'object') return false 274 | if (embed.footer) { 275 | if (embed.footer.text && typeof embed.footer.text !== 'string') return false 276 | if (embed.footer.icon_url && typeof embed.footer.icon_url !== 'string') return false 277 | if (embed.footer.icon_proxy_url && typeof embed.footer.icon_proxy_url !== 'string') return false 278 | } 279 | } 280 | } 281 | 282 | // Messages.Attachments 283 | if (message.attachments && (typeof message.attachments !== 'object' || !Array.isArray(message.attachments))) return false 284 | if (message.attachments) { 285 | for (const attachment of message.attachments) { 286 | if (typeof attachment.filename !== 'string') return false 287 | if (typeof attachment.size !== 'number') return false 288 | if (typeof attachment.url !== 'string') return false 289 | if (typeof attachment.proxy_url !== 'string') return false 290 | if (attachment.width && typeof attachment.width !== 'number') return false 291 | if (attachment.height && typeof attachment.height !== 'number') return false 292 | } 293 | } 294 | } 295 | return true 296 | } 297 | 298 | _getSystemMessageText (msg) { 299 | if (msg[SystemProcessed]) return msg.content 300 | msg[SystemProcessed] = true 301 | 302 | switch (msg.type) { 303 | case 1: // Recipient add 304 | return `<@${msg.author}> added someone.` 305 | case 2: // Recipient removal 306 | return `<@${msg.author}> removed someone.` 307 | case 3: // Call 308 | return `<@${msg.author}> started a call.` 309 | case 4: // Channel name change 310 | return `<@${msg.author}> changed the channel name: ${msg.content}` 311 | case 5: // Channel icon change 312 | return `<@${msg.author}> changed the channel icon.` 313 | case 6: // Message pinned 314 | return `<@${msg.author}> pinned a message to this channel.` 315 | case 7: // Welcome message 316 | return this._computeWelcomeMessage(msg) 317 | case 8: // Nitro boost 318 | if (msg.content) { 319 | return `<@${msg.author}> just boosted the server ${msg.content} times!` 320 | } 321 | return `<@${msg.author}> just boosted the server!` 322 | case 9: // Nitro boost (lvl up) 323 | case 10: 324 | case 11: 325 | if (msg.content) { 326 | return `<@${msg.author}> just boosted the server ${msg.content} times! This server has achieved **Level ${msg.type - 8}!**` 327 | } 328 | return `<@${msg.author}> just boosted the server! This server has achieved **Level ${msg.type - 8}!**` 329 | case 12: // Channel following 330 | return `<@${msg.author}> has added ${msg.content} to this channel` 331 | case 14: // Server Discovery bad 332 | return 'This server has been removed from Server Discovery because it no longer passes all the requirements. Check Server Settings for more details.' 333 | case 15: // Server Discovery good 334 | return 'This server is eligible for Server Discovery again and has been automatically relisted!' 335 | } 336 | } 337 | 338 | _computeWelcomeMessage (msg) { 339 | const messages = [ 340 | '<@%user%> joined the party.', // -5956206959001600000 341 | '<@%user%> is here.', // -5956206958997405696 342 | 'Welcome, <@%user%>. We hope you brought pizza.', // -5956206958993211392 343 | 'A wild <@%user%> appeared.', // -5956206958989017088 344 | '<@%user%> just landed.', // -5956206958984822784 345 | '<@%user%> just slid into the server.', // -5956206958980628480 346 | '<@%user%> just showed up!', // -5956206958976434176 347 | 'Welcome <@%user%>. Say hi!', // -5956206958972239872 348 | '<@%user%> hopped into the server.', // -5956206958968045568 349 | 'Everyone welcome <@%user%>!', // -5956206958963851264 350 | 'Glad you\'re here, <@%user%>.', // -5956206958959656960 351 | 'Good to see you, <@%user%>.', // -5956206958955462656 352 | 'Yay you made it, <@%user%>!' // -5956206958951268352 353 | ] 354 | 355 | // eslint-disable-next-line no-undef 356 | const date = Number((BigInt(msg.id) >> 22n) + 1420070400000n) 357 | return messages[~~(date % messages.length)].replace(/%user%/g, msg.author) 358 | } 359 | 360 | _formatBytes (bytes) { 361 | if (bytes === 0) return '0 B' 362 | const k = 1024 363 | const sizes = [ 'B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB' ] 364 | const i = Math.floor(Math.log(bytes) / Math.log(k)) 365 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}` 366 | } 367 | 368 | _computeIconHash (filename) { 369 | if (/\.pdf$/.test(filename)) { 370 | return 'f167b4196f02faf2dc2e7eb266a24275' 371 | } 372 | if (/\.ae/.test(filename)) { 373 | return '982bd8aedd89b0607f492d1175b3b3a5' 374 | } 375 | if (/\.sketch$/.test(filename)) { 376 | return 'f812168e543235a62b9f6deb2b094948' 377 | } 378 | if (/\.ai$/.test(filename)) { 379 | return '03ad68e1f4d47f2671d629cfeac048ef' 380 | } 381 | if (/\.(?:rar|zip|7z|tar|tar\.gz)$/.test(filename)) { 382 | return '73d212e3701483c36a4660b28ac15b62' 383 | } 384 | if (/\.(?:c\+\+|cpp|cc|c|h|hpp|mm|m|json|js|rb|rake|py|asm|fs|pyc|dtd|cgi|bat|rss|java|graphml|idb|lua|o|gml|prl|sls|conf|cmake|make|sln|vbe|cxx|wbf|vbs|r|wml|php|bash|applescript|fcgi|yaml|ex|exs|sh|ml|actionscript)$/.test(filename)) { 385 | return '481aa700fab464f2332ca9b5f4eb6ba4' 386 | } 387 | if (/\.(?:txt|rtf|doc|docx|md|pages|ppt|pptx|pptm|key|log)$/.test(filename)) { 388 | return '85f7a4063578f6e0e2c73f60bca0fcce' 389 | } 390 | if (/\.(?:xls|xlsx|numbers|csv)$/.test(filename)) { 391 | return '85f7a4063578f6e0e2c73f60bca0fcce' 392 | } 393 | if (/\.(?:html|xhtml|htm|js|xml|xls|xsd|css|styl)$/.test(filename)) { 394 | return 'a11e895b46cde503a094dd31641060a6' 395 | } 396 | if (/\.(?:mp3|ogg|wav|flac)$/.test(filename)) { 397 | return '5b0da31dc2b00717c1e35fb1f84f9b9b' 398 | } 399 | return '985ea67d2edab4424c62009886f12e44' 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /example.json: -------------------------------------------------------------------------------- 1 | { 2 | "entities": { 3 | "users": { 4 | "173237945149423619": { 5 | "avatar": "https://avatars.githubusercontent.com/u/29491781?v=4", 6 | "username": "Kanin", 7 | "discriminator": "0001", 8 | "badge": null 9 | }, 10 | "94762492923748352": { 11 | "avatar": "https://avatars.githubusercontent.com/u/9999055?v=4", 12 | "username": "Cynthia 🌹", 13 | "discriminator": "1337", 14 | "badge": null 15 | }, 16 | "337481187419226113": { 17 | "avatar": "https://cdn.discordapp.com/avatars/337481187419226113/047a2bc1a7c1c16dd70291b7d9802481.png", 18 | "username": "Naila", 19 | "discriminator": "1361", 20 | "badge": "bot" 21 | }, 22 | "webhook1": { 23 | "avatar": "https://avatars.githubusercontent.com/u/9999055?v=4", 24 | "username": "Cyyynthia the webhook", 25 | "discriminator": "0000", 26 | "badge": "bot" 27 | } 28 | }, 29 | "channels": { 30 | "645102034579750922": { 31 | "name": "python-testing" 32 | } 33 | }, 34 | "roles": { 35 | "645429085656580127": { 36 | "name": "Kanin's Bots", 37 | "color": 7516394 38 | } 39 | } 40 | }, 41 | "messages": [ 42 | { 43 | "id": "1", 44 | "author": "173237945149423619", 45 | "time": 1603375200000, 46 | "content": "Source: https://github.com/Naila/Discord-chat-replica" 47 | }, 48 | { 49 | "id": "1", 50 | "author": "173237945149423619", 51 | "time": 1603375200000, 52 | "content": "Welcome to the example chat archive! This is a tool that turns json into an HTML file that looks like this!" 53 | }, 54 | { 55 | "id": "1", 56 | "author": "173237945149423619", 57 | "time": 1603375200000, 58 | "content": "This was created by Cynthia, you can find her over at https://cynthia.dev/." 59 | }, 60 | { 61 | "id": "-5956206958976434176", 62 | "type": 7, 63 | "author": "94762492923748352", 64 | "time": 1603375200000 65 | }, 66 | { 67 | "id": "1", 68 | "author": "173237945149423619", 69 | "time": 1603375200000, 70 | "content": "Yeah that gal.. anyways lets get into what this is capable of, starting with markdown. Currently everything supported by Discord is supported here, such as:" 71 | }, 72 | { 73 | "id": "1", 74 | "author": "173237945149423619", 75 | "time": 1603375200000, 76 | "content": "*Italics*: \\*Italics\\* or \\_Italics\\_\n**Bold**: \\*\\*Bold\\*\\*\n***Bold Italics***: \\*\\*\\*Bold Italics\\*\\*\\*\n__Underline__: \\_\\_Underline\\_\\_\n~~Strikethrough~~: \\~~Strikethrough\\~~\n||Spoilers||: \\||Spoilers\\||\n> Quotes\n\\> Quotes\n> ||__~~***All of the above***~~__||:\n\\> \\||\\_\\_\\~~\\*\\*\\*All of the above\\*\\*\\*\\~~\\_\\_\\||\n>>> Multi\nLine\nQuotes" 77 | }, 78 | { 79 | "id": "1", 80 | "author": "173237945149423619", 81 | "time": 1603375200000, 82 | "content": "\\>>> Multi\nLine\nQuotes" 83 | }, 84 | { 85 | "id": "1", 86 | "author": "173237945149423619", 87 | "time": 1603375200000, 88 | "content": "Codeblocks:\n`Inline`\n```py\n>>> reader = you\n... cutie = a cute person\n... if reader == cutie:\n... print(\"A cutie is reading this\")\n\nA cutie is reading this\n```" 89 | }, 90 | { 91 | "id": "1", 92 | "author": "173237945149423619", 93 | "time": 1603375800000, 94 | "content": "Webhooks:" 95 | }, 96 | { 97 | "id": "1", 98 | "author": "webhook1", 99 | "time": 1603375800000, 100 | "content": "Did you know that webhooks can use [named links](https://youtube.com/watch?v=fC7oUOUEEi4)? p cool isn't it. Spoiler: ||nobody cares :^)||" 101 | }, 102 | { 103 | "id": "1", 104 | "author": "173237945149423619", 105 | "time": 1603375800000, 106 | "content": "Bots:" 107 | }, 108 | { 109 | "id": "-5956206958989017088", 110 | "type": 7, 111 | "author": "337481187419226113", 112 | "time": 1603375800000 113 | }, 114 | { 115 | "id": "1", 116 | "author": "337481187419226113", 117 | "time": 1603375800000, 118 | "content": "I'm a bot!" 119 | }, 120 | { 121 | "id": "1", 122 | "author": "173237945149423619", 123 | "time": 1603375800000, 124 | "content": "System messages:" 125 | }, 126 | { 127 | "id": "1", 128 | "type": 1, 129 | "author": "173237945149423619", 130 | "time": 1603375800000 131 | }, 132 | { 133 | "id": "1", 134 | "type": 2, 135 | "author": "173237945149423619", 136 | "time": 1603375800000 137 | }, 138 | { 139 | "id": "1", 140 | "type": 3, 141 | "author": "173237945149423619", 142 | "time": 1603375800000 143 | }, 144 | { 145 | "id": "1", 146 | "type": 4, 147 | "author": "173237945149423619", 148 | "time": 1603375800000, 149 | "content": "test-archive" 150 | }, 151 | { 152 | "id": "1", 153 | "type": 5, 154 | "author": "173237945149423619", 155 | "time": 1603375800000 156 | }, 157 | { 158 | "id": "1", 159 | "type": 6, 160 | "author": "173237945149423619", 161 | "time": 1603375800000 162 | }, 163 | { 164 | "id": "-5956206959001600000", 165 | "type": 7, 166 | "author": "173237945149423619", 167 | "time": 1603375800000 168 | }, 169 | { 170 | "id": "-5956206958997405696", 171 | "type": 7, 172 | "author": "173237945149423619", 173 | "time": 1603375800000 174 | }, 175 | { 176 | "id": "-5956206958993211392", 177 | "type": 7, 178 | "author": "173237945149423619", 179 | "time": 1603375800000 180 | }, 181 | { 182 | "id": "-5956206958989017088", 183 | "type": 7, 184 | "author": "173237945149423619", 185 | "time": 1603375800000 186 | }, 187 | { 188 | "id": "-5956206958984822784", 189 | "type": 7, 190 | "author": "173237945149423619", 191 | "time": 1603375800000 192 | }, 193 | { 194 | "id": "-5956206958980628480", 195 | "type": 7, 196 | "author": "173237945149423619", 197 | "time": 1603375800000 198 | }, 199 | { 200 | "id": "-5956206958976434176", 201 | "type": 7, 202 | "author": "173237945149423619", 203 | "time": 1603375800000 204 | }, 205 | { 206 | "id": "-5956206958972239872", 207 | "type": 7, 208 | "author": "173237945149423619", 209 | "time": 1603375800000 210 | }, 211 | { 212 | "id": "-5956206958968045568", 213 | "type": 7, 214 | "author": "173237945149423619", 215 | "time": 1603375800000 216 | }, 217 | { 218 | "id": "-5956206958963851264", 219 | "type": 7, 220 | "author": "173237945149423619", 221 | "time": 1603375800000 222 | }, 223 | { 224 | "id": "-5956206958959656960", 225 | "type": 7, 226 | "author": "173237945149423619", 227 | "time": 1603375800000 228 | }, 229 | { 230 | "id": "-5956206958955462656", 231 | "type": 7, 232 | "author": "173237945149423619", 233 | "time": 1603375800000 234 | }, 235 | { 236 | "id": "-5956206958951268352", 237 | "type": 7, 238 | "author": "173237945149423619", 239 | "time": 1603375800000 240 | }, 241 | { 242 | "id": "1", 243 | "type": 8, 244 | "author": "173237945149423619", 245 | "time": 1603375800000 246 | }, 247 | { 248 | "id": "1", 249 | "type": 9, 250 | "author": "173237945149423619", 251 | "time": 1603375800000 252 | }, 253 | { 254 | "id": "1", 255 | "type": 10, 256 | "author": "173237945149423619", 257 | "time": 1603375800000, 258 | "content": "13" 259 | }, 260 | { 261 | "id": "1", 262 | "type": 11, 263 | "author": "173237945149423619", 264 | "time": 1603375800000, 265 | "content": "15" 266 | }, 267 | { 268 | "id": "1", 269 | "type": 8, 270 | "author": "173237945149423619", 271 | "time": 1603375800000, 272 | "content": "150" 273 | }, 274 | { 275 | "id": "1", 276 | "type": 12, 277 | "author": "173237945149423619", 278 | "time": 1603375800000, 279 | "content": "<@94762492923748352>" 280 | }, 281 | { 282 | "id": "1", 283 | "type": 14, 284 | "author": "173237945149423619", 285 | "time": 1603375800000 286 | }, 287 | { 288 | "id": "1", 289 | "type": 15, 290 | "author": "173237945149423619", 291 | "time": 1603375800000 292 | }, 293 | { 294 | "id": "1", 295 | "author": "173237945149423619", 296 | "time": 1603375800000, 297 | "content": "Even the option to display deleted messages:" 298 | }, 299 | { 300 | "id": "1", 301 | "author": "94762492923748352", 302 | "time": 1603375800000, 303 | "deleted": true, 304 | "content": "Kanin is a dummy" 305 | }, 306 | { 307 | "id": "1", 308 | "author": "173237945149423619", 309 | "time": 1603375800000, 310 | "content": "Spotify embeds:" 311 | }, 312 | { 313 | "id": "1", 314 | "author": "173237945149423619", 315 | "time": 1603375800000, 316 | "content": "https://open.spotify.com/track/3tbwKbrgDNjT4JvM4SVAtz?si=QaVABNaxTUq0V-EXNrxOLw", 317 | "embeds": [ 318 | { 319 | "thumbnail": { 320 | "url": "https://i.scdn.co/image/ab67616d0000b273d0e2884b028722f45407a154", 321 | "proxy_url": "https://images-ext-2.discordapp.net/external/BTLM4KFKE4C2bUD9yLjdLM9VR6f_VwXMsBxRTozoERg/https/i.scdn.co/image/ab67616d0000b273d0e2884b028722f45407a154", 322 | "width": 640, 323 | "height": 640 324 | }, 325 | "provider": { 326 | "name": "Spotify", 327 | "url": null 328 | }, 329 | "type": "link", 330 | "description": "The Living Tombstone · Song · 2017", 331 | "url": "https://open.spotify.com/track/3tbwKbrgDNjT4JvM4SVAtz?si=QaVABNaxTUq0V-EXNrxOLw", 332 | "title": "No Mercy" 333 | } 334 | ] 335 | }, 336 | { 337 | "id": "1", 338 | "author": "173237945149423619", 339 | "time": 1603375800000, 340 | "content": "https://open.spotify.com/playlist/5xeVEVOzmyfoVrR32ITZ9X?si=Ku5zt6IQQeuNb6glW0p_Gg", 341 | "embeds": [ 342 | { 343 | "thumbnail": { 344 | "url": "https://i.scdn.co/image/ab67616d0000b273d0e2884b028722f45407a154", 345 | "proxy_url": "https://images-ext-2.discordapp.net/external/BTLM4KFKE4C2bUD9yLjdLM9VR6f_VwXMsBxRTozoERg/https/i.scdn.co/image/ab67616d0000b273d0e2884b028722f45407a154", 346 | "width": 640, 347 | "height": 640 348 | }, 349 | "provider": { 350 | "name": "Spotify", 351 | "url": null 352 | }, 353 | "type": "link", 354 | "description": "A playlist featuring The Living Tombstone and Toby Fox", 355 | "url": "https://open.spotify.com/playlist/5xeVEVOzmyfoVrR32ITZ9X?si=Ku5zt6IQQeuNb6glW0p_Gg", 356 | "title": "You should have picked mercy, a playlist by Daniel on Spotify" 357 | } 358 | ] 359 | }, 360 | { 361 | "id": "1", 362 | "author": "173237945149423619", 363 | "time": 1603376400000, 364 | "content": "Embeds:" 365 | }, 366 | { 367 | "id": "1", 368 | "author": "173237945149423619", 369 | "time": 1603376400000, 370 | "embeds": [ 371 | { 372 | "title": "title ~~(did you know you can have markdown here too?)~~", 373 | "description": "this supports [named links](https://discordapp.com) on top of the previously shown subset of markdown. ```\nyes, even code blocks```", 374 | "url": "https://discordapp.com", 375 | "color": 860633, 376 | "timestamp": "2020-10-23T13:59:44.266Z", 377 | "footer": { 378 | "icon_url": "https://cdn.discordapp.com/embed/avatars/0.png", 379 | "text": "footer text" 380 | }, 381 | "thumbnail": { 382 | "url": "https://cdn.discordapp.com/embed/avatars/0.png" 383 | }, 384 | "image": { 385 | "url": "https://cdn.discordapp.com/embed/avatars/0.png" 386 | }, 387 | "author": { 388 | "name": "author name", 389 | "url": "https://discordapp.com", 390 | "icon_url": "https://cdn.discordapp.com/embed/avatars/0.png" 391 | }, 392 | "fields": [ 393 | { 394 | "name": "🤔", 395 | "value": "some of these properties have certain limits..." 396 | }, 397 | { 398 | "name": "😱", 399 | "value": "try exceeding some of them!" 400 | }, 401 | { 402 | "name": "🙄", 403 | "value": "an informative error should show up, and this view will remain as-is until all issues are fixed" 404 | }, 405 | { 406 | "name": "<:thonkang:219069250692841473>", 407 | "value": "these last two", 408 | "inline": true 409 | }, 410 | { 411 | "name": "<:thonkang:219069250692841473>", 412 | "value": "are inline fields", 413 | "inline": true 414 | } 415 | ] 416 | } 417 | ] 418 | }, 419 | { 420 | "id": "1", 421 | "author": "173237945149423619", 422 | "time": 1603376400000, 423 | "content": "https://twitter.com/discord/status/1234924931328012288", 424 | "embeds": [ 425 | { 426 | "footer": { 427 | "text": "Twitter", 428 | "icon_url": "https://abs.twimg.com/icons/apple-touch-icon-192x192.png", 429 | "proxy_icon_url": "https://images-ext-1.discordapp.net/external/bXJWV2Y_F3XSra_kEqIYXAAsI3m1meckfLhYuWzxIfI/https/abs.twimg.com/icons/apple-touch-icon-192x192.png" 430 | }, 431 | "image": { 432 | "url": "https://pbs.twimg.com/media/ESNU_9MUEAAQs9Q.jpg:large", 433 | "proxy_url": "https://images-ext-1.discordapp.net/external/KFg4sHycrkWRsrjt95-xkAZPBdPMxRgagMmMK-BDaeo/https/pbs.twimg.com/media/ESNU_9MUEAAQs9Q.jpg%3Alarge", 434 | "width": 2048, 435 | "height": 1366 436 | }, 437 | "author": { 438 | "name": "Discord (@discord)", 439 | "url": "https://twitter.com/discord", 440 | "icon_url": "https://pbs.twimg.com/profile_images/1311725514814521347/L2UDOARa_bigger.jpg", 441 | "proxy_icon_url": "https://images-ext-2.discordapp.net/external/SBIn1aq03YNwvKEOAZhc2hGDMol6qcxkFXsB-PF8E1M/https/pbs.twimg.com/profile_images/1311725514814521347/L2UDOARa_bigger.jpg" 442 | }, 443 | "fields": [ 444 | { 445 | "name": "Retweets", 446 | "value": "80262", 447 | "inline": true 448 | }, 449 | { 450 | "name": "Likes", 451 | "value": "59217", 452 | "inline": true 453 | } 454 | ], 455 | "color": 1942002, 456 | "type": "rich", 457 | "description": "We wanted to do something special for those who couldn\u2019t attend PAX East with us. \n\nRT + follow us by 3/8 for a chance to win everything pictured (yes, gaming chair IS included)", 458 | "url": "https://twitter.com/discord/status/1234924931328012288" 459 | } 460 | ] 461 | }, 462 | { 463 | "id": "1", 464 | "author": "173237945149423619", 465 | "time": 1603376400000, 466 | "content": "https://twitter.com/SpaceX/status/1236172031919448067", 467 | "embeds": [ 468 | { 469 | "footer": { 470 | "text": "Twitter", 471 | "icon_url": "https://abs.twimg.com/icons/apple-touch-icon-192x192.png", 472 | "proxy_icon_url": "https://images-ext-1.discordapp.net/external/bXJWV2Y_F3XSra_kEqIYXAAsI3m1meckfLhYuWzxIfI/https/abs.twimg.com/icons/apple-touch-icon-192x192.png" 473 | }, 474 | "image": { 475 | "url": "https://pbs.twimg.com/media/ESfDyyvUMAALqKA.jpg:large", 476 | "proxy_url": "https://images-ext-2.discordapp.net/external/Sr4lbVDZzceMRo1E9f6-q5hpypkr7ksiOyUivMAJ6_4/https/pbs.twimg.com/media/ESfDyyvUMAALqKA.jpg%3Alarge", 477 | "width": 2048, 478 | "height": 1365 479 | }, 480 | "author": { 481 | "name": "SpaceX (@SpaceX)", 482 | "url": "https://twitter.com/SpaceX", 483 | "icon_url": "https://pbs.twimg.com/profile_images/1082744382585856001/rH_k3PtQ_bigger.jpg", 484 | "proxy_icon_url": "https://images-ext-2.discordapp.net/external/YWj4_0iHgQjUu-YFui4-4oQj5PKkm5D5zuSYlajgnNg/https/pbs.twimg.com/profile_images/1082744382585856001/rH_k3PtQ_bigger.jpg" 485 | }, 486 | "fields": [ 487 | { 488 | "name": "Retweets", 489 | "value": "2598", 490 | "inline": true 491 | }, 492 | { 493 | "name": "Likes", 494 | "value": "17965", 495 | "inline": true 496 | } 497 | ], 498 | "color": 1942002, 499 | "type": "rich", 500 | "description": "Falcon 9 launches the final mission of the first version of Dragon", 501 | "url": "https://twitter.com/SpaceX/status/1236172031919448067" 502 | }, 503 | { 504 | "image": { 505 | "url": "https://pbs.twimg.com/media/ESfDyywUcAASwpl.jpg:large", 506 | "proxy_url": "https://images-ext-2.discordapp.net/external/Eev3nZHn0gFQmoV_BcoIaCTokqoOXSPIDjT-e4Tt7pY/https/pbs.twimg.com/media/ESfDyywUcAASwpl.jpg%3Alarge", 507 | "width": 2048, 508 | "height": 1365 509 | }, 510 | "type": "rich", 511 | "url": "https://twitter.com/SpaceX/status/1236172031919448067" 512 | }, 513 | { 514 | "image": { 515 | "url": "https://pbs.twimg.com/media/ESfD0T4VAAEpRIe.jpg:large", 516 | "proxy_url": "https://images-ext-1.discordapp.net/external/N9fvJXQA0DeBBITbLyHr3TiTUrP3F7UpQiA6Xey13sE/https/pbs.twimg.com/media/ESfD0T4VAAEpRIe.jpg%3Alarge", 517 | "width": 2048, 518 | "height": 1365 519 | }, 520 | "type": "rich", 521 | "url": "https://twitter.com/SpaceX/status/1236172031919448067" 522 | } 523 | ] 524 | }, 525 | { 526 | "id": "1", 527 | "author": "173237945149423619", 528 | "time": 1603376400000, 529 | "content": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 530 | "embeds": [ 531 | { 532 | "thumbnail": { 533 | "url": "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", 534 | "proxy_url": "https://images-ext-1.discordapp.net/external/l-AFI3CsQVpcpSDYFtsDvDKag46BJ-uaQ9BTcU2JPC8/https/i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg", 535 | "width": 1280, 536 | "height": 720 537 | }, 538 | "video": { 539 | "url": "https://www.youtube.com/embed/dQw4w9WgXcQ", 540 | "width": 1280, 541 | "height": 720 542 | }, 543 | "provider": { 544 | "name": "YouTube", 545 | "url": "https://www.youtube.com" 546 | }, 547 | "id": "1", 548 | "author": { 549 | "name": "RickAstleyVEVO", 550 | "url": "https://www.youtube.com/channel/UC38IQsAvIsxxjztdMZQtwHA" 551 | }, 552 | "color": 16711680, 553 | "type": "video", 554 | "description": "Rick Astley's official music video for \u201cNever Gonna Give You Up\u201d \nListen to Rick Astley: https://RickAstley.lnk.to/_listenYD\n\nSubscribe to the official Rick Astley YouTube channel: https://RickAstley.lnk.to/subscribeYD\n\nFollow Rick Astley:\nFacebook: https://RickAstley.lnk.to/f...", 555 | "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", 556 | "title": "Rick Astley - Never Gonna Give You Up (Video)" 557 | } 558 | ] 559 | }, 560 | { 561 | "id": "1", 562 | "author": "173237945149423619", 563 | "time": 1603377000000, 564 | "content": "Videos:" 565 | }, 566 | { 567 | "id": "1", 568 | "author": "173237945149423619", 569 | "time": 1603377000000, 570 | "attachments": [ 571 | { 572 | "id": 769236098068512838, 573 | "filename": "BabyShark.mp4", 574 | "size": 23447023, 575 | "height": 360, 576 | "width": 640, 577 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769236098068512838/BabyShark.mp4", 578 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769236098068512838/BabyShark.mp4" 579 | } 580 | ] 581 | }, 582 | { 583 | "id": "1", 584 | "author": "173237945149423619", 585 | "time": 1603377000000, 586 | "content": "https://cdn.discordapp.com/attachments/686027141946671186/769236098068512838/BabyShark.mp4", 587 | "embeds": [ 588 | { 589 | "video": { 590 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769236098068512838/BabyShark.mp4", 591 | "width": 640, 592 | "height": 360, 593 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769236098068512838/BabyShark.mp4" 594 | }, 595 | "type": "video", 596 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769236098068512838/BabyShark.mp4" 597 | } 598 | ] 599 | }, 600 | { 601 | "id": "1", 602 | "author": "173237945149423619", 603 | "time": 1603377600000, 604 | "content": "Files:" 605 | }, 606 | { 607 | "id": "1", 608 | "author": "173237945149423619", 609 | "time": 1603377600000, 610 | "attachments": [ 611 | { 612 | "id": 769235696107651122, 613 | "filename": "BabyShark.mp3", 614 | "size": 4042220, 615 | "height": null, 616 | "width": null, 617 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769235696107651122/BabyShark.mp3", 618 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769235696107651122/BabyShark.mp3" 619 | } 620 | ] 621 | }, 622 | { 623 | "id": "1", 624 | "author": "173237945149423619", 625 | "time": 1603377600000, 626 | "attachments": [ 627 | { 628 | "id": 769231274405134356, 629 | "filename": "empty.ae", 630 | "size": 0, 631 | "height": null, 632 | "width": null, 633 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231274405134356/empty.ae", 634 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231274405134356/empty.ae" 635 | } 636 | ] 637 | }, 638 | { 639 | "id": "1", 640 | "author": "173237945149423619", 641 | "time": 1603377600000, 642 | "attachments": [ 643 | { 644 | "id": 769231275839586304, 645 | "filename": "empty.ai", 646 | "size": 0, 647 | "height": null, 648 | "width": null, 649 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231275839586304/empty.ai", 650 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231275839586304/empty.ai" 651 | } 652 | ] 653 | }, 654 | { 655 | "id": "1", 656 | "author": "173237945149423619", 657 | "time": 1603377600000, 658 | "attachments": [ 659 | { 660 | "id": 769231277613907968, 661 | "filename": "empty.html", 662 | "size": 0, 663 | "height": null, 664 | "width": null, 665 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231277613907968/empty.html", 666 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231277613907968/empty.html" 667 | } 668 | ] 669 | }, 670 | { 671 | "id": "1", 672 | "author": "173237945149423619", 673 | "time": 1603377600000, 674 | "attachments": [ 675 | { 676 | "id": 769231280587407460, 677 | "filename": "empty.pdf", 678 | "size": 0, 679 | "height": null, 680 | "width": null, 681 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231280587407460/empty.pdf", 682 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231280587407460/empty.pdf" 683 | } 684 | ] 685 | }, 686 | { 687 | "id": "1", 688 | "author": "173237945149423619", 689 | "time": 1603377600000, 690 | "attachments": [ 691 | { 692 | "id": 769231280935141416, 693 | "filename": "empty.py", 694 | "size": 0, 695 | "height": null, 696 | "width": null, 697 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231280935141416/empty.py", 698 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231280935141416/empty.py" 699 | } 700 | ] 701 | }, 702 | { 703 | "id": "1", 704 | "author": "173237945149423619", 705 | "time": 1603377600000, 706 | "attachments": [ 707 | { 708 | "id": 769231282588090408, 709 | "filename": "empty.sketch", 710 | "size": 0, 711 | "height": null, 712 | "width": null, 713 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231282588090408/empty.sketch", 714 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231282588090408/empty.sketch" 715 | } 716 | ] 717 | }, 718 | { 719 | "id": "1", 720 | "author": "173237945149423619", 721 | "time": 1603377600000, 722 | "attachments": [ 723 | { 724 | "id": 769231283557367818, 725 | "filename": "empty.txt", 726 | "size": 0, 727 | "height": null, 728 | "width": null, 729 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231283557367818/empty.txt", 730 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231283557367818/empty.txt" 731 | } 732 | ] 733 | }, 734 | { 735 | "id": "1", 736 | "author": "173237945149423619", 737 | "time": 1603377600000, 738 | "attachments": [ 739 | { 740 | "id": 769231286996434946, 741 | "filename": "empty.zip", 742 | "size": 0, 743 | "height": null, 744 | "width": null, 745 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/769231286996434946/empty.zip", 746 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/769231286996434946/empty.zip" 747 | } 748 | ] 749 | }, 750 | { 751 | "id": "1", 752 | "author": "173237945149423619", 753 | "time": 1603378200000, 754 | "content": "Images/GIFs:" 755 | }, 756 | { 757 | "id": "1", 758 | "author": "173237945149423619", 759 | "time": 1603378200000, 760 | "attachments": [ 761 | { 762 | "id": 686033467573927966, 763 | "filename": "Naila.png", 764 | "size": 2411472, 765 | "height": 4464, 766 | "width": 2640, 767 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/686033467573927966/Naila.png", 768 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/686033467573927966/Naila.png" 769 | } 770 | ] 771 | }, 772 | { 773 | "id": "1", 774 | "author": "173237945149423619", 775 | "time": 1603378200000, 776 | "content": "https://cdn.discordapp.com/attachments/686027141946671186/686033467573927966/Naila.png", 777 | "embeds": [ 778 | { 779 | "thumbnail": { 780 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/686033467573927966/Naila.png", 781 | "proxy_url": "https://media.discordapp.net/attachments/686027141946671186/686033467573927966/Naila.png", 782 | "width": 2640, 783 | "height": 4464 784 | }, 785 | "type": "image", 786 | "url": "https://cdn.discordapp.com/attachments/686027141946671186/686033467573927966/Naila.png" 787 | } 788 | ] 789 | }, 790 | { 791 | "id": "1", 792 | "author": "173237945149423619", 793 | "time": 1603378200000, 794 | "attachments": [ 795 | { 796 | "id": 769200945455824906, 797 | "filename": "tenor_1.gif", 798 | "size": 528525, 799 | "height": 360, 800 | "width": 640, 801 | "url": "https://cdn.discordapp.com/attachments/645102034579750922/769200945455824906/tenor_1.gif", 802 | "proxy_url": "https://media.discordapp.net/attachments/645102034579750922/769200945455824906/tenor_1.gif" 803 | } 804 | ] 805 | }, 806 | { 807 | "id": "1", 808 | "author": "173237945149423619", 809 | "time": 1603378200000, 810 | "content": "https://tenor.com/view/illya-blink-anime-eyes-cute-gif-16059710", 811 | "embeds": [ 812 | { 813 | "thumbnail": { 814 | "url": "https://media.tenor.co/images/8b1afd9e853b9c5a7af6fbb0f8b661ce/tenor.png", 815 | "proxy_url": "https://images-ext-2.discordapp.net/external/jXg7OeyDcV_YDkrCpXNqNSpfOtWhY3UfXWzQiWZZrsA/https/media.tenor.co/images/8b1afd9e853b9c5a7af6fbb0f8b661ce/tenor.png", 816 | "width": 640, 817 | "height": 360 818 | }, 819 | "video": { 820 | "url": "https://media.tenor.co/videos/dfb2eb33b95df0c579bc966cb664aaec/mp4", 821 | "width": 640, 822 | "height": 360 823 | }, 824 | "provider": { 825 | "name": "Tenor", 826 | "url": "https://tenor.co" 827 | }, 828 | "type": "gifv", 829 | "url": "https://tenor.com/view/illya-blink-anime-eyes-cute-gif-16059710" 830 | } 831 | ] 832 | }, 833 | { 834 | "id": "1", 835 | "author": "173237945149423619", 836 | "time": 1603378800000, 837 | "content": "Emojis:" 838 | }, 839 | { 840 | "id": "1", 841 | "author": "173237945149423619", 842 | "time": 1603378800000, 843 | "content": "\uD83C\uDDEA \uD83C\uDDF2 \uD83C\uDDF4 \uD83C\uDDEF \uD83C\uDDEE \uD83C\uDDF8<:blank:645426904878284802>\uD83C\uDDE6 \uD83C\uDDF7 \uD83C\uDDEA<:blank:645426904878284802>\uD83C\uDDE8 \uD83C\uDDF4 \uD83C\uDDF4 \uD83C\uDDF1" 844 | }, 845 | { 846 | "id": "1", 847 | "author": "173237945149423619", 848 | "time": 1603378800000, 849 | "content": "<:awoo1:331095654678003722><:awoo2:331095654439059468><:awoo3:331095654611025921><:awoo4:331095655130857482><:awoo5:331095655068073985>\n<:awoo6:331095655080656899><:awoo7:331095655407812608><:awoo8:331095657270214656><:awoo9:331095657102180354><:awoo10:331095656607383552>\n<:awoo11:331095656368439296><:awoo12:331095657639051265><:awoo13:331095657853222912><:awoo14:331095657714548736><:awoo15:331095656297136130>\n<:awoo16:331095656422965248><:awoo17:331095657232465920><:awoo18:331095656921956362><:awoo19:331095657882320896><:awoo20:331095657500770305>\n<:awoo21:331095656854847489><:awoo22:331095657655959552><:awoo23:331095657311895553><:awoo24:331095657181872129><:awoo25:331095657102180352>" 850 | }, 851 | { 852 | "id": "1", 853 | "author": "173237945149423619", 854 | "time": 1603379400000, 855 | "content": "Invites:" 856 | }, 857 | { 858 | "id": "1", 859 | "author": "173237945149423619", 860 | "time": 1603379400000, 861 | "content": "discord.gg/2ZRKrzf discord.gg/discord-developers discord.gg/an-invalid-invite" 862 | }, 863 | { 864 | "id": "1", 865 | "author": "173237945149423619", 866 | "time": 1603380000000, 867 | "content": "Mentions:" 868 | }, 869 | { 870 | "id": "1", 871 | "author": "173237945149423619", 872 | "time": 1603380000000, 873 | "content": "<@!173237945149423619> <@&645429085656580127> <#645102034579750922>" 874 | } 875 | ], 876 | "channel_name": "example-archive" 877 | } 878 | --------------------------------------------------------------------------------