Initial commit

This commit is contained in:
Dmitry Anderson 2024-10-19 12:00:35 +02:00
parent ebc366d9a5
commit 0bd90dac76
33 changed files with 1151 additions and 1 deletions

12
DEV-NOTES.md Normal file
View File

@ -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

View File

@ -1,3 +1,81 @@
# mic-bot
Telegram chat and inline bot for our community https://t.me/mental_illness_center_bot
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<br>
`@mental_illness_center_bot safebooru <tags>`<br>
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": "<your_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
```

31
bot/bot.ts Normal file
View File

@ -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<Context>;
}
class BotUnknownOnStartErr extends Err {
code: ERR_CODES = ERR_CODES.UnknownErr;
}
export const runBot = async (cfg: config.BotConfig, initMode: (bot: Bot<Ctx>) => void) => {
// Bot initialization & setup
const bot = new Bot<Ctx>(cfg.bot_token)
bot.use(session({ initial: defaultSessionData }))
bot.catch((err: BotError<Context>) => {
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 })
})
}

29
bot/ctx.ts Normal file
View File

@ -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<SessionData>;
export type { SessionData, Ctx }
export { defaultSessionData }

14
bot/lang/en.ts Normal file
View File

@ -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

30
bot/lang/export.ts Normal file
View File

@ -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<string, LangPack>([
[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
}
}

9
bot/lang/hr.ts Normal file
View File

@ -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

14
bot/lang/hu.ts Normal file
View File

@ -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

20
bot/lang/lang_pack.ts Normal file
View File

@ -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,
}
},
}

9
bot/lang/pl.ts Normal file
View File

@ -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

14
bot/lang/rs.ts Normal file
View File

@ -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

14
bot/lang/ru.ts Normal file
View File

@ -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

14
bot/lang/ua.ts Normal file
View File

@ -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

107
bot/normal_mode/captcha.ts Normal file
View File

@ -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<Ctx>) => {
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<Ctx, "message:text">) => {
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<Ctx, "message:text">): Promise<boolean> => {
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, ":new_chat_members">) => {
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 }

10
bot/normal_mode/init.ts Normal file
View File

@ -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<Ctx>, db: Kysely<Database>, cfg: CompiledConfig) => {
const { botCfg } = cfg
}

0
bot/setup_mode/init.ts Normal file
View File

7
cfg/bot.ts Normal file
View File

@ -0,0 +1,7 @@
export enum BotMode{ setup, normal }
export type BotConfig = {
mode: BotMode,
bot_token: string,
chat_id: number,
admin_ids: number[],
}

117
cfg/config.ts Normal file
View File

@ -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<FileConfig>(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"

38
config.ts Normal file
View File

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

22
core/entities.ts Normal file
View File

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

8
deno.json Normal file
View File

@ -0,0 +1,8 @@
{
"tasks": {
"dev": "deno run --watch main.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@1"
}
}

199
deno.lock Normal file
View File

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

70
external/safebooru.ts vendored Normal file
View File

@ -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<SafebooruPostData[]>
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<SafebooruPostData[]> => {
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
}

22
main.ts Normal file
View File

@ -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() }

40
migrations/1.init.ts Normal file
View File

@ -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<any>): Promise<void> {
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<any>): Promise<void> {
await db.schema.dropTable("users").execute()
await db.schema.dropTable("joined_by_referal").execute()
await db.schema.dropTable("invite_link").execute()
}

7
repo/exports.ts Normal file
View File

@ -0,0 +1,7 @@
export { setupDB } from "./setup_db.ts";
export type {
UserID, User,
TableUsers,
SelectUser, InsertUser, UpdateUser,
Database
} from "./scheme.ts"

35
repo/scheme.ts Normal file
View File

@ -0,0 +1,35 @@
import { ColumnType, Insertable, Selectable, Updateable } from "npm:kysely";
import { UserID } from "../core/entities.ts";
type DateOrStr = Date | string
type WrittableOnce<type> = ColumnType<type, type, never>
export interface TableUsers extends UserID {
tg_id: number | null
joined_byreferal_from_user_id: WrittableOnce<string | null>
is_chat_participant: boolean
is_captcha_passed: boolean
is_timeout: boolean
is_banned: boolean
created_at: ColumnType<Date, DateOrStr, never>
joined_chat_at: ColumnType<Date | null, DateOrStr | null, DateOrStr>
timeout_until: ColumnType<Date | null, DateOrStr | null, DateOrStr | null>
}
export interface TableInviteLinks {
link: WrittableOnce<string>
expect_user_tg_id: WrittableOnce<number>
}
export interface Database {
users: TableUsers
inviteLinks: TableInviteLinks
}
export type SelectUser = Selectable<TableUsers>
export type InsertUser = Insertable<TableUsers>
export type UpdateUser = Updateable<TableUsers>
export type SelectInviteLinks = Selectable<TableInviteLinks>
export type InsertInviteLinks = Insertable<TableInviteLinks>
export type UpdateInviteLinks = Updateable<TableInviteLinks>

32
repo/setup_db.ts Normal file
View File

@ -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<Database>,
}
export const setupDB = async (cfg: CompiledConfig): Promise<SetupDbOutput> => {
const pool = new Pool(cfg.pgPoolCfg)
const dialect = new PostgresDialect({ pool })
const db = new Kysely<Database>({ 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 }
}

9
utils/convert.ts Normal file
View File

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

9
utils/env.ts Normal file
View File

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

57
utils/errors.ts Normal file
View File

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

11
utils/io.ts Normal file
View File

@ -0,0 +1,11 @@
export const readJsonFile = async <T>(filePath: string): Promise<T> => {
const decoder = new TextDecoder("utf-8");
const data = await Deno.readFile(filePath);
return JSON.parse(decoder.decode(data));
}
export const readJsonFileSync = <T>(filePath: string): T => {
const decoder = new TextDecoder("utf-8");
const data = Deno.readFileSync(filePath);
return JSON.parse(decoder.decode(data));
}

62
utils/url_builder.ts Normal file
View File

@ -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 : ""
}`
}
}