cipher-attacks commited on
Commit
b37ebd1
ยท
1 Parent(s): e112a52
.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)