Initial commit

This commit is contained in:
James Shiffer 2023-10-07 22:46:02 -07:00
commit 68b94e0642
19 changed files with 4095 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
PORT=3000

29
.gitignore vendored Normal file
View 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
View 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
View File

@ -0,0 +1,3 @@
TOKEN="sadkfl;jasdkl;fj"
REACTIONS="💀,💯,😭"
GUILD="123456789012345678"

62
discord/bot.ts Normal file
View 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

Binary file not shown.

View 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;

View 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

File diff suppressed because it is too large Load Diff

17
discord/package.json Normal file
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View 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
View File

@ -0,0 +1,2 @@
/**
!.gitignore

40
server.ts Normal file
View 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
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es2020",
"sourceMap": true
},
"exclude": [
"discord/node_modules"
]
}

51
views/index.pug Normal file
View 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