From 0bd90dac766140dc6ff65db55fa6c330dee6de12 Mon Sep 17 00:00:00 2001
From: Dmitry Anderson <4nd3r5z0n@gmail.com>
Date: Sat, 19 Oct 2024 12:00:35 +0200
Subject: [PATCH] Initial commit
---
DEV-NOTES.md | 12 +++
README.md | 80 ++++++++++++++-
bot/bot.ts | 31 ++++++
bot/ctx.ts | 29 ++++++
bot/lang/en.ts | 14 +++
bot/lang/export.ts | 30 ++++++
bot/lang/hr.ts | 9 ++
bot/lang/hu.ts | 14 +++
bot/lang/lang_pack.ts | 20 ++++
bot/lang/pl.ts | 9 ++
bot/lang/rs.ts | 14 +++
bot/lang/ru.ts | 14 +++
bot/lang/ua.ts | 14 +++
bot/normal_mode/captcha.ts | 107 ++++++++++++++++++++
bot/normal_mode/init.ts | 10 ++
bot/setup_mode/init.ts | 0
cfg/bot.ts | 7 ++
cfg/config.ts | 117 ++++++++++++++++++++++
config.ts | 38 +++++++
core/entities.ts | 22 ++++
deno.json | 8 ++
deno.lock | 199 +++++++++++++++++++++++++++++++++++++
external/safebooru.ts | 70 +++++++++++++
main.ts | 22 ++++
migrations/1.init.ts | 40 ++++++++
repo/exports.ts | 7 ++
repo/scheme.ts | 35 +++++++
repo/setup_db.ts | 32 ++++++
utils/convert.ts | 9 ++
utils/env.ts | 9 ++
utils/errors.ts | 57 +++++++++++
utils/io.ts | 11 ++
utils/url_builder.ts | 62 ++++++++++++
33 files changed, 1151 insertions(+), 1 deletion(-)
create mode 100644 DEV-NOTES.md
create mode 100644 bot/bot.ts
create mode 100644 bot/ctx.ts
create mode 100644 bot/lang/en.ts
create mode 100644 bot/lang/export.ts
create mode 100644 bot/lang/hr.ts
create mode 100644 bot/lang/hu.ts
create mode 100644 bot/lang/lang_pack.ts
create mode 100644 bot/lang/pl.ts
create mode 100644 bot/lang/rs.ts
create mode 100644 bot/lang/ru.ts
create mode 100644 bot/lang/ua.ts
create mode 100644 bot/normal_mode/captcha.ts
create mode 100644 bot/normal_mode/init.ts
create mode 100644 bot/setup_mode/init.ts
create mode 100644 cfg/bot.ts
create mode 100644 cfg/config.ts
create mode 100644 config.ts
create mode 100644 core/entities.ts
create mode 100644 deno.json
create mode 100644 deno.lock
create mode 100644 external/safebooru.ts
create mode 100644 main.ts
create mode 100644 migrations/1.init.ts
create mode 100644 repo/exports.ts
create mode 100644 repo/scheme.ts
create mode 100644 repo/setup_db.ts
create mode 100644 utils/convert.ts
create mode 100644 utils/env.ts
create mode 100644 utils/errors.ts
create mode 100644 utils/io.ts
create mode 100644 utils/url_builder.ts
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 : ""
+ }`
+ }
+}