diff --git a/DEV-NOTES.md b/DEV-NOTES.md new file mode 100644 index 0000000..f14ad0f --- /dev/null +++ b/DEV-NOTES.md @@ -0,0 +1,12 @@ +# Project dependencies + +# Structure +- `bot` -- the bot code itself +- `cfg` -- everything configuration related +- `external` -- eveything responsible for communication with an external network interfaces/devices +- `repo` -- everything storage related (including external databases, caches, CDNs) +- `utils` -- everything else + +# Configuring the project + +We store code in a .ts file cuz it's working just fine diff --git a/README.md b/README.md index 09a27d7..847d4ff 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,81 @@ # mic-bot -Telegram chat and inline bot for our community https://t.me/mental_illness_center_bot \ No newline at end of file +Telegram chat and inline bot for our community https://t.me/mental_illness_center_bot + +## Features + +### Captcha +When new user starts the bot it sends him a captcha. After passing the captcha user can get the chat invite + +### Safebooru image search +To search images via safebooru just type in your message prompt
+`@mental_illness_center_bot safebooru `
+and it will find images for you. + +## TODO: +- Safebooru images lazy loading +- Minecraft server intergration +- Referal system +- Safebooru ChatGPT text to tags + +## Config + +### File config + +Before running **MIC** create file `./config.json` that contains +```json +{ + // to run in Docker set the value to + // "/run/secrets/db_password" + // If wasn't set -- POSTGRES_PASSWORD env + // variable will be used instead + "db_password_file": "~/.secrets/mic", + // BOT_TOKEN env variable has more priority + // that the config value + "bot_token": "" +} +``` + +### Environment variables + +- `POSTGRESS_PASSWORD` + DB Password. If Config file property `db_password_file` has been set -- it will be used instead +- `BOT_TOKEN` + Telegram bot token. Has bigger priority than the config property +- `MIC_CONFIG_PATH` + Specifies the path to a MIC config file. `./config.json` by default +- `MIC_APPLY_MIGRATIONS` + (y/N) // Set it to "y" before running the MIC for the first time to apply DB migrations. Gets reseted to "n" automatically so migrations are applied once +- `MIC_DROP_DB` + (y/N) // **I WILL RAPE U IF U USE IT ON A PROD DB. ONLY FOR DEVELOPMENT!!!** Gets reseted to "n" automatically for security reasons + +More advanced parameters can be configured at `config.ts`. + +## Running +> [!WARNING] +> **Read the Config section before running. App won't start without a valid configuration!** +### Docker + +```sh +# Create directories required by the app +mkdir .secrets postgres_data +# Set the DB password +nvim .secrets/db_password.txt +# Classical docker commands +docker compose build +docker compose up +``` + +### Host +Before running the bot on host -- setup + +```sh +mkdir bin +# With compilation +deno compile -o ./bin/mic ./main.ts +./bin/mic +# Just run it +deno run ./main.ts +# Dev run with autorestart +deno run ./main.ts +``` \ No newline at end of file diff --git a/bot/bot.ts b/bot/bot.ts new file mode 100644 index 0000000..f5a85f3 --- /dev/null +++ b/bot/bot.ts @@ -0,0 +1,31 @@ +import { Bot, Context, session } from "https://deno.land/x/grammy/mod.ts"; +import { BotError } from "https://deno.land/x/grammy@v1.30.0/bot.ts"; +import { Ctx, defaultSessionData } from "./ctx.ts"; +import * as config from "../cfg/exports.ts" +import { ERR_CODES, Err } from "../utils/errors.ts"; + +class BotUnknownRuntimeErr extends Err { + code: ERR_CODES = ERR_CODES.UnknownErr; + override cause?: BotError; +} +class BotUnknownOnStartErr extends Err { + code: ERR_CODES = ERR_CODES.UnknownErr; +} + +export const runBot = async (cfg: config.BotConfig, initMode: (bot: Bot) => void) => { + // Bot initialization & setup + const bot = new Bot(cfg.bot_token) + bot.use(session({ initial: defaultSessionData })) + bot.catch((err: BotError) => { + throw new BotUnknownRuntimeErr("Unknown error while running the bot", + { cause: err }) + }) + + initMode(bot) + + console.log("Starting bot") + await bot.start().catch(err => { + throw new BotUnknownOnStartErr("Unknown error while starting the bot", + { cause: err }) + }) +} diff --git a/bot/ctx.ts b/bot/ctx.ts new file mode 100644 index 0000000..c74b5da --- /dev/null +++ b/bot/ctx.ts @@ -0,0 +1,29 @@ +import { Context, SessionFlavor } from "https://deno.land/x/grammy@v1.30.0/mod.ts"; +import { CaptchaSessionData } from "./normal_mode/captcha.ts"; + +interface SessionData { + // Can be useful if will decide to extend + // with some websites + inner_id: string + is_banned: boolean, + + captcha_solved: boolean + captcha_data?: CaptchaSessionData + invite_link?: string + + chat_participant: boolean +} + +const defaultSessionData = (): SessionData => { + const innerId = crypto.randomUUID() // UUIDv4 + return { + inner_id: innerId, + is_banned: false, + captcha_solved: false, + chat_participant: false, + } +} +type Ctx = Context & SessionFlavor; + +export type { SessionData, Ctx } +export { defaultSessionData } diff --git a/bot/lang/en.ts b/bot/lang/en.ts new file mode 100644 index 0000000..c061e70 --- /dev/null +++ b/bot/lang/en.ts @@ -0,0 +1,14 @@ +import { LANG_EN, LangPack } from "./lang_pack.ts"; + +const pack: LangPack = { + code: LANG_EN, + btn_labels: null, + replies: { + captcha: { + already_in_chat: "You are already in chat", + passed: (invite_link: string) => `Captcha passed! Now u can join to the community: ${invite_link}`, + failed: (timeout_mins: number) => `Капча не пройдена. Вы сможете попробовать снова через ${timeout_mins} минут.`, + } + }, +} +export default pack \ No newline at end of file diff --git a/bot/lang/export.ts b/bot/lang/export.ts new file mode 100644 index 0000000..5972869 --- /dev/null +++ b/bot/lang/export.ts @@ -0,0 +1,30 @@ +import { LangPack } from "./lang_pack.ts" +import en_pack from "./en.ts" +import ru_pack from "./ru.ts" +import rs_pack from "./rs.ts" +import hr_pack from "./hr.ts" +import hu_pack from "./hu.ts" +import pl_pack from "./pl.ts" +import ua_pack from "./pl.ts" + + +export * from "./lang_pack.ts" + +export class LangManager { + static defaultLang: LangPack = en_pack + static langsMap = new Map([ + [en_pack.code, en_pack], + [ru_pack.code, ru_pack], + [rs_pack.code, rs_pack], + [hr_pack.code, hr_pack], + [hu_pack.code, hu_pack], + [pl_pack.code, pl_pack], + [ua_pack.code, ua_pack], + ]) + + static getDefaultLang(): LangPack { return this.defaultLang } + static getLang(langCode?: string): LangPack { + return this.langsMap.get(langCode || this.defaultLang.code) + || this.defaultLang + } +} \ No newline at end of file diff --git a/bot/lang/hr.ts b/bot/lang/hr.ts new file mode 100644 index 0000000..27a26cc --- /dev/null +++ b/bot/lang/hr.ts @@ -0,0 +1,9 @@ +import pack_rs from "./rs.ts" +import { LANG_HR, LangPack } from "./lang_pack.ts"; + +const pack: LangPack = { + code: LANG_HR, + btn_labels: null, + replies: pack_rs.replies, +} +export default pack \ No newline at end of file diff --git a/bot/lang/hu.ts b/bot/lang/hu.ts new file mode 100644 index 0000000..06f9a2c --- /dev/null +++ b/bot/lang/hu.ts @@ -0,0 +1,14 @@ +import { LANG_EN, LangPack } from "./lang_pack.ts"; + +const pack: LangPack = { + code: LANG_EN, + btn_labels: null, + replies: { + captcha: { + already_in_chat: "Ti si već u chat-u", + passed: (invite_link: string) => `Captcha sikeresen teljesítve! Most már csatlakozhat közösségünkhöz: ${invite_link}`, + failed: (timeout_mins: number) => `A captcha nem sikerült. 15 perc múlva próbálkozhat ${timeout_mins} újra.`, + } + }, +} +export default pack \ No newline at end of file diff --git a/bot/lang/lang_pack.ts b/bot/lang/lang_pack.ts new file mode 100644 index 0000000..54c915b --- /dev/null +++ b/bot/lang/lang_pack.ts @@ -0,0 +1,20 @@ +export const LANG_RU = "ru" +export const LANG_EN = "en" +export const LANG_UA = "ua" +export const LANG_HU = "hu" +export const LANG_RS = "rs" +export const LANG_HR = "hr" +export const LANG_PL = "pl" + + +export interface LangPack { + code: string, + btn_labels: null, + replies: { + captcha: { + already_in_chat: string, + passed: (invite_link: string) => string, + failed: (timeout_mins: number) => string, + } + }, +} \ No newline at end of file diff --git a/bot/lang/pl.ts b/bot/lang/pl.ts new file mode 100644 index 0000000..c7cf29c --- /dev/null +++ b/bot/lang/pl.ts @@ -0,0 +1,9 @@ +import { LANG_PL, LangPack } from "./lang_pack.ts"; +import lang_en from "./en.ts" + +const pack: LangPack = { + code: LANG_PL, + btn_labels: null, + replies: lang_en.replies, +} +export default pack \ No newline at end of file diff --git a/bot/lang/rs.ts b/bot/lang/rs.ts new file mode 100644 index 0000000..364c1dc --- /dev/null +++ b/bot/lang/rs.ts @@ -0,0 +1,14 @@ +import { LangPack, LANG_RS } from "./lang_pack.ts"; + +const pack: LangPack = { + code: LANG_RS, + btn_labels: null, + replies: { + captcha: { + already_in_chat: "Ti si već u chat-u", + passed: (invite_link: string) => `Uspeo si sa captch-om! Sada mozes predruziti se nasoj zajednici: ${invite_link}`, + failed: (timeout_mins: number) => `Nisi prošao captch-u. Mozes pokušati ponovo za ${timeout_mins} minuta.`, + } + }, +} +export default pack \ No newline at end of file diff --git a/bot/lang/ru.ts b/bot/lang/ru.ts new file mode 100644 index 0000000..0fd9f00 --- /dev/null +++ b/bot/lang/ru.ts @@ -0,0 +1,14 @@ +import { LANG_RU, LangPack } from "./lang_pack.ts"; + +const pack: LangPack = { + code: LANG_RU, + btn_labels: null, + replies: { + captcha: { + already_in_chat: "Вы уже в чате", + passed: (invite_link: string) => `Капча пройдена! Теперь вы можете присоединиться к нашему сообществу: ${invite_link}`, + failed: (timeout_mins: number) => `Капча не пройдена. Вы сможете попробовать снова через ${timeout_mins} минут.`, + } + }, +} +export default pack \ No newline at end of file diff --git a/bot/lang/ua.ts b/bot/lang/ua.ts new file mode 100644 index 0000000..b0d8898 --- /dev/null +++ b/bot/lang/ua.ts @@ -0,0 +1,14 @@ +import { LANG_UA, LangPack } from "./lang_pack.ts"; + +const pack: LangPack = { + code: LANG_UA, + btn_labels: null, + replies: { + captcha: { + already_in_chat: "Ви вже в чаті", + passed: (invite_link: string) => `Капча пройдена! Тепер ви можете приєднатися до нашої спільноти: ${invite_link}`, + failed: (timeout_mins: number) => `Капча не пройдена. Ви зможете спробувати ще раз через ${timeout_mins} хвилин.`, + } + }, +} +export default pack \ No newline at end of file diff --git a/bot/normal_mode/captcha.ts b/bot/normal_mode/captcha.ts new file mode 100644 index 0000000..290840a --- /dev/null +++ b/bot/normal_mode/captcha.ts @@ -0,0 +1,107 @@ +import { CommandContext, Filter } from "https://deno.land/x/grammy@v1.30.0/mod.ts"; +import { BotConfig } from "../../cfg/bot.ts" +import { Ctx } from "../ctx.ts"; +import { LangManager } from "../lang/export.ts"; + +const CAPTCHA_FAILS_LIMIT = 3 +const TIMEOUT_AFTER_FAIL_MINS = 5 + +export interface CaptchaSessionData { + message_id: number, + tries_failed: number, + generated_at: number, + // Captcha can be changed from a simple matematical ones + // to a more complicated images or even science-related questions + solution: string, +} + +const randInt = (min: number, max: number): number => + Math.floor(Math.random() * (max - min) + min) + +class Captcha { + // ! READ ONLY ! Initialized by the constructor + public _generated_at = Date.now() + // ! READ ONLY ! Initialized by the constructor + public _solution: number + // ! READ ONLY ! Initialized by the constructor + public _text: string + + constructor() { + const number1 = randInt(-100, 100) + const number2 = randInt(-100, 100) + this._solution = number1 + number2 + this._text = `Captcha: ${number1}+${number2}=?` + } +} +// User captcha response sanitizer/parser/validator +const isSolutionCorrect = (expectSolution: string, solution: string): boolean => { + solution = solution.replace(' ','').replace('\t','') // Sanitizing + return parseInt(solution, 10) == parseInt(expectSolution, 10) ? true : false +} + +const initUserCaptcha = async (ctx: CommandContext) => { + if (ctx.chat.type != "private" || + ctx.session.captcha_data) return + + if (ctx.session.chat_participant) { + if (!ctx.msg?.from) return + ctx.reply(LangManager.getLang(ctx.msg.from.language_code) + .replies.captcha.already_in_chat) + } + + const captcha = new Captcha() + const msg = await ctx.reply(captcha._text) + + ctx.session.captcha_data = { + message_id: msg.message_id, + tries_failed: 0, + generated_at: captcha._generated_at, + solution: captcha._solution.toString(), + } +} + +const captchaPassed = async (botCfg: BotConfig, ctx: Filter) => { + if (ctx.session.captcha_data) { + ctx.api.deleteMessage( + ctx.chatId, ctx.session.captcha_data.message_id + ).catch() + } + ctx.session.captcha_data = undefined + ctx.session.captcha_solved = true + const now = Math.floor(Date.now() / 1000); + const linkExpireTimestamp = now + (12 * 60 * 60); + const link = await ctx.api.createChatInviteLink(botCfg.chat_id, { + member_limit: 1, + expire_date: linkExpireTimestamp, + }) + ctx.reply(LangManager.getLang(ctx.msg.from.language_code) + .replies.captcha.passed(link.invite_link)) + ctx.session.captcha_data = undefined +} + +// returns true if captcha is response to a captcha; else -- returns false +const handleNewMessage = async (botCfg: BotConfig, ctx: Filter): Promise => { + if (ctx.message.chat.type != "private" || !ctx.session.captcha_data) return false + if (isSolutionCorrect(ctx.session.captcha_data.solution, ctx.message.text)) { + await captchaPassed(botCfg, ctx) + } else { + ctx.session.captcha_data.tries_failed++ + ctx.api.deleteMessage(ctx.chatId, ctx.msg.message_id).catch() + if (ctx.session.captcha_data.tries_failed > CAPTCHA_FAILS_LIMIT) { + ctx.reply(LangManager.getLang(ctx.msg.from.language_code) + .replies.captcha.failed(TIMEOUT_AFTER_FAIL_MINS)) + ctx.api.deleteMessage(ctx.chatId, ctx.session.captcha_data.message_id).catch() + } + } + return true +} + +const handleUserJoinChat = async (botCfg: BotConfig, ctx: Filter) => { + ctx.session.chat_participant = true + const inviteLink = ctx.session.invite_link + if (!inviteLink) return + await ctx.api.revokeChatInviteLink(botCfg.chat_id, inviteLink) +} + + +export { handleNewMessage, handleUserJoinChat, initUserCaptcha } diff --git a/bot/normal_mode/init.ts b/bot/normal_mode/init.ts new file mode 100644 index 0000000..22f09f8 --- /dev/null +++ b/bot/normal_mode/init.ts @@ -0,0 +1,10 @@ +import { Bot } from "https://deno.land/x/grammy@v1.30.0/mod.ts"; +import { Kysely } from "npm:kysely"; +import { Ctx } from "../ctx.ts"; +import { CompiledConfig } from "../../cfg/exports.ts"; +import { Database } from "../../repo/exports.ts"; + + +export const init = (bot: Bot, db: Kysely, cfg: CompiledConfig) => { + const { botCfg } = cfg +} \ No newline at end of file diff --git a/bot/setup_mode/init.ts b/bot/setup_mode/init.ts new file mode 100644 index 0000000..e69de29 diff --git a/cfg/bot.ts b/cfg/bot.ts new file mode 100644 index 0000000..961455f --- /dev/null +++ b/cfg/bot.ts @@ -0,0 +1,7 @@ +export enum BotMode{ setup, normal } +export type BotConfig = { + mode: BotMode, + bot_token: string, + chat_id: number, + admin_ids: number[], +} \ No newline at end of file diff --git a/cfg/config.ts b/cfg/config.ts new file mode 100644 index 0000000..37a59b1 --- /dev/null +++ b/cfg/config.ts @@ -0,0 +1,117 @@ +import type { PoolConfig } from "npm:@types/pg"; +import { strToBool } from "../utils/convert.ts"; +import { BadConfigErr } from "../utils/errors.ts"; +import { readJsonFileSync } from "../utils/io.ts"; +import { BotConfig } from "./bot.ts"; + +export const POSTGRES_PASSWORD = "POSTGRES_PASSWORD" +export const MIC_APPLY_MIGRATIONS = "MIC_APPLY_MIGRATIONS" +export const DEFAULT_CONFIG_FILE_PATH = "DEFAULT_CONFIG_FILE_PATH" +export const MIC_DROP_DB = "MIC_DROP_DB" +export const BOT_TOKEN = "BOT_TOKEN" +export const MIC_CONFIG_PATH = "MIC_CONFIG_PATH" + + +export interface EnvConfig { + db_password?: string, + bot_token?: string, + mic_config_path?: string, + mic_apply_migrations: boolean, + mic_drop_db: boolean, +} + +export interface FileConfig { + db_user?: string, + db_name?: string, + db_port?: number, + db_host?: string, + db_tls?: boolean, + db_password_file?: string, + bot_token?: string, +} + +export interface Config { + db_user?: string, + db_name?: string, + db_port?: number, + db_host?: string, + db_tls?: boolean, + db_password: string, + bot_token: string, + config_path: string, + apply_migrations: boolean, + drop_db: boolean, +} + +export interface MigrationConfig { + dropDb: boolean + applyMigrations: boolean + migrationsPath: string +} + +export interface CompiledConfig { + pgPoolCfg: PoolConfig + botCfg: BotConfig + migrationCfg?: MigrationConfig +} + +export const getEnvConfig = (): EnvConfig => { + const apply_migrations = + strToBool(Deno.env.get(MIC_APPLY_MIGRATIONS) || "n") || false + if (apply_migrations) Deno.env.set(MIC_APPLY_MIGRATIONS, "n") + + const drop_db = strToBool(Deno.env.get(MIC_DROP_DB) || "n") || false + if (drop_db) Deno.env.set(MIC_DROP_DB, "n") + + return { + db_password: Deno.env.get(POSTGRES_PASSWORD), + bot_token: Deno.env.get(BOT_TOKEN), + mic_config_path: Deno.env.get(MIC_CONFIG_PATH), + mic_apply_migrations: apply_migrations, + mic_drop_db: drop_db, + } +} + +export const getConfig = (): Config => { + const envCfg = getEnvConfig() + const config_path = envCfg.mic_config_path || DEFAULT_CONFIG_FILE_PATH + const fileCfg = readJsonFileSync(config_path) + + let db_password: string; + if (fileCfg.db_password_file) { + const decoder = new TextDecoder("utf-8") + const data = Deno.readFileSync(fileCfg.db_password_file) + db_password = decoder.decode(data).toString().slice(0, -1) + } else if (envCfg.db_password) { + db_password = envCfg.db_password + } else throw new BadConfigErr( + "Expected db_password_file to be set in the config "+ + "or the POSTGRES_PASSWORD environment variable, "+ + "but bot both are undefined. "+ + "Check the Config section in README.md" + ) + + const bot_token: string | undefined = envCfg.bot_token || fileCfg.bot_token + if (!bot_token) throw new BadConfigErr( + "Expected bot_token to be set in the config "+ + "or at the BOT_TOKEN environment variable, "+ + "but bot token is undefined. "+ + "Check the Config section in README.md" + ) + + return { + db_host: fileCfg.db_host, + db_user: fileCfg.db_user, + db_name: fileCfg.db_name, + db_port: fileCfg.db_port, + db_tls: fileCfg.db_tls, + db_password, + config_path, + apply_migrations: envCfg.mic_apply_migrations, + drop_db: envCfg.mic_drop_db, + + bot_token, + } +} + +export * from "./bot.ts" \ No newline at end of file diff --git a/config.ts b/config.ts new file mode 100644 index 0000000..bc712ef --- /dev/null +++ b/config.ts @@ -0,0 +1,38 @@ +import type { PoolConfig } from "npm:@types/pg"; +import { getConfig } from "./cfg/config.ts"; +import type { BotConfig, CompiledConfig } from "./cfg/config.ts"; +import { BotMode } from "./cfg/bot.ts"; + +const MIC_CHAT_ID = -1002438254268 +const MR_ANDERSON_ID = 1843444763 + +export const DEFAULT_DB_HOST = "localhost" +export const DEFAULT_DB_USER = "mic" +export const DEFAULT_DB_NAME = "mic" +export const DEFAULT_DB_PORT = 5432 +export const DEFAULT_DB_TLS = false + +export const reloadConfig = (): CompiledConfig => { + const cfg = getConfig() + const pgPoolCfg: PoolConfig = { + host: cfg.db_name || DEFAULT_DB_HOST, + user: cfg.db_user || DEFAULT_DB_USER, + database: cfg.db_name || DEFAULT_DB_NAME, + port: cfg.db_port || DEFAULT_DB_PORT, + ssl: cfg.db_tls || DEFAULT_DB_TLS, + password: cfg.db_password, + } + const botCfg: BotConfig = { + mode: BotMode.normal, + bot_token: cfg.bot_token, + chat_id: MIC_CHAT_ID, + admin_ids: [ + MR_ANDERSON_ID, + ] + } + return { + pgPoolCfg, botCfg, + dropDb: cfg.drop_db, + applyMigrations: cfg.apply_migrations + } +} \ No newline at end of file diff --git a/core/entities.ts b/core/entities.ts new file mode 100644 index 0000000..0320fa3 --- /dev/null +++ b/core/entities.ts @@ -0,0 +1,22 @@ +// Type used in used in every table that reffers to a specific user +export interface UserID { id: string } + +export interface User extends UserID { + // If some user added from the telegram -- tg_id shouldn't be null + // But in case of adding later other ways to interract with the MIC + // We gonna make it nullable + tg_id: number | null + joined_byreferal_from_user_id: number | null + is_chat_participant: boolean + is_captcha_passed: boolean + is_timeout: boolean + is_banned: boolean + created_at: Date + joined_chat_at: Date + timeout_until: Date | null +} + +export interface InviteLink { + link: string + expect_user_tg_id: number +} diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..5b320c2 --- /dev/null +++ b/deno.json @@ -0,0 +1,8 @@ +{ + "tasks": { + "dev": "deno run --watch main.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..872cd4f --- /dev/null +++ b/deno.lock @@ -0,0 +1,199 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:@types/pg": "npm:@types/pg@8.11.10", + "npm:kysely": "npm:kysely@0.27.4", + "npm:pg": "npm:pg@8.13.0" + }, + "npm": { + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + }, + "@types/pg@8.11.10": { + "integrity": "sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==", + "dependencies": { + "@types/node": "@types/node@18.16.19", + "pg-protocol": "pg-protocol@1.7.0", + "pg-types": "pg-types@4.0.2" + } + }, + "kysely@0.27.4": { + "integrity": "sha512-dyNKv2KRvYOQPLCAOCjjQuCk4YFd33BvGdf/o5bC7FiW+BB6snA81Zt+2wT9QDFzKqxKa5rrOmvlK/anehCcgA==", + "dependencies": {} + }, + "obuf@1.1.2": { + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "dependencies": {} + }, + "pg-cloudflare@1.1.1": { + "integrity": "sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==", + "dependencies": {} + }, + "pg-connection-string@2.7.0": { + "integrity": "sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==", + "dependencies": {} + }, + "pg-int8@1.0.1": { + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dependencies": {} + }, + "pg-numeric@1.0.2": { + "integrity": "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==", + "dependencies": {} + }, + "pg-pool@3.7.0_pg@8.13.0": { + "integrity": "sha512-ZOBQForurqh4zZWjrgSwwAtzJ7QiRX0ovFkZr2klsen3Nm0aoh33Ls0fzfv3imeH/nw/O27cjdz5kzYJfeGp/g==", + "dependencies": { + "pg": "pg@8.13.0" + } + }, + "pg-protocol@1.7.0": { + "integrity": "sha512-hTK/mE36i8fDDhgDFjy6xNOG+LCorxLG3WO17tku+ij6sVHXh1jQUJ8hYAnRhNla4QVD2H8er/FOjc/+EgC6yQ==", + "dependencies": {} + }, + "pg-types@2.2.0": { + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dependencies": { + "pg-int8": "pg-int8@1.0.1", + "postgres-array": "postgres-array@2.0.0", + "postgres-bytea": "postgres-bytea@1.0.0", + "postgres-date": "postgres-date@1.0.7", + "postgres-interval": "postgres-interval@1.2.0" + } + }, + "pg-types@4.0.2": { + "integrity": "sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==", + "dependencies": { + "pg-int8": "pg-int8@1.0.1", + "pg-numeric": "pg-numeric@1.0.2", + "postgres-array": "postgres-array@3.0.2", + "postgres-bytea": "postgres-bytea@3.0.0", + "postgres-date": "postgres-date@2.1.0", + "postgres-interval": "postgres-interval@3.0.0", + "postgres-range": "postgres-range@1.1.4" + } + }, + "pg@8.13.0": { + "integrity": "sha512-34wkUTh3SxTClfoHB3pQ7bIMvw9dpFU1audQQeZG837fmHfHpr14n/AELVDoOYVDW2h5RDWU78tFjkD+erSBsw==", + "dependencies": { + "pg-cloudflare": "pg-cloudflare@1.1.1", + "pg-connection-string": "pg-connection-string@2.7.0", + "pg-pool": "pg-pool@3.7.0_pg@8.13.0", + "pg-protocol": "pg-protocol@1.7.0", + "pg-types": "pg-types@2.2.0", + "pgpass": "pgpass@1.0.5" + } + }, + "pgpass@1.0.5": { + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dependencies": { + "split2": "split2@4.2.0" + } + }, + "postgres-array@2.0.0": { + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dependencies": {} + }, + "postgres-array@3.0.2": { + "integrity": "sha512-6faShkdFugNQCLwucjPcY5ARoW1SlbnrZjmGl0IrrqewpvxvhSLHimCVzqeuULCbG0fQv7Dtk1yDbG3xv7Veog==", + "dependencies": {} + }, + "postgres-bytea@1.0.0": { + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "dependencies": {} + }, + "postgres-bytea@3.0.0": { + "integrity": "sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==", + "dependencies": { + "obuf": "obuf@1.1.2" + } + }, + "postgres-date@1.0.7": { + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dependencies": {} + }, + "postgres-date@2.1.0": { + "integrity": "sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==", + "dependencies": {} + }, + "postgres-interval@1.2.0": { + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dependencies": { + "xtend": "xtend@4.0.2" + } + }, + "postgres-interval@3.0.0": { + "integrity": "sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==", + "dependencies": {} + }, + "postgres-range@1.1.4": { + "integrity": "sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==", + "dependencies": {} + }, + "split2@4.2.0": { + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dependencies": {} + }, + "xtend@4.0.2": { + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dependencies": {} + } + } + }, + "redirects": { + "https://deno.land/x/grammy/mod.ts": "https://deno.land/x/grammy@v1.30.0/mod.ts" + }, + "remote": { + "https://cdn.skypack.dev/-/debug@v4.3.4-o4liVvMlOnQWbLSYZMXw/dist=es2019,mode=imports/optimized/debug.js": "671100993996e39b501301a87000607916d4d2d9f8fc8e9c5200ae5ba64a1389", + "https://cdn.skypack.dev/-/ms@v2.1.2-giBDZ1IA5lmQ3ZXaa87V/dist=es2019,mode=imports/optimized/ms.js": "fd88e2d51900437011f1ad232f3393ce97db1b87a7844b3c58dd6d65562c1276", + "https://cdn.skypack.dev/debug@4.3.4": "7b1d010cc930f71b940ba5941da055bc181115229e29de7214bdb4425c68ea76", + "https://deno.land/std@0.211.0/path/_common/assert_path.ts": "2ca275f36ac1788b2acb60fb2b79cb06027198bc2ba6fb7e163efaedde98c297", + "https://deno.land/std@0.211.0/path/_common/basename.ts": "569744855bc8445f3a56087fd2aed56bdad39da971a8d92b138c9913aecc5fa2", + "https://deno.land/std@0.211.0/path/_common/constants.ts": "dc5f8057159f4b48cd304eb3027e42f1148cf4df1fb4240774d3492b5d12ac0c", + "https://deno.land/std@0.211.0/path/_common/strip_trailing_separators.ts": "7024a93447efcdcfeaa9339a98fa63ef9d53de363f1fbe9858970f1bba02655a", + "https://deno.land/std@0.211.0/path/_os.ts": "8fb9b90fb6b753bd8c77cfd8a33c2ff6c5f5bc185f50de8ca4ac6a05710b2c15", + "https://deno.land/std@0.211.0/path/basename.ts": "5d341aadb7ada266e2280561692c165771d071c98746fcb66da928870cd47668", + "https://deno.land/std@0.211.0/path/posix/_util.ts": "1e3937da30f080bfc99fe45d7ed23c47dd8585c5e473b2d771380d3a6937cf9d", + "https://deno.land/std@0.211.0/path/posix/basename.ts": "39ee27a29f1f35935d3603ccf01d53f3d6e0c5d4d0f84421e65bd1afeff42843", + "https://deno.land/std@0.211.0/path/windows/_util.ts": "d5f47363e5293fced22c984550d5e70e98e266cc3f31769e1710511803d04808", + "https://deno.land/std@0.211.0/path/windows/basename.ts": "e2dbf31d1d6385bfab1ce38c333aa290b6d7ae9e0ecb8234a654e583cf22f8fe", + "https://deno.land/x/grammy@v1.30.0/bot.ts": "9576361190e2dfcf7d2a665e6e20b66c15ae7a7cf81c25baea50b6c682b7a439", + "https://deno.land/x/grammy@v1.30.0/composer.ts": "dab5a40d8a6fdc734bfb218f8b2e4ef846c05b833219ddd3fdee3f145bb2660b", + "https://deno.land/x/grammy@v1.30.0/context.ts": "9ace341055f75fa9035941aed82617381ef608c6bd4b269c59428bc5b577cddd", + "https://deno.land/x/grammy@v1.30.0/convenience/constants.ts": "1560129784be52f49aa0bea8716f09ed00dac367fef195be6a2c09bdfc43fb99", + "https://deno.land/x/grammy@v1.30.0/convenience/frameworks.ts": "d5fce7be2c3d5f21db752b5b98d6eba97c7a2b18e5d41f4fc4654d387174b861", + "https://deno.land/x/grammy@v1.30.0/convenience/inline_query.ts": "409d1940c7670708064efa495003bcbfdf6763a756b2e6303c464489fd3394ff", + "https://deno.land/x/grammy@v1.30.0/convenience/input_media.ts": "7af72a5fdb1af0417e31b1327003f536ddfdf64e06ab8bc7f5da6b574de38658", + "https://deno.land/x/grammy@v1.30.0/convenience/keyboard.ts": "de39a47199a9dde779c396c951a0452080df0e964e9ea366ae4237acdc3302f7", + "https://deno.land/x/grammy@v1.30.0/convenience/session.ts": "f0ce5742f7938aae146946c8b84d29540d20f592193ebfc217cb79e6e1af33fe", + "https://deno.land/x/grammy@v1.30.0/convenience/webhook.ts": "53937566edaa258401a44594a8f750a15983952bd99aa4d21c6722a7aa1d77a9", + "https://deno.land/x/grammy@v1.30.0/core/api.ts": "9671698e5f7aa68a603d34d221be408b3bcb729a3a8c5364254a9a6b68553b46", + "https://deno.land/x/grammy@v1.30.0/core/client.ts": "0830ccfa575f926092431896071e1999de8a5bd829ffa6c21c1315d58227b51f", + "https://deno.land/x/grammy@v1.30.0/core/error.ts": "5245f18f273da6be364f525090c24d2e9a282c180904f674f66946f2b2556247", + "https://deno.land/x/grammy@v1.30.0/core/payload.ts": "420e17c3c2830b5576ea187cfce77578fe09f1204b25c25ea2f220ca7c86e73b", + "https://deno.land/x/grammy@v1.30.0/filter.ts": "dd30fa957e37a0857e471157bc5a21a13b8f5c3d145b512679aefb3ff5174670", + "https://deno.land/x/grammy@v1.30.0/mod.ts": "7723e08709ff7fd01df3e463503e14e4fd1a581669380eed70351e1121e8a833", + "https://deno.land/x/grammy@v1.30.0/platform.deno.ts": "68272a7e1d9a2d74d8a45342526485dbc0531dee812f675d7f8a4e7fc8393028", + "https://deno.land/x/grammy@v1.30.0/types.deno.ts": "e2b54fac56d6be3aab3fd240cdb4c8b9a72e226317a5dc073d07761d792159a8", + "https://deno.land/x/grammy@v1.30.0/types.ts": "729415590dfa188dbe924dea614dff4e976babdbabb28a307b869fc25777cdf0", + "https://deno.land/x/grammy_types@v3.14.0/api.ts": "ae04d6628e3d25ae805bc07a19475065044fc44cde0a40877405bc3544d03a5f", + "https://deno.land/x/grammy_types@v3.14.0/inline.ts": "ef999d5131968bdc49c0b74d2b0ff94fca310edfa06b02fe1ae1ac2949156934", + "https://deno.land/x/grammy_types@v3.14.0/langs.ts": "5f5fd09c58ba3ae942dd7cea2696f95587d2032c1829bba4bca81762b7ef73b6", + "https://deno.land/x/grammy_types@v3.14.0/manage.ts": "2bf2395311dcfdbb97d3227048eb877cac92d2a247563bb0e4f2e45acd4f99b1", + "https://deno.land/x/grammy_types@v3.14.0/markup.ts": "7430abcea68d294df73a433f621a37cf1281718d3e29e903ed1e474038c7489d", + "https://deno.land/x/grammy_types@v3.14.0/message.ts": "4054128d05fd2b7ea77fea47f995e5c4d268b7150bc7316f3f03fc203e6567ae", + "https://deno.land/x/grammy_types@v3.14.0/methods.ts": "8b6f31f5dd827586d0947f9e5e85db919138e55b383159126b29256c5f6d3519", + "https://deno.land/x/grammy_types@v3.14.0/mod.ts": "7ecea1d3f7085d64419b78183039c78d70d655aeaa8b07f118ffbfb823f5b0b7", + "https://deno.land/x/grammy_types@v3.14.0/passport.ts": "19820e7d6c279521f8bc8912d6a378239f73d4ab525453808994b5f44ef95215", + "https://deno.land/x/grammy_types@v3.14.0/payment.ts": "13a80dd951efee47caad8d71dc8efd293247a42ef4bfff587ca64316ff0d638e", + "https://deno.land/x/grammy_types@v3.14.0/settings.ts": "f8ff810da6f1007ed24cd504809bf46820229c395ff9bfc3e5c8ceaef5b2aae1", + "https://deno.land/x/grammy_types@v3.14.0/update.ts": "71cc0d5ec860149b71415ba03282b1d7edd0466b36e2789521a3b3a3d7796493" + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1" + ] + } +} diff --git a/external/safebooru.ts b/external/safebooru.ts new file mode 100644 index 0000000..4da2ba5 --- /dev/null +++ b/external/safebooru.ts @@ -0,0 +1,70 @@ +import { ReqUrlBuilder } from "../utils/url_builder.ts"; + +export const DEFAULT_POSTS_LIMIT = 30 +export const DEFAULT_START_PAGE_ID = 0 +export const SAFEBOORU_HOST = "safebooru.org" + +export type GetSafebooruPostsFunc = (limit: number, pageId: number, tags: string[]) => Promise +interface SafebooruPostData { + preview_url: string; + sample_url: string; + file_url: string; + directory: number; + hash: string; + width: number; + height: number; + id: number; + image: string; + change: number; + owner: string; + parent_id: number; + rating: string; + sample: boolean; + sample_height: number; + sample_width: number; + score: number | null; + tags: string; + source: string; + status: string; + has_notes: boolean; + comment_count: number; +} + + +const tagsArrToUrlParam = (tags: string[]): string => { + if (tags.length > 0) { + let hasNonEmpty = false + for (let i = 0; i < tags.length; i++) { + if (tags[i].length > 0) { + hasNonEmpty = true + break + } + } + if (!hasNonEmpty) return "" + + let param: string = "tags=" + tags.forEach((tag, i) => { + if (i != 0) { param += " " } + param += tag + }) + return param + } + return "" +} +const mkPostsLimitParam = (limit: number): string => `limit=${limit}` +const mkPageIdParam = (pid: number): string => `pid=${pid}` + + +export const getSafebooruPosts = async (limit: number, pageId: number, tags: string[]): Promise => { + const reqURL = new ReqUrlBuilder(`${SAFEBOORU_HOST}`).setProtocol("https").setPath("/index.php") + .setParams("page=dapi", "s=post", "q=index", "json=1") // Default for safebooru API + .setParams(mkPostsLimitParam(limit), mkPageIdParam(pageId)) + .setParams(tagsArrToUrlParam(tags)).getReqURL() + + const resp = await fetch(reqURL) + if (!resp.ok) { return [] } + const respData = await resp.text() + if (respData.length < 3) return [] + const posts: SafebooruPostData[] = JSON.parse(respData) + return posts +} diff --git a/main.ts b/main.ts new file mode 100644 index 0000000..173f0ad --- /dev/null +++ b/main.ts @@ -0,0 +1,22 @@ +import { runBot } from "./bot/bot.ts"; +import { init as initNormalMode } from "./bot/normal_mode/init.ts" +import { setupDB } from "./repo/exports.ts"; +import { reloadConfig } from "./config.ts"; +import { BotMode } from "./cfg/bot.ts"; + +const main = async () => { + const cfg = reloadConfig() + const { botCfg } = cfg + const { db } = await setupDB(cfg) + + runBot(botCfg, (bot) => { + switch (botCfg.mode) { + case BotMode.setup: + initNormalMode(bot, db, cfg) + break; + case BotMode.normal: + break; + } + }) +} +if (import.meta.main) { main() } diff --git a/migrations/1.init.ts b/migrations/1.init.ts new file mode 100644 index 0000000..4eb250c --- /dev/null +++ b/migrations/1.init.ts @@ -0,0 +1,40 @@ +import { Kysely, sql } from 'npm:kysely' + +// Just a shortcut cuz my monitor is small +const tg_id = 'users.tg_id' + +export async function up(db: Kysely): Promise { + await db.schema.createTable('users') + .addColumn('id', 'uuid', + col => col.defaultTo(sql`gen_random_uuid()`).primaryKey().onDelete('cascade')) + .addColumn('joined_byreferal_from_user_id', 'uuid', + col => col.defaultTo(sql`gen_random_uuid()`).primaryKey().onDelete('cascade')) + .addColumn('tg_id', 'bigint', + col => col) + .addColumn('is_chat_participant', 'boolean', + col => col.defaultTo(false).notNull()) + .addColumn('is_captcha_passed', 'boolean', + col => col.defaultTo(false).notNull()) + .addColumn('is_timeout', 'boolean', + col => col.defaultTo(false).notNull()) + .addColumn('is_banned', 'boolean', + col => col.defaultTo(false).notNull()) + .addColumn('created_at', 'timestamp', + col => col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()) + .addColumn('joined_chat_at', 'timestamp', + col => col) + .addColumn('timeout_until', 'timestamp', + col => col) + .execute() + + await db.schema.createTable('invite_links') + .addColumn('link', 'text', col => col.primaryKey()) + .addColumn('expect_user_tg_id', 'bigint', col => col.references(tg_id).notNull()) + .execute() +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable("users").execute() + await db.schema.dropTable("joined_by_referal").execute() + await db.schema.dropTable("invite_link").execute() +} \ No newline at end of file diff --git a/repo/exports.ts b/repo/exports.ts new file mode 100644 index 0000000..62fa42a --- /dev/null +++ b/repo/exports.ts @@ -0,0 +1,7 @@ +export { setupDB } from "./setup_db.ts"; +export type { + UserID, User, + TableUsers, + SelectUser, InsertUser, UpdateUser, + Database +} from "./scheme.ts" \ No newline at end of file diff --git a/repo/scheme.ts b/repo/scheme.ts new file mode 100644 index 0000000..1eb4e5d --- /dev/null +++ b/repo/scheme.ts @@ -0,0 +1,35 @@ +import { ColumnType, Insertable, Selectable, Updateable } from "npm:kysely"; +import { UserID } from "../core/entities.ts"; + +type DateOrStr = Date | string +type WrittableOnce = ColumnType + +export interface TableUsers extends UserID { + tg_id: number | null + joined_byreferal_from_user_id: WrittableOnce + is_chat_participant: boolean + is_captcha_passed: boolean + is_timeout: boolean + is_banned: boolean + created_at: ColumnType + joined_chat_at: ColumnType + timeout_until: ColumnType +} + +export interface TableInviteLinks { + link: WrittableOnce + expect_user_tg_id: WrittableOnce +} + + +export interface Database { + users: TableUsers + inviteLinks: TableInviteLinks +} + +export type SelectUser = Selectable +export type InsertUser = Insertable +export type UpdateUser = Updateable +export type SelectInviteLinks = Selectable +export type InsertInviteLinks = Insertable +export type UpdateInviteLinks = Updateable diff --git a/repo/setup_db.ts b/repo/setup_db.ts new file mode 100644 index 0000000..49881f0 --- /dev/null +++ b/repo/setup_db.ts @@ -0,0 +1,32 @@ +import { Kysely, Migrator, PostgresDialect } from 'npm:kysely' +import { FileMigrationProvider } from "npm:kysely"; +import { Pool } from "npm:pg"; +import { Database } from "./scheme.ts" +import { CompiledConfig } from "../cfg/config.ts"; +import { promises as fs } from 'node:fs' +import * as path from 'node:path' + + +export type SetupDbOutput = { + pool: Pool, + db: Kysely, +} +export const setupDB = async (cfg: CompiledConfig): Promise => { + const pool = new Pool(cfg.pgPoolCfg) + const dialect = new PostgresDialect({ pool }) + const db = new Kysely({ dialect }) + + if (cfg.migrationCfg && cfg.migrationCfg.applyMigrations) { + const migrator = new Migrator({ + db, + provider: new FileMigrationProvider({ + fs, + path, + migrationFolder: cfg.migrationCfg.migrationsPath, + }), + allowUnorderedMigrations: true + }) + await migrator.migrateToLatest() + } + return { pool, db } +} \ No newline at end of file diff --git a/utils/convert.ts b/utils/convert.ts new file mode 100644 index 0000000..51ad019 --- /dev/null +++ b/utils/convert.ts @@ -0,0 +1,9 @@ +export const strToBool = (str: string): boolean | undefined => { + str = str.toLowerCase() + if (str == "y" || str == "yes" || str == "true") { + return true + } else if (str == "n" || str == "no" || str == "false") { + return false + } + return undefined +} \ No newline at end of file diff --git a/utils/env.ts b/utils/env.ts new file mode 100644 index 0000000..4ca460f --- /dev/null +++ b/utils/env.ts @@ -0,0 +1,9 @@ +import { BadConfigErr } from "./errors.ts"; + +export const mustGetEnv = (envName: string): string => { + const env = Deno.env.get(envName) + if (!env) { + throw new BadConfigErr(`required environment variable ${envName} wasn't set`) + } + return env +} diff --git a/utils/errors.ts b/utils/errors.ts new file mode 100644 index 0000000..3cad9c1 --- /dev/null +++ b/utils/errors.ts @@ -0,0 +1,57 @@ +export enum ERR_CODES { + // General + UnknownErr, + Unimplemented, + Other, + + // System/Timeout errors + Canceled, + Timeout, + RemoteServiceErr, + OutOfMemory, + + // Validation/Input errors + InvalidAction, + BadConfig, + InvalidArgument, + OutOfRange, + + // Access errors + PermissionDenied, + Unauthenticated, + + // Entity errors + EntityExists, + EntityNotFound, + Outdated, + + // Not error by itself, but adds some additional + // information to the error in error stack + ErrClarification, +}; + +export const getErrCode = (name: keyof typeof ERR_CODES): ERR_CODES | undefined => { + return ERR_CODES[name]; +} + +export abstract class Err extends Error { + abstract code: ERR_CODES; + get_name() { return this.constructor.name }; +} + +export const getErrStack = (err: Error): Error[] => { + const errStack: Error[] = [] + let thisErr: Error = err + while (thisErr.cause) { + errStack.push(thisErr) + if (thisErr.cause instanceof Error) + thisErr = thisErr.cause + } + return errStack +} + +// =============================== +// == Some useful error classes == +// =============================== + +export class BadConfigErr extends Err { code = ERR_CODES.BadConfig } \ No newline at end of file diff --git a/utils/io.ts b/utils/io.ts new file mode 100644 index 0000000..e371079 --- /dev/null +++ b/utils/io.ts @@ -0,0 +1,11 @@ +export const readJsonFile = async (filePath: string): Promise => { + const decoder = new TextDecoder("utf-8"); + const data = await Deno.readFile(filePath); + return JSON.parse(decoder.decode(data)); +} + +export const readJsonFileSync = (filePath: string): T => { + const decoder = new TextDecoder("utf-8"); + const data = Deno.readFileSync(filePath); + return JSON.parse(decoder.decode(data)); +} \ No newline at end of file diff --git a/utils/url_builder.ts b/utils/url_builder.ts new file mode 100644 index 0000000..0e3f1ea --- /dev/null +++ b/utils/url_builder.ts @@ -0,0 +1,62 @@ +export class ReqUrlBuilder { + protected protocol: string = "https"; + protected host: string = ""; + protected path: string = ""; + protected params: { [key: string]: string } = {}; + + constructor(url: string) { + const [protocol, host] = url.split("://"); + if (host) { + this.protocol = protocol + this.host = host + } else { + this.host = protocol // Default to https if no protocol provided + } + } + + setProtocol(protocol: string): ReqUrlBuilder { + this.protocol = protocol.replace("://", "") + return this + } + setHost(host: string): ReqUrlBuilder { + this.host = host + return this + } + setPath(path: string): ReqUrlBuilder { + this.path = path.startsWith("/") ? path : `/${path}` + return this + } + + private addParam(key: string, value: string): ReqUrlBuilder { + if (key && value) { + this.params[key] = value; + } + return this + } + + setParams(...params: string[]): ReqUrlBuilder { + params.forEach((param) => { + const [key, value] = param.split("=") + if (key && value) { + this.addParam(key, value) + } + }) + return this + } + + setQueryParams(queryParams: { [key: string]: string }): ReqUrlBuilder { + Object.entries(queryParams).forEach(([key, value]) => { + this.addParam(key, value) + }) + return this + } + + getReqURL(): string { + const queryString = Object.keys(this.params) + .map((key) => `${key}=${encodeURIComponent(this.params[key])}`) + .join("&") + return `${this.protocol}://${this.host}${this.path}${ + queryString ? "?" + queryString : "" + }` + } +}