Compare commits

..

No commits in common. "master" and "v0.3.0" have entirely different histories.

22 changed files with 300 additions and 1302 deletions

2
.gitignore vendored
View file

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

View file

@ -1,19 +1,5 @@
# Changelog # 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) ## 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. * Now the bot may show list of server the bot is in at startup, given the flag `GUILD_DETAIL_SHOW=1` in env.

View file

@ -1,14 +0,0 @@
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

@ -1,26 +0,0 @@
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

@ -1,183 +0,0 @@
{
"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

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

1087
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

9
src/cron/calendar.ts Normal file
View file

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

19
src/database.ts Normal file
View file

@ -0,0 +1,19 @@
/*
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

View file

@ -1,34 +0,0 @@
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
});

View file

@ -1,38 +0,0 @@
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]
})
]);

View file

@ -1,37 +0,0 @@
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;
}

9
src/initConfig.ts Normal file
View file

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

View file

@ -1,22 +1,7 @@
import client from "./bot";
import init from "./initConfig";
import { configDotenv } from "dotenv"; init();
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); client.login(process.env.TOKEN);

View file

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

View file

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

View file

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