mirror of
https://git.femboyfinancial.jp/james/FemScoreboard.git
synced 2024-11-23 19:11:59 -08:00
Initial commit
This commit is contained in:
commit
68b94e0642
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
**/.env
|
||||||
|
**/*.js
|
||||||
|
**/*.js.map
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
24
README.md
Normal file
24
README.md
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# FemScoreboard
|
||||||
|
|
||||||
|
Web application/Discord bot which ranks users based on the reactions received on their messages.
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
First, set up the Discord bot, syncing the message history before starting the bot to watch for live reactions.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd discord
|
||||||
|
cp .env.example .env
|
||||||
|
vim .env # make appropriate changes
|
||||||
|
npm install
|
||||||
|
npm run sync
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
With the bot running, configure and start the web application from this directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
vim .env # make appropriate changes
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
3
discord/.env.example
Normal file
3
discord/.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
TOKEN="sadkfl;jasdkl;fj"
|
||||||
|
REACTIONS="💀,💯,😭"
|
||||||
|
GUILD="123456789012345678"
|
62
discord/bot.ts
Normal file
62
discord/bot.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* bot.ts
|
||||||
|
* Scans the chat for reactions and updates the leaderboard database.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client, Events, GatewayIntentBits, MessageReaction, PartialMessageReaction, Partials, User } from 'discord.js';
|
||||||
|
import { db, openDb, reactionEmojis, recordReaction } from './util';
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions],
|
||||||
|
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once(Events.ClientReady, async () => {
|
||||||
|
console.log('[bot] Ready.');
|
||||||
|
for (let i = 0; i < reactionEmojis.length; ++i)
|
||||||
|
console.log(`[bot] config: reaction_${i + 1} = ${reactionEmojis[i]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onMessageReactionChanged(reaction: MessageReaction | PartialMessageReaction, user: User)
|
||||||
|
{
|
||||||
|
// When a reaction is received, check if the structure is partial
|
||||||
|
if (reaction.partial) {
|
||||||
|
// If the message this reaction belongs to was removed, the fetching might result in an API error which should be handled
|
||||||
|
try {
|
||||||
|
await reaction.fetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bot] Something went wrong when fetching the reaction:', error);
|
||||||
|
// Return as `reaction.message.author` may be undefined/null
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reaction.message.partial) {
|
||||||
|
// If the message this reaction belongs to was removed, the fetching might result in an API error which should be handled
|
||||||
|
try {
|
||||||
|
await reaction.message.fetch();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bot] Something went wrong when fetching the message:', error);
|
||||||
|
// Return as `reaction.message.author` may be undefined/null
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now the message has been cached and is fully available
|
||||||
|
console.log(`[bot] ${reaction.message.author.id}'s message reaction count changed: ${reaction.emoji.name}x${reaction.count}`);
|
||||||
|
await recordReaction(<MessageReaction> reaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.on(Events.MessageReactionAdd, onMessageReactionChanged);
|
||||||
|
client.on(Events.MessageReactionRemove, onMessageReactionChanged);
|
||||||
|
|
||||||
|
async function startup() {
|
||||||
|
console.log("[db] Opening...");
|
||||||
|
await openDb();
|
||||||
|
console.log("[db] Migrating...");
|
||||||
|
await db.migrate();
|
||||||
|
console.log("[db] Ready.");
|
||||||
|
console.log("[bot] Logging in...");
|
||||||
|
await client.login(process.env.TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
startup();
|
BIN
discord/db.sqlite
Normal file
BIN
discord/db.sqlite
Normal file
Binary file not shown.
23
discord/migrations/001-messages-table.sql
Normal file
23
discord/migrations/001-messages-table.sql
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Up
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table messages
|
||||||
|
(
|
||||||
|
id integer
|
||||||
|
constraint messages_pk
|
||||||
|
primary key,
|
||||||
|
guild integer not null,
|
||||||
|
channel integer not null,
|
||||||
|
author text not null,
|
||||||
|
content text,
|
||||||
|
reaction_1_count integer not null default 0,
|
||||||
|
reaction_2_count integer not null default 0,
|
||||||
|
reaction_3_count integer not null default 0
|
||||||
|
);
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Down
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP TABLE messages;
|
20
discord/migrations/002-users-table.sql
Normal file
20
discord/migrations/002-users-table.sql
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Up
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
create table users
|
||||||
|
(
|
||||||
|
id text
|
||||||
|
constraint users_pk
|
||||||
|
primary key,
|
||||||
|
username text not null,
|
||||||
|
reaction_1_total integer not null default 0,
|
||||||
|
reaction_2_total integer not null default 0,
|
||||||
|
reaction_3_total integer not null default 0
|
||||||
|
);
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
-- Down
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
DROP TABLE users;
|
1386
discord/package-lock.json
generated
Normal file
1386
discord/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
discord/package.json
Normal file
17
discord/package.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "femscoreboardbot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"discord.js": "^14.13.0",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"sqlite": "^5.0.1",
|
||||||
|
"sqlite3": "^5.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx tsc && node bot.js",
|
||||||
|
"sync": "npx tsc && node sync.js"
|
||||||
|
}
|
||||||
|
}
|
76
discord/sync.ts
Normal file
76
discord/sync.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* sync.ts
|
||||||
|
* Syncs the message reactions in chat with the database, for when the bot is not running.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Client,
|
||||||
|
Collection,
|
||||||
|
Events,
|
||||||
|
GatewayIntentBits,
|
||||||
|
GuildTextBasedChannel,
|
||||||
|
IntentsBitField,
|
||||||
|
Message,
|
||||||
|
MessageReaction,
|
||||||
|
Partials
|
||||||
|
} from 'discord.js';
|
||||||
|
import { db, clearDb, openDb, reactionEmojis, recordReaction } from './util';
|
||||||
|
|
||||||
|
const client = new Client({
|
||||||
|
intents: [GatewayIntentBits.MessageContent, IntentsBitField.Flags.Guilds, IntentsBitField.Flags.GuildMessages],
|
||||||
|
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once(Events.ClientReady, async () => {
|
||||||
|
console.log('[bot] Ready.');
|
||||||
|
for (let i = 0; i < reactionEmojis.length; ++i)
|
||||||
|
console.log(`[bot] config: reaction_${i + 1} = ${reactionEmojis[i]}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startup() {
|
||||||
|
console.log("[db] Clearing database...");
|
||||||
|
clearDb();
|
||||||
|
console.log("[db] Opening...");
|
||||||
|
await openDb();
|
||||||
|
console.log("[db] Migrating...");
|
||||||
|
await db.migrate();
|
||||||
|
console.log("[db] Ready.");
|
||||||
|
console.log("[bot] Logging in...");
|
||||||
|
await client.login(process.env.TOKEN);
|
||||||
|
|
||||||
|
const guild = await client.guilds.fetch(process.env.GUILD);
|
||||||
|
if (!guild) {
|
||||||
|
console.error(`[bot] FATAL: guild ${guild.id} not found!`);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
console.log(`[bot] Entered guild ${guild.id}`);
|
||||||
|
const channels = await guild.channels.fetch();
|
||||||
|
const textChannels = <Collection<string, GuildTextBasedChannel>> channels.filter(c => c && 'messages' in c && c.isTextBased);
|
||||||
|
for (const [id, textChannel] of textChannels) {
|
||||||
|
console.log(`[bot] Found text channel ${id}`);
|
||||||
|
let before: string = undefined;
|
||||||
|
let messages = new Collection<string, Message<true>>();
|
||||||
|
let newMessages: Collection<string, Message<true>>;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
newMessages = await textChannel.messages.fetch({before, limit: 100});
|
||||||
|
messages = messages.concat(newMessages);
|
||||||
|
console.log(`[bot] [${id}] Fetched ${messages.size} messages (+${newMessages.size})`);
|
||||||
|
if (messages.size > 0)
|
||||||
|
before = messages.last().id;
|
||||||
|
} while (newMessages.size > 0);
|
||||||
|
console.log(`[bot] [${id}] Fetched all messages.`);
|
||||||
|
const reactions = messages.flatMap<MessageReaction>(m => m.reactions.cache);
|
||||||
|
console.log(`[bot] Found ${reactions.size} reactions`);
|
||||||
|
for (const [_, reaction] of reactions) {
|
||||||
|
await recordReaction(reaction);
|
||||||
|
}
|
||||||
|
console.log(`[bot] [${id}] Finished recording reactions.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[bot] [${id}] Failed to fetch messages and reactions: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
startup();
|
104
discord/util.ts
Normal file
104
discord/util.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* util.ts
|
||||||
|
* Common helper functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MessageReaction, User } from 'discord.js';
|
||||||
|
import { createWriteStream, existsSync, unlinkSync } from 'fs';
|
||||||
|
import { get as httpGet } from 'https';
|
||||||
|
import { Database, open } from 'sqlite';
|
||||||
|
import { Database as Database3 } from 'sqlite3';
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { ScoreboardMessageRow } from '../models';
|
||||||
|
|
||||||
|
|
||||||
|
const reactionEmojis: string[] = process.env.REACTIONS.split(',');
|
||||||
|
let db: Database = null;
|
||||||
|
|
||||||
|
async function openDb() {
|
||||||
|
db = await open({
|
||||||
|
filename: 'db.sqlite',
|
||||||
|
driver: Database3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDb() {
|
||||||
|
unlinkSync('db.sqlite');
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageLink(message: ScoreboardMessageRow)
|
||||||
|
{
|
||||||
|
return `https://discord.com/channels/${message.guild}/${message.channel}/${message.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userAvatarPath(user: User)
|
||||||
|
{
|
||||||
|
return `../public/avatars/${user.id}.webp`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadUserAvatar(user: User)
|
||||||
|
{
|
||||||
|
console.log(`[bot] Downloading ${user.id}'s avatar...`);
|
||||||
|
const file = createWriteStream(userAvatarPath(user));
|
||||||
|
return new Promise<void>(resolve => {
|
||||||
|
httpGet(user.avatarURL(), res => {
|
||||||
|
res.pipe(file);
|
||||||
|
file.on('finish', () => {
|
||||||
|
file.close();
|
||||||
|
console.log(`[bot] Finished downloading ${user.id}'s avatar.`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshUserReactionTotalCount(user: User, emoji_idx: number)
|
||||||
|
{
|
||||||
|
const result = await db.get<{sum: number}>(
|
||||||
|
`SELECT sum(reaction_${emoji_idx}_count) AS sum FROM messages WHERE author = ?`,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
const emojiTotal = result.sum;
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO users(id, username, reaction_${emoji_idx}_total) VALUES(?, ?, ?) ON CONFLICT(id) DO
|
||||||
|
UPDATE SET reaction_${emoji_idx}_total = ?, username = ? WHERE id = ?`,
|
||||||
|
user.id,
|
||||||
|
user.displayName,
|
||||||
|
emojiTotal,
|
||||||
|
emojiTotal,
|
||||||
|
user.displayName,
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
if (!existsSync(userAvatarPath(user))) {
|
||||||
|
await downloadUserAvatar(user);
|
||||||
|
}
|
||||||
|
console.log(`[bot] Refreshed ${user.id}'s ${reactionEmojis[emoji_idx - 1]} count.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recordReaction(reaction: MessageReaction)
|
||||||
|
{
|
||||||
|
const emojiIdx = reactionEmojis.indexOf(reaction.emoji.name) + 1;
|
||||||
|
if (emojiIdx === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await db.run(
|
||||||
|
`INSERT INTO messages(id, guild, channel, author, content, reaction_${emojiIdx}_count) VALUES(?, ?, ?, ?, ?, 1)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET reaction_${emojiIdx}_count = ? WHERE id = ?`,
|
||||||
|
reaction.message.id,
|
||||||
|
reaction.message.guildId,
|
||||||
|
reaction.message.channelId,
|
||||||
|
reaction.message.author.id,
|
||||||
|
reaction.message.content,
|
||||||
|
reaction.count,
|
||||||
|
reaction.message.id
|
||||||
|
);
|
||||||
|
await refreshUserReactionTotalCount(reaction.message.author, emojiIdx);
|
||||||
|
console.log(`[bot] Recorded ${reaction.emoji.name}x${reaction.count} in database.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[bot] Something went wrong when updating the database:', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { db, clearDb, openDb, reactionEmojis, recordReaction };
|
25
models.ts
Normal file
25
models.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* models.ts
|
||||||
|
* Database models
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ScoreboardMessageRow {
|
||||||
|
id: number,
|
||||||
|
author: number,
|
||||||
|
guild: number,
|
||||||
|
channel: number,
|
||||||
|
content: string,
|
||||||
|
reaction_1_count: number,
|
||||||
|
reaction_2_count: number,
|
||||||
|
reaction_3_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScoreboardUserRow {
|
||||||
|
id: number,
|
||||||
|
username: string,
|
||||||
|
reaction_1_total: number,
|
||||||
|
reaction_2_total: number,
|
||||||
|
reaction_3_total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScoreboardMessageRow, ScoreboardUserRow };
|
2203
package-lock.json
generated
Normal file
2203
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "femleaderboard",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx tsc && node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"pug": "^3.0.2",
|
||||||
|
"sqlite": "^5.0.1",
|
||||||
|
"sqlite3": "^5.1.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.18",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
}
|
||||||
|
}
|
2
public/avatars/.gitignore
vendored
Normal file
2
public/avatars/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/**
|
||||||
|
!.gitignore
|
40
server.ts
Normal file
40
server.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* server.ts
|
||||||
|
* Web server for the scoreboard page.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Database as Database3 } from 'sqlite3';
|
||||||
|
import { Database, open } from 'sqlite';
|
||||||
|
import express = require('express');
|
||||||
|
import 'dotenv/config';
|
||||||
|
import { ScoreboardMessageRow, ScoreboardUserRow } from './models';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.static('public'));
|
||||||
|
app.set('view engine', 'pug');
|
||||||
|
const port = process.env.PORT;
|
||||||
|
let db: Database = null;
|
||||||
|
|
||||||
|
async function openDb() {
|
||||||
|
return open({
|
||||||
|
filename: 'discord/db.sqlite',
|
||||||
|
driver: Database3
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/', async (req, res) => {
|
||||||
|
const users = await db.all<[ScoreboardUserRow]>('SELECT * FROM users');
|
||||||
|
//const msgs = await db.all<[ScoreboardMessageRow]>('SELECT * FROM messages');
|
||||||
|
|
||||||
|
const funniest = [...users].sort((a, b) => a.reaction_1_total - b.reaction_1_total);
|
||||||
|
const realest = [...users].sort((a, b) => a.reaction_2_total - b.reaction_2_total);
|
||||||
|
const cunniest = [...users].sort((a, b) => a.reaction_3_total - b.reaction_3_total);
|
||||||
|
res.render('index', { funniest, realest, cunniest });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, async () => {
|
||||||
|
console.log('[web] Opening database...');
|
||||||
|
db = await openDb();
|
||||||
|
console.log('[web] Database ready.');
|
||||||
|
console.log(`[web] Listening on port ${port}`);
|
||||||
|
});
|
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2020",
|
||||||
|
"sourceMap": true
|
||||||
|
},
|
||||||
|
"exclude": [
|
||||||
|
"discord/node_modules"
|
||||||
|
]
|
||||||
|
}
|
51
views/index.pug
Normal file
51
views/index.pug
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
doctype html
|
||||||
|
html
|
||||||
|
head
|
||||||
|
title FemScoreboard
|
||||||
|
body
|
||||||
|
h1 Funniest MFs 💀
|
||||||
|
table
|
||||||
|
thead
|
||||||
|
tr
|
||||||
|
th= "Name"
|
||||||
|
th= "Avatar"
|
||||||
|
th= "Score"
|
||||||
|
tbody
|
||||||
|
each row in funniest
|
||||||
|
tr
|
||||||
|
td= row.username
|
||||||
|
td
|
||||||
|
img(src="/avatars/" + row.id + ".webp", height="64")
|
||||||
|
td= row.reaction_1_total
|
||||||
|
br
|
||||||
|
h1 Realest MFs 💯
|
||||||
|
table
|
||||||
|
thead
|
||||||
|
tr
|
||||||
|
th= "Name"
|
||||||
|
th= "Avatar"
|
||||||
|
th= "Score"
|
||||||
|
tbody
|
||||||
|
each row in realest
|
||||||
|
tr
|
||||||
|
td= row.username
|
||||||
|
td
|
||||||
|
img(src="/avatars/" + row.id + ".webp", height="64")
|
||||||
|
td= row.reaction_2_total
|
||||||
|
br
|
||||||
|
h1 Cunniest MFs 😭
|
||||||
|
table
|
||||||
|
thead
|
||||||
|
tr
|
||||||
|
th= "Name"
|
||||||
|
th= "Avatar"
|
||||||
|
th= "Score"
|
||||||
|
tbody
|
||||||
|
each row in cunniest
|
||||||
|
tr
|
||||||
|
td
|
||||||
|
span= row.username
|
||||||
|
td
|
||||||
|
img(src="/avatars/" + row.id + ".webp", height="64")
|
||||||
|
td
|
||||||
|
span= row.reaction_3_total
|
Loading…
Reference in New Issue
Block a user