Compare commits

...

7 commits

22 changed files with 1304 additions and 302 deletions

2
.gitignore vendored
View file

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

View file

@ -1,5 +1,19 @@
# 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.
* Added [Wikicord](https://wikicord.wikioasis.org/) and [Italian Mapping Wiki](https://it.mappingwiki.org/) as sources for `/wiki`.
## 0.3.1 (February 20, 2026)
* Updated package-lock.json
## 0.3.0 (February 20, 2026)
* Now the bot may show list of server the bot is in at startup, given the flag `GUILD_DETAIL_SHOW=1` in env.

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
}
]
}

1091
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.0",
"version": "0.4.0.b5",
"private": true,
"description": "",
"license": "Apache-2.0",
@ -14,15 +14,18 @@
"dependencies": {
"chalk": "^5.4.1",
"discord.js": "^14.14.1",
"dotenv": "^16.4.7",
"mysql": "^2.18.1",
"mysql2": "^3.12.0",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"node-cron": "^3.0.3",
"pg": "^8.18.0",
"wikijs": "^6.4.1"
},
"devDependencies": {
"@types/chalk": "^0.4.31",
"@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

@ -14,8 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import "./initConfig";
import { GatewayIntentBits, Events, Interaction, ChatInputCommandInteraction, Guild } from 'discord.js';
import { MyClient } from './client';
import commandList from './commandList';
@ -43,6 +41,10 @@ client.on(Events.InteractionCreate, async (interaction: Interaction) => {
} catch (ex) {
console.error(`${chalk.red('Error in command')} ${chalk.bold(`/${commandName}`)}`);
console.error(ex);
await interaction.followUp({
content: `Errore nel comando /${commandName}. Contattare l'amministratore del bot.`,
ephemeral: true
});
}
}
});

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

@ -5,11 +5,21 @@ const pageSources = {
'cittadeldank': {
url: 'https://wiki.cittadeldank.it',
name: 'Città del Dank'
},
'wikicord': {
url: 'https://wikicord.wikioasis.org/api.php',
name: 'Wikicord'
},
'mappingwikiit': {
url: 'https://it.mappingwiki.org',
name: 'Mapping Wiki IT'
}
}
const pageSourcesAuto = [
'cittadeldank'
'cittadeldank',
'wikicord',
'mappingwikiit'
];
const data = new SlashCommandBuilder()
@ -23,6 +33,8 @@ const data = new SlashCommandBuilder()
.setDescription('Dove guardare')
.addChoices([
{name: 'Città del Dank', value: 'cittadeldank'},
{name: 'Wikicord', value: 'wikicord'},
{name: 'Mapping Wiki IT', value: 'mappingwikiit'},
{name: 'Automatico', value: 'auto'}
])
.setRequired(false)
@ -45,7 +57,13 @@ async function execute (interaction: ChatInputCommandInteraction) {
if (siteChoice === 'auto') {
for (let site of pageSourcesAuto) {
pageData = await fetchPageFromSite(site, pageName);
try {
pageData = await fetchPageFromSite(site, pageName);
} catch (error) {
console.warn(`Skipping source ${site}: ${error}`);
continue;
}
if (pageData) break;
}
} else {
@ -57,7 +75,7 @@ async function execute (interaction: ChatInputCommandInteraction) {
const pageEmbed = new EmbedBuilder()
.setTitle(pageData.title)
.setURL(pageData.url)
.setDescription(pageData.summary)
.setDescription(pageData.summary || '...')
.setFooter({
text: `Informazioni da ${pageData.origin}`
});
@ -71,7 +89,7 @@ async function execute (interaction: ChatInputCommandInteraction) {
});
} else {
await interaction.followUp({
content: `Pagina **${pageName}** non trovata`
content: `Pagina **${pageName}** non trovata!`
});
}

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

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

@ -0,0 +1,34 @@
import { drizzle } from "drizzle-orm/node-postgres";
import * as schema from "./schema";
import { Pool } from "pg";
function urlToObj(url: string) {
if (!url) {
console.warn("DATABASE_URL is not set, expect the database to not connect");
return null;
}
const { username, password, hostname, port, pathname } = URL.parse(url);
return {
user: username,
password,
host: hostname,
port: +(port || 5432),
database: pathname.split('/')[1]
}
}
const client = new Pool({
...urlToObj(process.env.DATABASE_URL!)
});
export const db = drizzle({
client,
// casing: 'snake_case', [DOES NOT WORK ON DRIZZLEKIT]
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

@ -1,9 +0,0 @@
import { config as configDotenv } from 'dotenv';
export default function init (){
configDotenv();
}

View file

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

View file

@ -14,8 +14,6 @@ function strToObjArr(s: string | object[]): object[] {
export type SectionObject = {title: string, content: string};
type WithPageId = {pageId: any};
export class MediaWikiClient {
apiUrl: string
siteName: string | null
@ -29,7 +27,7 @@ export class MediaWikiClient {
const wikiClient = wiki({
apiUrl: this.apiUrl,
origin: null
});
}) as any;
const page = await wikiClient.page(title);
const pageId = page.raw.pageid;
const pageUrl = page.url();

View file

@ -14,12 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import init from "./initConfig";
import { configDotenv } from "dotenv";
configDotenv();
import commandList from "./commandList";
import { REST, Routes } from 'discord.js';
init();
function registerGlobal(rest: REST, clientId: string, commands: any){
rest.put(Routes.applicationCommands(clientId), { body: commands })

View file

@ -1,5 +1,7 @@
{
"compilerOptions": {
"target": "es2017",
"module": "preserve",
"rootDir": "src/",
"outDir": "build/",
"esModuleInterop": true,