Spaces:
Running on Zero
Running on Zero
Commit ยท
b37ebd1
1
Parent(s): e112a52
stage 1
Browse files- .env.example +7 -0
- .gitignore +4 -0
- app.py +33 -0
- app/__init__.py +0 -0
- app/bot/__init__.py +0 -0
- app/bot/handlers/__init__.py +11 -0
- app/bot/handlers/gate.py +25 -0
- app/bot/handlers/menu.py +29 -0
- app/bot/handlers/start.py +53 -0
- app/bot/keyboards/__init__.py +0 -0
- app/bot/keyboards/common.py +46 -0
- app/bot/loader.py +16 -0
- app/bot/middlewares/__init__.py +0 -0
- app/bot/middlewares/context.py +33 -0
- app/bot/middlewares/gate.py +32 -0
- app/bot/middlewares/throttle.py +25 -0
- app/config.py +23 -0
- app/core/__init__.py +0 -0
- app/core/catalog.py +3 -0
- app/core/i18n.py +24 -0
- app/core/logging.py +10 -0
- app/db/__init__.py +0 -0
- app/db/base.py +5 -0
- app/db/models/__init__.py +7 -0
- app/db/models/course.py +17 -0
- app/db/models/material.py +22 -0
- app/db/models/pending.py +22 -0
- app/db/models/referral.py +15 -0
- app/db/models/user.py +23 -0
- app/db/repos/__init__.py +0 -0
- app/db/repos/referrals.py +21 -0
- app/db/repos/users.py +24 -0
- app/db/session.py +29 -0
- app/main.py +34 -0
- app/services/__init__.py +0 -0
- app/services/gate.py +34 -0
- app/services/referrals.py +31 -0
- app/services/users.py +27 -0
- locales/am.json +23 -0
- locales/en.json +23 -0
- requirements.txt +5 -0
- tests/test_i18n.py +10 -0
.env.example
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Local dev only. On Hugging Face put these in Settings -> Secrets. NEVER commit .env
|
| 2 |
+
BOT_TOKEN=123456:ABC...
|
| 3 |
+
DATABASE_URL=postgresql://USER:PASSWORD@HOST:6543/postgres
|
| 4 |
+
GATE_CHANNEL=@DBUCentral
|
| 5 |
+
ADMIN_IDS=111111,222222
|
| 6 |
+
DEFAULT_LANG=am
|
| 7 |
+
LOG_LEVEL=INFO
|
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.env
|
| 4 |
+
dev.db
|
app.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Space entrypoint (Gradio SDK).
|
| 2 |
+
|
| 3 |
+
Gradio only serves a tiny status page (also used for uptime pings).
|
| 4 |
+
The Telegram bot runs in a background thread.
|
| 5 |
+
"""
|
| 6 |
+
import asyncio
|
| 7 |
+
import threading
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
try: # ZeroGPU Spaces expect at least one @spaces.GPU function
|
| 12 |
+
import spaces
|
| 13 |
+
|
| 14 |
+
@spaces.GPU
|
| 15 |
+
def _noop():
|
| 16 |
+
return "ok"
|
| 17 |
+
except ImportError: # running locally
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
from app.main import run_bot_forever
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _runner() -> None:
|
| 24 |
+
asyncio.run(run_bot_forever())
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
threading.Thread(target=_runner, daemon=True, name="telegram-bot").start()
|
| 28 |
+
|
| 29 |
+
with gr.Blocks(title="DBU Central Bot") as demo:
|
| 30 |
+
gr.Markdown("### DBU Central Bot is running โ
")
|
| 31 |
+
|
| 32 |
+
if __name__ == "__main__":
|
| 33 |
+
demo.launch()
|
app/__init__.py
ADDED
|
File without changes
|
app/bot/__init__.py
ADDED
|
File without changes
|
app/bot/handlers/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiogram import Router
|
| 2 |
+
|
| 3 |
+
from app.bot.handlers import gate, menu, start
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def build_router() -> Router:
|
| 7 |
+
router = Router()
|
| 8 |
+
router.include_router(gate.router)
|
| 9 |
+
router.include_router(start.router)
|
| 10 |
+
router.include_router(menu.router)
|
| 11 |
+
return router
|
app/bot/handlers/gate.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiogram import F, Router
|
| 2 |
+
from aiogram.types import CallbackQuery
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
|
| 5 |
+
from app.bot.handlers.start import next_step
|
| 6 |
+
from app.core.i18n import t
|
| 7 |
+
from app.db.models import User
|
| 8 |
+
from app.services import gate
|
| 9 |
+
from app.services import referrals as referral_service
|
| 10 |
+
|
| 11 |
+
router = Router()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.callback_query(F.data == "gate:check")
|
| 15 |
+
async def gate_check(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 16 |
+
if not await gate.is_member(call.bot, user.id, fresh=True):
|
| 17 |
+
await call.answer(t(user.lang, "gate_not_yet"), show_alert=True)
|
| 18 |
+
return
|
| 19 |
+
await call.answer()
|
| 20 |
+
if not user.gate_passed:
|
| 21 |
+
user.gate_passed = True
|
| 22 |
+
await referral_service.reward_if_eligible(session, call.bot, user)
|
| 23 |
+
if call.message:
|
| 24 |
+
await call.message.delete()
|
| 25 |
+
await next_step(call.message, user)
|
app/bot/handlers/menu.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiogram import F, Router
|
| 2 |
+
from aiogram.types import Message
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
|
| 5 |
+
from app.bot.keyboards.common import main_menu
|
| 6 |
+
from app.core.i18n import all_translations, t
|
| 7 |
+
from app.db.models import User
|
| 8 |
+
from app.services import referrals as referral_service
|
| 9 |
+
|
| 10 |
+
router = Router()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.message(F.text.in_(all_translations("btn_library") | all_translations("btn_gpa")))
|
| 14 |
+
async def coming_soon(message: Message, user: User) -> None:
|
| 15 |
+
await message.answer(t(user.lang, "soon"))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.message(F.text.in_(all_translations("btn_invite")))
|
| 19 |
+
async def invite(message: Message, user: User, session: AsyncSession) -> None:
|
| 20 |
+
me = await message.bot.me()
|
| 21 |
+
count, points = await referral_service.stats(session, user)
|
| 22 |
+
link = f"https://t.me/{me.username}?start={user.id}"
|
| 23 |
+
await message.answer(t(user.lang, "invite_text", link=link, count=count, points=points))
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@router.message(F.text.in_(all_translations("btn_lang")))
|
| 27 |
+
async def toggle_lang(message: Message, user: User) -> None:
|
| 28 |
+
user.lang = "en" if user.lang == "am" else "am"
|
| 29 |
+
await message.answer(t(user.lang, "lang_changed"), reply_markup=main_menu(user.lang))
|
app/bot/handlers/start.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiogram import F, Router
|
| 2 |
+
from aiogram.filters import CommandStart
|
| 3 |
+
from aiogram.types import CallbackQuery, Message
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.bot.keyboards.common import main_menu, stream_kb, year_kb
|
| 7 |
+
from app.core.catalog import STREAMS, YEARS
|
| 8 |
+
from app.core.i18n import t
|
| 9 |
+
from app.db.models import User
|
| 10 |
+
|
| 11 |
+
router = Router()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def next_step(message: Message, user: User) -> None:
|
| 15 |
+
"""Send whichever onboarding step is still missing, else the main menu."""
|
| 16 |
+
lang = user.lang
|
| 17 |
+
if user.stream is None:
|
| 18 |
+
await message.answer(t(lang, "choose_stream"), reply_markup=stream_kb(lang))
|
| 19 |
+
elif user.year is None:
|
| 20 |
+
await message.answer(t(lang, "choose_year"), reply_markup=year_kb(lang))
|
| 21 |
+
else:
|
| 22 |
+
await message.answer(t(lang, "menu_title"), reply_markup=main_menu(lang))
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@router.message(CommandStart())
|
| 26 |
+
async def cmd_start(message: Message, user: User) -> None:
|
| 27 |
+
await message.answer(t(user.lang, "welcome", name=message.from_user.full_name))
|
| 28 |
+
await next_step(message, user)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.callback_query(F.data.startswith("ob:stream:"))
|
| 32 |
+
async def pick_stream(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 33 |
+
value = call.data.split(":")[2]
|
| 34 |
+
if value in STREAMS:
|
| 35 |
+
user.stream = value
|
| 36 |
+
await call.answer()
|
| 37 |
+
if call.message:
|
| 38 |
+
await call.message.delete()
|
| 39 |
+
await next_step(call.message, user)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.callback_query(F.data.startswith("ob:year:"))
|
| 43 |
+
async def pick_year(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 44 |
+
value = call.data.split(":")[2]
|
| 45 |
+
if value.isdigit() and int(value) in YEARS:
|
| 46 |
+
user.year = int(value)
|
| 47 |
+
await call.answer()
|
| 48 |
+
if call.message:
|
| 49 |
+
await call.message.delete()
|
| 50 |
+
if user.year is not None:
|
| 51 |
+
await call.message.answer(t(user.lang, "onboarded"), reply_markup=main_menu(user.lang))
|
| 52 |
+
else:
|
| 53 |
+
await next_step(call.message, user)
|
app/bot/keyboards/__init__.py
ADDED
|
File without changes
|
app/bot/keyboards/common.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiogram.types import (
|
| 2 |
+
InlineKeyboardButton,
|
| 3 |
+
InlineKeyboardMarkup,
|
| 4 |
+
KeyboardButton,
|
| 5 |
+
ReplyKeyboardMarkup,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
from app.core.catalog import STREAMS, YEARS
|
| 9 |
+
from app.core.i18n import t
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def gate_kb(lang: str, url: str) -> InlineKeyboardMarkup:
|
| 13 |
+
return InlineKeyboardMarkup(
|
| 14 |
+
inline_keyboard=[
|
| 15 |
+
[InlineKeyboardButton(text=t(lang, "gate_join_btn"), url=url)],
|
| 16 |
+
[InlineKeyboardButton(text=t(lang, "gate_check_btn"), callback_data="gate:check")],
|
| 17 |
+
]
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def stream_kb(lang: str) -> InlineKeyboardMarkup:
|
| 22 |
+
return InlineKeyboardMarkup(
|
| 23 |
+
inline_keyboard=[
|
| 24 |
+
[InlineKeyboardButton(text=t(lang, f"stream_{s}"), callback_data=f"ob:stream:{s}")]
|
| 25 |
+
for s in STREAMS
|
| 26 |
+
]
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def year_kb(lang: str) -> InlineKeyboardMarkup:
|
| 31 |
+
buttons = [
|
| 32 |
+
InlineKeyboardButton(text=t(lang, "year_label", n=y), callback_data=f"ob:year:{y}")
|
| 33 |
+
for y in YEARS
|
| 34 |
+
]
|
| 35 |
+
return InlineKeyboardMarkup(inline_keyboard=[buttons[:3], buttons[3:]])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main_menu(lang: str) -> ReplyKeyboardMarkup:
|
| 39 |
+
return ReplyKeyboardMarkup(
|
| 40 |
+
keyboard=[
|
| 41 |
+
[KeyboardButton(text=t(lang, "btn_library"))],
|
| 42 |
+
[KeyboardButton(text=t(lang, "btn_gpa")), KeyboardButton(text=t(lang, "btn_invite"))],
|
| 43 |
+
[KeyboardButton(text=t(lang, "btn_lang"))],
|
| 44 |
+
],
|
| 45 |
+
resize_keyboard=True,
|
| 46 |
+
)
|
app/bot/loader.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from aiogram import Dispatcher
|
| 2 |
+
|
| 3 |
+
from app.bot.handlers import build_router
|
| 4 |
+
from app.bot.middlewares.context import ContextMiddleware
|
| 5 |
+
from app.bot.middlewares.gate import GateMiddleware
|
| 6 |
+
from app.bot.middlewares.throttle import ThrottleMiddleware
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def build_dispatcher() -> Dispatcher:
|
| 10 |
+
dp = Dispatcher()
|
| 11 |
+
for observer in (dp.message, dp.callback_query):
|
| 12 |
+
observer.middleware(ThrottleMiddleware())
|
| 13 |
+
observer.middleware(ContextMiddleware()) # must come before the gate
|
| 14 |
+
observer.middleware(GateMiddleware())
|
| 15 |
+
dp.include_router(build_router())
|
| 16 |
+
return dp
|
app/bot/middlewares/__init__.py
ADDED
|
File without changes
|
app/bot/middlewares/context.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Awaitable, Callable, Optional
|
| 2 |
+
|
| 3 |
+
from aiogram import BaseMiddleware
|
| 4 |
+
from aiogram.types import Message, TelegramObject
|
| 5 |
+
|
| 6 |
+
from app.db.session import SessionFactory
|
| 7 |
+
from app.services import users as users_service
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _referrer_from(event: TelegramObject) -> Optional[int]:
|
| 11 |
+
if isinstance(event, Message) and event.text and event.text.startswith("/start"):
|
| 12 |
+
parts = event.text.split(maxsplit=1)
|
| 13 |
+
if len(parts) == 2 and parts[1].isdigit():
|
| 14 |
+
return int(parts[1])
|
| 15 |
+
return None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ContextMiddleware(BaseMiddleware):
|
| 19 |
+
"""One DB session per update + the current `user` and `lang` injected into handlers."""
|
| 20 |
+
|
| 21 |
+
async def __call__(self, handler: Callable[[TelegramObject, dict], Awaitable[Any]],
|
| 22 |
+
event: TelegramObject, data: dict) -> Any:
|
| 23 |
+
tg = data.get("event_from_user")
|
| 24 |
+
if tg is None or tg.is_bot:
|
| 25 |
+
return await handler(event, data)
|
| 26 |
+
async with SessionFactory() as session:
|
| 27 |
+
user = await users_service.get_or_create(session, tg, _referrer_from(event))
|
| 28 |
+
data.update(session=session, user=user, lang=user.lang)
|
| 29 |
+
if user.is_banned:
|
| 30 |
+
return None
|
| 31 |
+
result = await handler(event, data)
|
| 32 |
+
await session.commit()
|
| 33 |
+
return result
|
app/bot/middlewares/gate.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Awaitable, Callable
|
| 2 |
+
|
| 3 |
+
from aiogram import BaseMiddleware
|
| 4 |
+
from aiogram.types import CallbackQuery, Message, TelegramObject
|
| 5 |
+
|
| 6 |
+
from app.bot.keyboards.common import gate_kb
|
| 7 |
+
from app.config import get_settings
|
| 8 |
+
from app.core.i18n import t
|
| 9 |
+
from app.services import gate
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class GateMiddleware(BaseMiddleware):
|
| 13 |
+
async def __call__(self, handler: Callable[[TelegramObject, dict], Awaitable[Any]],
|
| 14 |
+
event: TelegramObject, data: dict) -> Any:
|
| 15 |
+
tg = data.get("event_from_user")
|
| 16 |
+
user = data.get("user")
|
| 17 |
+
if tg is None or user is None or tg.id in get_settings().admin_id_set:
|
| 18 |
+
return await handler(event, data)
|
| 19 |
+
if isinstance(event, CallbackQuery) and event.data == "gate:check":
|
| 20 |
+
return await handler(event, data)
|
| 21 |
+
if await gate.is_member(data["bot"], tg.id):
|
| 22 |
+
return await handler(event, data)
|
| 23 |
+
|
| 24 |
+
lang = user.lang
|
| 25 |
+
text, kb = t(lang, "gate_text"), gate_kb(lang, gate.channel_url())
|
| 26 |
+
if isinstance(event, Message):
|
| 27 |
+
await event.answer(text, reply_markup=kb)
|
| 28 |
+
elif isinstance(event, CallbackQuery):
|
| 29 |
+
await event.answer()
|
| 30 |
+
if event.message:
|
| 31 |
+
await event.message.answer(text, reply_markup=kb)
|
| 32 |
+
return None
|
app/bot/middlewares/throttle.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import time
|
| 2 |
+
from typing import Any, Awaitable, Callable
|
| 3 |
+
|
| 4 |
+
from aiogram import BaseMiddleware
|
| 5 |
+
from aiogram.types import TelegramObject
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ThrottleMiddleware(BaseMiddleware):
|
| 9 |
+
"""Drop updates from a user who sends faster than `interval` seconds apart."""
|
| 10 |
+
|
| 11 |
+
def __init__(self, interval: float = 0.5) -> None:
|
| 12 |
+
self.interval = interval
|
| 13 |
+
self._last: dict[int, float] = {}
|
| 14 |
+
|
| 15 |
+
async def __call__(self, handler: Callable[[TelegramObject, dict], Awaitable[Any]],
|
| 16 |
+
event: TelegramObject, data: dict) -> Any:
|
| 17 |
+
tg = data.get("event_from_user")
|
| 18 |
+
if tg:
|
| 19 |
+
now = time.monotonic()
|
| 20 |
+
if now - self._last.get(tg.id, 0) < self.interval:
|
| 21 |
+
return None
|
| 22 |
+
self._last[tg.id] = now
|
| 23 |
+
if len(self._last) > 50_000:
|
| 24 |
+
self._last.clear()
|
| 25 |
+
return await handler(event, data)
|
app/config.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from functools import lru_cache
|
| 2 |
+
|
| 3 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class Settings(BaseSettings):
|
| 7 |
+
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
| 8 |
+
|
| 9 |
+
bot_token: str
|
| 10 |
+
database_url: str = "sqlite+aiosqlite:///./dev.db"
|
| 11 |
+
gate_channel: str = "@DBUCentral"
|
| 12 |
+
admin_ids: str = "" # comma separated Telegram user ids
|
| 13 |
+
default_lang: str = "am"
|
| 14 |
+
log_level: str = "INFO"
|
| 15 |
+
|
| 16 |
+
@property
|
| 17 |
+
def admin_id_set(self) -> set[int]:
|
| 18 |
+
return {int(x) for x in self.admin_ids.replace(" ", "").split(",") if x.isdigit()}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@lru_cache
|
| 22 |
+
def get_settings() -> Settings:
|
| 23 |
+
return Settings()
|
app/core/__init__.py
ADDED
|
File without changes
|
app/core/catalog.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Placeholder catalog. Extend later (departments, semesters...).
|
| 2 |
+
STREAMS = ("natural", "social")
|
| 3 |
+
YEARS = (1, 2, 3, 4, 5)
|
app/core/i18n.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
_LOCALES_DIR = Path(__file__).resolve().parents[2] / "locales"
|
| 5 |
+
SUPPORTED = ("am", "en")
|
| 6 |
+
_cache: dict[str, dict[str, str]] = {}
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _load(lang: str) -> dict[str, str]:
|
| 10 |
+
if lang not in _cache:
|
| 11 |
+
with open(_LOCALES_DIR / f"{lang}.json", encoding="utf-8") as f:
|
| 12 |
+
_cache[lang] = json.load(f)
|
| 13 |
+
return _cache[lang]
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def t(lang: str, key: str, **kwargs) -> str:
|
| 17 |
+
lang = lang if lang in SUPPORTED else "am"
|
| 18 |
+
text = _load(lang).get(key) or _load("en").get(key) or key
|
| 19 |
+
return text.format(**kwargs) if kwargs else text
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def all_translations(key: str) -> set[str]:
|
| 23 |
+
"""Same key in every language (used to match localized reply-keyboard buttons)."""
|
| 24 |
+
return {t(lang, key) for lang in SUPPORTED}
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from app.config import get_settings
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def setup_logging() -> None:
|
| 7 |
+
logging.basicConfig(
|
| 8 |
+
level=get_settings().log_level,
|
| 9 |
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
| 10 |
+
)
|
app/db/__init__.py
ADDED
|
File without changes
|
app/db/base.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.orm import DeclarativeBase
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class Base(DeclarativeBase):
|
| 5 |
+
pass
|
app/db/models/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.db.models.course import Course
|
| 2 |
+
from app.db.models.material import Material
|
| 3 |
+
from app.db.models.pending import PendingMaterial
|
| 4 |
+
from app.db.models.referral import Referral
|
| 5 |
+
from app.db.models.user import User
|
| 6 |
+
|
| 7 |
+
__all__ = ["User", "Course", "Material", "Referral", "PendingMaterial"]
|
app/db/models/course.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import Integer, String
|
| 4 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 5 |
+
|
| 6 |
+
from app.db.base import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Course(Base):
|
| 10 |
+
__tablename__ = "courses"
|
| 11 |
+
|
| 12 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 13 |
+
code: Mapped[Optional[str]] = mapped_column(String(32))
|
| 14 |
+
name: Mapped[str] = mapped_column(String(256))
|
| 15 |
+
stream: Mapped[Optional[str]] = mapped_column(String(16))
|
| 16 |
+
year: Mapped[Optional[int]] = mapped_column(Integer)
|
| 17 |
+
semester: Mapped[Optional[int]] = mapped_column(Integer)
|
app/db/models/material.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 6 |
+
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Material(Base):
|
| 11 |
+
"""A file stored as a message in the private library channel (served via copy_message)."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "materials"
|
| 14 |
+
|
| 15 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 16 |
+
course_id: Mapped[Optional[int]] = mapped_column(ForeignKey("courses.id"))
|
| 17 |
+
kind: Mapped[str] = mapped_column(String(16), default="slide") # slide|exam|note|book
|
| 18 |
+
title: Mapped[str] = mapped_column(String(256))
|
| 19 |
+
library_chat_id: Mapped[int] = mapped_column(BigInteger)
|
| 20 |
+
library_message_id: Mapped[int] = mapped_column(BigInteger)
|
| 21 |
+
file_unique_id: Mapped[Optional[str]] = mapped_column(String(128), unique=True)
|
| 22 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
app/db/models/pending.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import BigInteger, DateTime, Integer, String, func
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 6 |
+
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PendingMaterial(Base):
|
| 11 |
+
"""Filled later by the separate collector userbot; approved by an admin."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "pending_materials"
|
| 14 |
+
|
| 15 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 16 |
+
source_chat_id: Mapped[Optional[int]] = mapped_column(BigInteger)
|
| 17 |
+
source_message_id: Mapped[Optional[int]] = mapped_column(BigInteger)
|
| 18 |
+
file_name: Mapped[Optional[str]] = mapped_column(String(256))
|
| 19 |
+
caption: Mapped[Optional[str]] = mapped_column(String(1024))
|
| 20 |
+
file_unique_id: Mapped[Optional[str]] = mapped_column(String(128), unique=True)
|
| 21 |
+
status: Mapped[str] = mapped_column(String(16), default="pending") # pending|approved|rejected
|
| 22 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
app/db/models/referral.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import BigInteger, DateTime, Integer, func
|
| 4 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 5 |
+
|
| 6 |
+
from app.db.base import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Referral(Base):
|
| 10 |
+
__tablename__ = "referrals"
|
| 11 |
+
|
| 12 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
| 13 |
+
inviter_id: Mapped[int] = mapped_column(BigInteger, index=True)
|
| 14 |
+
invitee_id: Mapped[int] = mapped_column(BigInteger, unique=True)
|
| 15 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
app/db/models/user.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import BigInteger, Boolean, DateTime, Integer, String, func
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 6 |
+
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class User(Base):
|
| 11 |
+
__tablename__ = "users"
|
| 12 |
+
|
| 13 |
+
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
| 14 |
+
username: Mapped[Optional[str]] = mapped_column(String(64))
|
| 15 |
+
full_name: Mapped[str] = mapped_column(String(256), default="")
|
| 16 |
+
lang: Mapped[str] = mapped_column(String(2), default="am")
|
| 17 |
+
stream: Mapped[Optional[str]] = mapped_column(String(16))
|
| 18 |
+
year: Mapped[Optional[int]] = mapped_column(Integer)
|
| 19 |
+
referred_by: Mapped[Optional[int]] = mapped_column(BigInteger)
|
| 20 |
+
gate_passed: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 21 |
+
points: Mapped[int] = mapped_column(Integer, default=0)
|
| 22 |
+
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 23 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
app/db/repos/__init__.py
ADDED
|
File without changes
|
app/db/repos/referrals.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import func, select
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 3 |
+
|
| 4 |
+
from app.db.models import Referral
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
async def exists_for_invitee(session: AsyncSession, invitee_id: int) -> bool:
|
| 8 |
+
res = await session.execute(select(Referral.id).where(Referral.invitee_id == invitee_id))
|
| 9 |
+
return res.scalar_one_or_none() is not None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def add(session: AsyncSession, inviter_id: int, invitee_id: int) -> None:
|
| 13 |
+
session.add(Referral(inviter_id=inviter_id, invitee_id=invitee_id))
|
| 14 |
+
await session.flush()
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def count_for_inviter(session: AsyncSession, inviter_id: int) -> int:
|
| 18 |
+
res = await session.execute(
|
| 19 |
+
select(func.count(Referral.id)).where(Referral.inviter_id == inviter_id)
|
| 20 |
+
)
|
| 21 |
+
return int(res.scalar_one())
|
app/db/repos/users.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
|
| 5 |
+
from app.db.models import User
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def get(session: AsyncSession, user_id: int) -> Optional[User]:
|
| 9 |
+
return await session.get(User, user_id)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def create(
|
| 13 |
+
session: AsyncSession,
|
| 14 |
+
*,
|
| 15 |
+
user_id: int,
|
| 16 |
+
username: Optional[str],
|
| 17 |
+
full_name: str,
|
| 18 |
+
lang: str,
|
| 19 |
+
referred_by: Optional[int] = None,
|
| 20 |
+
) -> User:
|
| 21 |
+
user = User(id=user_id, username=username, full_name=full_name, lang=lang, referred_by=referred_by)
|
| 22 |
+
session.add(user)
|
| 23 |
+
await session.flush()
|
| 24 |
+
return user
|
app/db/session.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy.engine import make_url
|
| 2 |
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
| 3 |
+
from sqlalchemy.pool import NullPool
|
| 4 |
+
|
| 5 |
+
from app.config import get_settings
|
| 6 |
+
from app.db.base import Base
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _build_engine():
|
| 10 |
+
url = make_url(get_settings().database_url)
|
| 11 |
+
kwargs: dict = {}
|
| 12 |
+
connect_args: dict = {}
|
| 13 |
+
if url.drivername.startswith("postgresql"):
|
| 14 |
+
# Supabase/Neon: drop ?sslmode=..., force asyncpg, work behind a pooler
|
| 15 |
+
url = url.set(drivername="postgresql+asyncpg", query={})
|
| 16 |
+
connect_args = {"ssl": "require", "statement_cache_size": 0}
|
| 17 |
+
kwargs["poolclass"] = NullPool
|
| 18 |
+
return create_async_engine(url, connect_args=connect_args, **kwargs)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
engine = _build_engine()
|
| 22 |
+
SessionFactory = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def init_db() -> None:
|
| 26 |
+
import app.db.models # noqa: F401 (register tables)
|
| 27 |
+
|
| 28 |
+
async with engine.begin() as conn:
|
| 29 |
+
await conn.run_sync(Base.metadata.create_all)
|
app/main.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
from aiogram import Bot
|
| 5 |
+
from aiogram.client.default import DefaultBotProperties
|
| 6 |
+
from aiogram.enums import ParseMode
|
| 7 |
+
|
| 8 |
+
from app.bot.loader import build_dispatcher
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
from app.core.logging import setup_logging
|
| 11 |
+
from app.db.session import init_db
|
| 12 |
+
|
| 13 |
+
log = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def run_bot_forever() -> None:
|
| 17 |
+
setup_logging()
|
| 18 |
+
await init_db()
|
| 19 |
+
bot = Bot(
|
| 20 |
+
token=get_settings().bot_token,
|
| 21 |
+
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
| 22 |
+
)
|
| 23 |
+
while True:
|
| 24 |
+
try:
|
| 25 |
+
# handle_signals=False because we run in a non-main thread
|
| 26 |
+
await build_dispatcher().start_polling(bot, handle_signals=False)
|
| 27 |
+
break
|
| 28 |
+
except Exception:
|
| 29 |
+
log.exception("Polling crashed; restarting in 5s")
|
| 30 |
+
await asyncio.sleep(5)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
if __name__ == "__main__":
|
| 34 |
+
asyncio.run(run_bot_forever())
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/gate.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Channel-membership gate (bot must be an ADMIN of the gate channel to read members)."""
|
| 2 |
+
import logging
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from aiogram import Bot
|
| 6 |
+
from aiogram.enums import ChatMemberStatus
|
| 7 |
+
|
| 8 |
+
from app.config import get_settings
|
| 9 |
+
|
| 10 |
+
log = logging.getLogger(__name__)
|
| 11 |
+
_OK = {ChatMemberStatus.CREATOR, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.MEMBER}
|
| 12 |
+
_cache: dict[int, float] = {}
|
| 13 |
+
_TTL = 600 # seconds a positive result is trusted
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
async def is_member(bot: Bot, user_id: int, *, fresh: bool = False) -> bool:
|
| 17 |
+
now = time.time()
|
| 18 |
+
if not fresh and _cache.get(user_id, 0) > now:
|
| 19 |
+
return True
|
| 20 |
+
try:
|
| 21 |
+
member = await bot.get_chat_member(get_settings().gate_channel, user_id)
|
| 22 |
+
except Exception as exc: # fail open: a misconfigured gate must not lock everyone out
|
| 23 |
+
log.warning("Gate check failed (is the bot admin of the channel?): %s", exc)
|
| 24 |
+
return True
|
| 25 |
+
ok = member.status in _OK
|
| 26 |
+
if ok:
|
| 27 |
+
_cache[user_id] = now + _TTL
|
| 28 |
+
else:
|
| 29 |
+
_cache.pop(user_id, None)
|
| 30 |
+
return ok
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def channel_url() -> str:
|
| 34 |
+
return f"https://t.me/{get_settings().gate_channel.lstrip('@')}"
|
app/services/referrals.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
|
| 3 |
+
from aiogram import Bot
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.core.i18n import t
|
| 7 |
+
from app.db.models import User
|
| 8 |
+
from app.db.repos import referrals as repo
|
| 9 |
+
from app.db.repos import users as users_repo
|
| 10 |
+
|
| 11 |
+
log = logging.getLogger(__name__)
|
| 12 |
+
REFERRAL_POINTS = 10
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def reward_if_eligible(session: AsyncSession, bot: Bot, invitee: User) -> None:
|
| 16 |
+
"""Called once, when the invitee passes the channel gate (so fake joins don't count)."""
|
| 17 |
+
if not invitee.referred_by or await repo.exists_for_invitee(session, invitee.id):
|
| 18 |
+
return
|
| 19 |
+
inviter = await users_repo.get(session, invitee.referred_by)
|
| 20 |
+
if not inviter:
|
| 21 |
+
return
|
| 22 |
+
await repo.add(session, inviter.id, invitee.id)
|
| 23 |
+
inviter.points += REFERRAL_POINTS
|
| 24 |
+
try:
|
| 25 |
+
await bot.send_message(inviter.id, t(inviter.lang, "referral_reward", points=REFERRAL_POINTS))
|
| 26 |
+
except Exception as exc:
|
| 27 |
+
log.info("Could not notify inviter %s: %s", inviter.id, exc)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
async def stats(session: AsyncSession, user: User) -> tuple[int, int]:
|
| 31 |
+
return await repo.count_for_inviter(session, user.id), user.points
|
app/services/users.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from aiogram.types import User as TgUser
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.config import get_settings
|
| 7 |
+
from app.db.models import User
|
| 8 |
+
from app.db.repos import users as repo
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
async def get_or_create(
|
| 12 |
+
session: AsyncSession, tg: TgUser, referrer_hint: Optional[int] = None
|
| 13 |
+
) -> User:
|
| 14 |
+
user = await repo.get(session, tg.id)
|
| 15 |
+
if user:
|
| 16 |
+
return user
|
| 17 |
+
referred_by = None
|
| 18 |
+
if referrer_hint and referrer_hint != tg.id and await repo.get(session, referrer_hint):
|
| 19 |
+
referred_by = referrer_hint
|
| 20 |
+
return await repo.create(
|
| 21 |
+
session,
|
| 22 |
+
user_id=tg.id,
|
| 23 |
+
username=tg.username,
|
| 24 |
+
full_name=tg.full_name or "",
|
| 25 |
+
lang=get_settings().default_lang,
|
| 26 |
+
referred_by=referred_by,
|
| 27 |
+
)
|
locales/am.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"welcome": "๐ แฐแแ <b>{name}</b>!\nแแฐ <b>DBU Central</b> แฅแแณแ แ แฐแ
แ แแกแข",
|
| 3 |
+
"gate_text": "แฆแฑแ แแแ แแ แแแแชแซ แปแแแฝแแ แญแแแแ ๐\nแจแแซ ยซโ
แฐแแแ
แซแแยป แญแซแแข",
|
| 4 |
+
"gate_join_btn": "๐ข แปแแแ แฐแแแแ",
|
| 5 |
+
"gate_check_btn": "โ
แฐแแแ
แซแแ",
|
| 6 |
+
"gate_not_yet": "แแ แปแแแ แ แแฐแแแแแแข แแแแชแซ แญแแแแแข",
|
| 7 |
+
"choose_stream": "แจแตแแ แแญแ แแญ แแแต?",
|
| 8 |
+
"stream_natural": "๐ฌ แฐแแฅแฎ แณแญแแต",
|
| 9 |
+
"stream_social": "๐ แแ
แ แซแ แณแญแแต",
|
| 10 |
+
"choose_year": "แจแตแแ แแแต แแญ แแแต?",
|
| 11 |
+
"year_label": "{n}แ แแแต",
|
| 12 |
+
"onboarded": "โ
แฐแแแแ แแ! แจแณแฝ แซแแ แแแ แญแแจแกแข",
|
| 13 |
+
"menu_title": "แแ แแแ ๐",
|
| 14 |
+
"btn_library": "๐ แคแฐ-แแปแแแต",
|
| 15 |
+
"btn_gpa": "๐งฎ GPA แแตแซ",
|
| 16 |
+
"btn_invite": "๐ฅ แแฐแ แแฅแ",
|
| 17 |
+
"btn_lang": "๐ แแแ",
|
| 18 |
+
"soon": "๐ง แญแ
แญแแ แ แ
แญแก แญแแฃแแข",
|
| 19 |
+
"invite_text": "๐ แจแฅแญแตแ แแแ แฃ แแแญแฆ\n{link}\n\nแจแแ แแต: <b>{count}</b> แฐแ\nแแฅแฅ: <b>{points}</b>",
|
| 20 |
+
"referral_reward": "๐ แ แฒแต แแฐแ แ แแแญแ แฐแแแ
แแ! +{points} แแฅแฅ",
|
| 21 |
+
"lang_changed": "โ
แแแ แแฐ แ แแญแ แฐแแญแฏแแข",
|
| 22 |
+
"banned": "โ แแแซแ แณแแทแแข"
|
| 23 |
+
}
|
locales/en.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"welcome": "๐ Hi <b>{name}</b>!\nWelcome to <b>DBU Central</b>.",
|
| 3 |
+
"gate_text": "To use the bot, first join our channel ๐\nThen tap ยซโ
I joinedยป.",
|
| 4 |
+
"gate_join_btn": "๐ข Join channel",
|
| 5 |
+
"gate_check_btn": "โ
I joined",
|
| 6 |
+
"gate_not_yet": "You haven't joined the channel yet. Please join first.",
|
| 7 |
+
"choose_stream": "Which stream are you in?",
|
| 8 |
+
"stream_natural": "๐ฌ Natural Science",
|
| 9 |
+
"stream_social": "๐ Social Science",
|
| 10 |
+
"choose_year": "Which year are you in?",
|
| 11 |
+
"year_label": "Year {n}",
|
| 12 |
+
"onboarded": "โ
You're registered! Pick from the menu below.",
|
| 13 |
+
"menu_title": "Main menu ๐",
|
| 14 |
+
"btn_library": "๐ Library",
|
| 15 |
+
"btn_gpa": "๐งฎ GPA calculator",
|
| 16 |
+
"btn_invite": "๐ฅ Invite friends",
|
| 17 |
+
"btn_lang": "๐ Language",
|
| 18 |
+
"soon": "๐ง This section is coming soon.",
|
| 19 |
+
"invite_text": "๐ Your invite link:\n{link}\n\nInvited: <b>{count}</b>\nPoints: <b>{points}</b>",
|
| 20 |
+
"referral_reward": "๐ A new friend joined with your link! +{points} points",
|
| 21 |
+
"lang_changed": "โ
Language changed to English.",
|
| 22 |
+
"banned": "โ Your account is banned."
|
| 23 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiogram>=3.13,<4
|
| 2 |
+
sqlalchemy[asyncio]>=2.0
|
| 3 |
+
asyncpg>=0.29
|
| 4 |
+
aiosqlite>=0.20
|
| 5 |
+
pydantic-settings>=2.2
|
tests/test_i18n.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
LOCALES = Path(__file__).resolve().parents[1] / "locales"
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_same_keys():
|
| 8 |
+
am = json.loads((LOCALES / "am.json").read_text(encoding="utf-8"))
|
| 9 |
+
en = json.loads((LOCALES / "en.json").read_text(encoding="utf-8"))
|
| 10 |
+
assert set(am) == set(en)
|