0.4.0b1 add database and /userinfo (incomplete)

This commit is contained in:
Yusur 2026-02-25 10:46:15 +01:00
parent 3f13fe1ec0
commit 5b98a2e98a
18 changed files with 1194 additions and 243 deletions

2
.gitignore vendored
View file

@ -24,4 +24,4 @@ dist/
.err
.vscode
/run.sh
drizzle

View file

@ -1,5 +1,10 @@
# Changelog
## 0.4.0 (TBA)
* Switched database to PostgreSQL
* Added `/userinfo` command and balance
## 0.3.2 (February 20, 2026)
* Fixed `/wiki` loading non-existent pages.

14
drizzle.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { configDotenv } from "dotenv";
configDotenv();
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: 'postgresql',
schema: './src/db/schema.ts',
dbCredentials: {
url: process.env.DATABASE_URL!
}
});

View file

@ -0,0 +1,26 @@
CREATE TABLE "balances" (
"userId" integer,
"guildId" integer,
"balance" bigint DEFAULT 0::bigint,
"lastMessageHour" integer,
CONSTRAINT "balances_userId_guildId_pk" PRIMARY KEY("userId","guildId")
);
--> statement-breakpoint
CREATE TABLE "guilds" (
"id" serial PRIMARY KEY NOT NULL,
"discordId" bigint,
"displayName" varchar(80),
CONSTRAINT "guilds_discordId_unique" UNIQUE("discordId")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" serial PRIMARY KEY NOT NULL,
"discordId" bigint,
"username" varchar(34),
"displayName" varchar(64),
"reputation" smallint DEFAULT 0,
CONSTRAINT "users_discordId_unique" UNIQUE("discordId")
);
--> statement-breakpoint
ALTER TABLE "balances" ADD CONSTRAINT "balances_userId_users_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "balances" ADD CONSTRAINT "balances_guildId_guilds_id_fk" FOREIGN KEY ("guildId") REFERENCES "public"."guilds"("id") ON DELETE no action ON UPDATE no action;

View file

@ -0,0 +1,183 @@
{
"id": "4602e85b-a3cf-4168-881e-a95e9316f325",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.balances": {
"name": "balances",
"schema": "",
"columns": {
"userId": {
"name": "userId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"guildId": {
"name": "guildId",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"balance": {
"name": "balance",
"type": "bigint",
"primaryKey": false,
"notNull": false,
"default": "0::bigint"
},
"lastMessageHour": {
"name": "lastMessageHour",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"balances_userId_users_id_fk": {
"name": "balances_userId_users_id_fk",
"tableFrom": "balances",
"tableTo": "users",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"balances_guildId_guilds_id_fk": {
"name": "balances_guildId_guilds_id_fk",
"tableFrom": "balances",
"tableTo": "guilds",
"columnsFrom": [
"guildId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"balances_userId_guildId_pk": {
"name": "balances_userId_guildId_pk",
"columns": [
"userId",
"guildId"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.guilds": {
"name": "guilds",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"discordId": {
"name": "discordId",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"displayName": {
"name": "displayName",
"type": "varchar(80)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"guilds_discordId_unique": {
"name": "guilds_discordId_unique",
"nullsNotDistinct": false,
"columns": [
"discordId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"discordId": {
"name": "discordId",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"username": {
"name": "username",
"type": "varchar(34)",
"primaryKey": false,
"notNull": false
},
"displayName": {
"name": "displayName",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"reputation": {
"name": "reputation",
"type": "smallint",
"primaryKey": false,
"notNull": false,
"default": 0
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_discordId_unique": {
"name": "users_discordId_unique",
"nullsNotDistinct": false,
"columns": [
"discordId"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1771929740316,
"tag": "0000_strange_fabian_cortez",
"breakpoints": true
}
]
}

1004
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "sknsybot",
"version": "0.3.2",
"version": "0.4.0.b1",
"private": true,
"description": "",
"license": "Apache-2.0",
@ -15,14 +15,16 @@
"chalk": "^5.4.1",
"discord.js": "^14.14.1",
"dotenv": "^16.4.7",
"mysql": "^2.18.1",
"mysql2": "^3.12.0",
"drizzle-orm": "^0.45.1",
"node-cron": "^3.0.3",
"pg": "^8.18.0",
"wikijs": "^6.4.1"
},
"devDependencies": {
"@types/node": "^22.10.7",
"@types/node-cron": "^3.0.11",
"@types/pg": "^8.16.0",
"drizzle-kit": "^0.31.9",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
"typescript": "^5.7.3"

View file

@ -16,7 +16,7 @@ limitations under the License.
import "./initConfig";
import { GatewayIntentBits, Events, Interaction, ChatInputCommandInteraction, Guild, MessageFlags } from 'discord.js';
import { GatewayIntentBits, Events, Interaction, ChatInputCommandInteraction, Guild } from 'discord.js';
import { MyClient } from './client';
import commandList from './commandList';

View file

@ -18,6 +18,7 @@ import { ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from '
import yroo from './commands/yroo';
import wiki from './commands/wiki';
import coal from './commands/coal';
import userinfo from './commands/userinfo';
import version from './commands/version';
function fakeCommand(name: string, description?: string) {
@ -40,8 +41,8 @@ const commandList = [
wiki,
coal,
version,
userinfo,
fakeCommand('dict', 'Cerca una parola nel dizionario nassiryota'),
fakeCommand('userinfo', 'Mostra informazioni sull\'utente'),
fakeCommand('bible', 'Leggi un versetto della Bibbia'),
fakeCommand('rllaw', 'Leggi un articolo della legge italiana')
];

38
src/commands/userinfo.ts Normal file
View file

@ -0,0 +1,38 @@
import { ChatInputCommandInteraction, EmbedBuilder, SlashCommandBuilder } from 'discord.js';
import { findUser } from '../db/users';
const data = new SlashCommandBuilder()
.setName("userinfo")
.setDescription("Informazioni su un utente")
.addUserOption((o) => o.setName("u")
.setDescription("L'utente")
.setRequired(true)
)
;
async function execute (interaction: ChatInputCommandInteraction) {
await interaction.deferReply();
const user = interaction.options.getUser("u");
const dbUser = await findUser({
id: user.id,
username: user.username,
globalName: user.globalName
});
const uEmbed = new EmbedBuilder()
.setTitle(`${user.globalName} (@${user.username})`)
.setThumbnail(user.avatarURL())
.addFields([
{name: 'Bilancio', value: '<bilancio>', inline: true}
]);
await interaction.followUp({
embeds: [uEmbed]
})
}
export default { data, execute };

View file

@ -1,9 +0,0 @@
import * as cron from 'node-cron';
//cron.schedule('0 7 * * *', () => {
// // TODO send good morning to channel
//});

View file

@ -1,19 +0,0 @@
/*
Copyright 2025 Sakuragasaki46
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO

16
src/db/database.ts Normal file
View file

@ -0,0 +1,16 @@
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
import { Pool } from "pg";
const client = new Pool({
connectionString: process.env.DATABASE_URL!
});
export const db = drizzle({
client,
casing: 'snake_case',
schema
});

38
src/db/schema.ts Normal file
View file

@ -0,0 +1,38 @@
import { sql } from "drizzle-orm";
import * as p from "drizzle-orm/pg-core";
export const users = p.pgTable('users', {
id: p.serial().primaryKey(),
discordId: p.bigint({mode: "bigint"}).unique(),
username: p.varchar({length: 34}),
displayName: p.varchar({length: 64}),
/** reputation values:
* 0 = unscreened
* 1 = exempt
* 2 = suspicious
* 3 = dangerous
*/
reputation: p.smallint().default(0)
});
export const guilds = p.pgTable('guilds', {
id: p.serial().primaryKey(),
discordId: p.bigint({mode: "bigint"}).unique(),
displayName: p.varchar(({length: 80}))
// TODO channel IDs
});
export const balances = p.pgTable('balances', {
userId: p.integer().references(() => users.id),
guildId: p.integer().references(() => guilds.id),
balance: p.bigint({mode: "bigint"}).default(sql`0::bigint`),
lastMessageHour: p.integer(),
}, (t) => [
p.primaryKey({
columns: [t.userId, t.guildId]
})
]);

37
src/db/users.ts Normal file
View file

@ -0,0 +1,37 @@
import { eq } from "drizzle-orm";
import { db } from "./database";
import { users } from "./schema";
export type UserUpdate = {
id: string | bigint,
username: string
globalName: string
};
export type NewUser = typeof users.$inferInsert;
export async function findUser (userData: UserUpdate) {
let user = await db.query.users.findFirst({
where: () => eq(users.discordId, BigInt(userData.id))
}) as NewUser;
if (!user) {
// upsert the user
user = await db.insert(users).values({
discordId: BigInt(userData.id),
username: userData.username,
displayName: userData.globalName,
} as NewUser).returning() as NewUser;
} else if (
user.username !== userData.username ||
user.displayName !== userData.globalName
) {
await db.update(users).set({
username: userData.username,
displayName: userData.globalName
}).where(eq(users.discordId, BigInt(userData.id)))
}
return user;
}

View file

@ -2,8 +2,11 @@
import { config as configDotenv } from 'dotenv';
export default function init (){
configDotenv();
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL not set. Cowardly refusing to start up');
}
}

View file

@ -1,7 +1,20 @@
import client from "./bot";
import init from "./initConfig";
init();
import client from "./bot";
// query TEST
import { db } from "./db/database";
import { count } from "drizzle-orm";
import { users } from "./db/schema";
import chalk from "chalk";
(async function () {
const uCount = await db.select({ count: count() }).from(users);
console.log(`Watching over ${chalk.bold(uCount)} users`);
})().then(() => { });
// END query TEST
client.login(process.env.TOKEN);