Initial commit
This commit is contained in:
parent
ebc366d9a5
commit
0bd90dac76
12
DEV-NOTES.md
Normal file
12
DEV-NOTES.md
Normal 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
|
78
README.md
78
README.md
@ -1,3 +1,81 @@
|
|||||||
# mic-bot
|
# 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
31
bot/bot.ts
Normal 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
29
bot/ctx.ts
Normal 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
14
bot/lang/en.ts
Normal 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
30
bot/lang/export.ts
Normal 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
9
bot/lang/hr.ts
Normal 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
14
bot/lang/hu.ts
Normal 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
20
bot/lang/lang_pack.ts
Normal 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
9
bot/lang/pl.ts
Normal 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
14
bot/lang/rs.ts
Normal 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
14
bot/lang/ru.ts
Normal 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
14
bot/lang/ua.ts
Normal 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
107
bot/normal_mode/captcha.ts
Normal 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
10
bot/normal_mode/init.ts
Normal 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
0
bot/setup_mode/init.ts
Normal file
7
cfg/bot.ts
Normal file
7
cfg/bot.ts
Normal 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
117
cfg/config.ts
Normal 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
38
config.ts
Normal 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
22
core/entities.ts
Normal 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
8
deno.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"dev": "deno run --watch main.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@std/assert": "jsr:@std/assert@1"
|
||||||
|
}
|
||||||
|
}
|
199
deno.lock
Normal file
199
deno.lock
Normal 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
70
external/safebooru.ts
vendored
Normal 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
22
main.ts
Normal 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
40
migrations/1.init.ts
Normal 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
7
repo/exports.ts
Normal 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
35
repo/scheme.ts
Normal 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
32
repo/setup_db.ts
Normal 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
9
utils/convert.ts
Normal 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
9
utils/env.ts
Normal 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
57
utils/errors.ts
Normal 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
11
utils/io.ts
Normal 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
62
utils/url_builder.ts
Normal 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 : ""
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user