Spaces:
Running on Zero
Running on Zero
Commit ยท
c3b9247
1
Parent(s): 663fdd6
natural amharic
Browse files- app/bot/delivery.py +73 -0
- app/bot/handlers/__init__.py +2 -1
- app/bot/handlers/admin.py +36 -0
- app/bot/handlers/gate.py +3 -0
- app/bot/handlers/groups.py +75 -0
- app/bot/handlers/library.py +3 -8
- app/bot/handlers/menu.py +8 -2
- app/bot/handlers/search.py +1 -0
- app/bot/handlers/start.py +13 -10
- app/bot/keyboards/common.py +9 -0
- app/bot/middlewares/context.py +12 -4
- app/bot/middlewares/gate.py +5 -1
- app/db/migrate.py +3 -0
- app/db/models/__init__.py +2 -1
- app/db/models/group.py +18 -0
- app/db/models/user.py +3 -0
- app/db/repos/groups.py +20 -0
- app/db/repos/stats.py +37 -2
- app/db/repos/users.py +2 -0
- app/services/growth.py +42 -0
- app/services/library.py +1 -0
- app/services/poster.py +56 -0
- app/services/users.py +3 -1
- locales/am.json +8 -1
- locales/en.json +8 -1
- tests/test_logic.py +33 -0
app/bot/delivery.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sending things to a student from anywhere (button, deep link, after onboarding)."""
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
from aiogram import Bot
|
| 5 |
+
from aiogram.exceptions import TelegramBadRequest
|
| 6 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
+
|
| 8 |
+
from app.bot.keyboards import library as lib_kb
|
| 9 |
+
from app.bot.keyboards.common import share_kb
|
| 10 |
+
from app.core import season
|
| 11 |
+
from app.core.i18n import t
|
| 12 |
+
from app.db.models import Material, User
|
| 13 |
+
from app.db.repos import library as repo
|
| 14 |
+
from app.services import growth
|
| 15 |
+
from app.services import library as lib
|
| 16 |
+
from app.services import xp as xp_service
|
| 17 |
+
|
| 18 |
+
log = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def _maybe_ask_to_share(bot: Bot, user: User, chat_id: int) -> None:
|
| 22 |
+
"""At the moment a student just got something useful, suggest sharing: at most once a day,
|
| 23 |
+
never on their first day."""
|
| 24 |
+
today = season.day_key()
|
| 25 |
+
if (user.active_days or 0) < 2 or user.share_prompt_day == today:
|
| 26 |
+
return
|
| 27 |
+
user.share_prompt_day = today
|
| 28 |
+
me = await bot.me()
|
| 29 |
+
link = growth.deep_link(me.username, str(user.id)) # their personal referral link
|
| 30 |
+
url = growth.share_url(link, t(user.lang, "share_text"))
|
| 31 |
+
try:
|
| 32 |
+
await bot.send_message(chat_id, t(user.lang, "share_prompt"), reply_markup=share_kb(user.lang, url))
|
| 33 |
+
except Exception as exc:
|
| 34 |
+
log.info("Could not send the share nudge: %r", exc)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def deliver_material(bot: Bot, session: AsyncSession, user: User, material: Material, chat_id: int) -> bool:
|
| 38 |
+
course = await repo.get_course(session, material.course_id) if material.course_id else None
|
| 39 |
+
try:
|
| 40 |
+
await lib.send_material(bot, chat_id, material, course)
|
| 41 |
+
except TelegramBadRequest:
|
| 42 |
+
return False
|
| 43 |
+
material.downloads = (material.downloads or 0) + 1
|
| 44 |
+
res = await xp_service.award(session, user, "download", 1, ref=f"{material.id}:{season.day_key()}")
|
| 45 |
+
if res.leveled_up:
|
| 46 |
+
await xp_service.announce_level(bot, user, res.new_level)
|
| 47 |
+
await _maybe_ask_to_share(bot, user, chat_id)
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
async def deliver_link(bot: Bot, session: AsyncSession, user: User, chat_id: int, kind: str, value: str) -> bool:
|
| 52 |
+
"""Open what a shared link points at (a course or one file). False = nothing to show."""
|
| 53 |
+
if kind == "course":
|
| 54 |
+
course, counts = await lib.kinds_of(session, int(value))
|
| 55 |
+
if course and any(counts.values()):
|
| 56 |
+
await bot.send_message(
|
| 57 |
+
chat_id, t(user.lang, "lib_kinds", course=course.name), reply_markup=lib_kb.kinds(user.lang, course.id, counts)
|
| 58 |
+
)
|
| 59 |
+
return True
|
| 60 |
+
elif kind == "file":
|
| 61 |
+
material = await repo.get_material(session, int(value))
|
| 62 |
+
if material and material.file_id and material.status == "ok":
|
| 63 |
+
return await deliver_material(bot, session, user, material, chat_id)
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
async def deliver_pending(bot: Bot, session: AsyncSession, user: User, chat_id: int) -> None:
|
| 68 |
+
"""The link someone opened before they had joined / finished onboarding."""
|
| 69 |
+
if not user.pending_link:
|
| 70 |
+
return
|
| 71 |
+
kind, value = growth.parse_start_payload(user.pending_link)
|
| 72 |
+
user.pending_link = None
|
| 73 |
+
await deliver_link(bot, session, user, chat_id, kind, value)
|
app/bot/handlers/__init__.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from aiogram import Router
|
| 2 |
|
| 3 |
-
from app.bot.handlers import admin, gate, inline, library, menu, search, start
|
| 4 |
|
| 5 |
|
| 6 |
def build_router() -> Router:
|
|
@@ -8,6 +8,7 @@ def build_router() -> Router:
|
|
| 8 |
# order matters: specific handlers first, plain-text search fallback LAST
|
| 9 |
router.include_router(gate.router)
|
| 10 |
router.include_router(admin.router)
|
|
|
|
| 11 |
router.include_router(start.router)
|
| 12 |
router.include_router(menu.router)
|
| 13 |
router.include_router(library.router)
|
|
|
|
| 1 |
from aiogram import Router
|
| 2 |
|
| 3 |
+
from app.bot.handlers import admin, gate, groups, inline, library, menu, search, start
|
| 4 |
|
| 5 |
|
| 6 |
def build_router() -> Router:
|
|
|
|
| 8 |
# order matters: specific handlers first, plain-text search fallback LAST
|
| 9 |
router.include_router(gate.router)
|
| 10 |
router.include_router(admin.router)
|
| 11 |
+
router.include_router(groups.router)
|
| 12 |
router.include_router(start.router)
|
| 13 |
router.include_router(menu.router)
|
| 14 |
router.include_router(library.router)
|
app/bot/handlers/admin.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
import html
|
| 3 |
import logging
|
| 4 |
import time
|
|
|
|
| 5 |
from uuid import uuid4
|
| 6 |
from typing import Optional
|
| 7 |
|
|
@@ -19,6 +20,7 @@ from app.db.repos import library as repo
|
|
| 19 |
from app.db.repos import stats as stats_repo
|
| 20 |
from app.core import season as clock
|
| 21 |
from app.db.repos import users as users_repo
|
|
|
|
| 22 |
from app.services import library as library_service
|
| 23 |
from app.services import smart_ingest
|
| 24 |
from app.services import watermark as wm
|
|
@@ -46,6 +48,8 @@ HELP = (
|
|
| 46 |
"/seedcourses : add the Year-1 course list (safe to run twice)\n"
|
| 47 |
"/courses : list courses with ids\n"
|
| 48 |
"/delcourse <id> [move_files_to_id] : delete a course (refuses if it still has files)\n"
|
|
|
|
|
|
|
| 49 |
"/emptycourses : list courses with no files, with a button to delete them all\n"
|
| 50 |
"/gatecheck : is the bot admin in every join-gate channel/group?\n"
|
| 51 |
"/stats : numbers\n"
|
|
@@ -201,6 +205,38 @@ async def del_course(message: Message, command: CommandObject, session: AsyncSes
|
|
| 201 |
await message.answer(f"๐ Deleted course #{cid} โ{course.name}โ{moved}.")
|
| 202 |
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
@router.message(Command("emptycourses"))
|
| 205 |
async def empty_courses(message: Message, session: AsyncSession) -> None:
|
| 206 |
items = await repo.empty_courses(session)
|
|
|
|
| 2 |
import html
|
| 3 |
import logging
|
| 4 |
import time
|
| 5 |
+
from datetime import datetime, timedelta, timezone
|
| 6 |
from uuid import uuid4
|
| 7 |
from typing import Optional
|
| 8 |
|
|
|
|
| 20 |
from app.db.repos import stats as stats_repo
|
| 21 |
from app.core import season as clock
|
| 22 |
from app.db.repos import users as users_repo
|
| 23 |
+
from app.services import growth
|
| 24 |
from app.services import library as library_service
|
| 25 |
from app.services import smart_ingest
|
| 26 |
from app.services import watermark as wm
|
|
|
|
| 48 |
"/seedcourses : add the Year-1 course list (safe to run twice)\n"
|
| 49 |
"/courses : list courses with ids\n"
|
| 50 |
"/delcourse <id> [move_files_to_id] : delete a course (refuses if it still has files)\n"
|
| 51 |
+
"/growth : where new students come from + a funnel per source\n"
|
| 52 |
+
"/poster [tag] : printable A4 poster with a QR code (tag = where you will put it, e.g. dorm5)\n"
|
| 53 |
"/emptycourses : list courses with no files, with a button to delete them all\n"
|
| 54 |
"/gatecheck : is the bot admin in every join-gate channel/group?\n"
|
| 55 |
"/stats : numbers\n"
|
|
|
|
| 205 |
await message.answer(f"๐ Deleted course #{cid} โ{course.name}โ{moved}.")
|
| 206 |
|
| 207 |
|
| 208 |
+
@router.message(Command("growth"))
|
| 209 |
+
async def growth_report(message: Message, session: AsyncSession) -> None:
|
| 210 |
+
since = datetime.now(timezone.utc) - timedelta(days=7)
|
| 211 |
+
r = await stats_repo.growth_report(session, since)
|
| 212 |
+
days = " ".join(f"{d[5:]}: <b>{n}</b>" for d, n in r["by_day"]) or "no new users"
|
| 213 |
+
lines = [
|
| 214 |
+
"๐ <b>Growth</b>",
|
| 215 |
+
f"Total users: <b>{r['total']}</b> ยท groups using the bot: <b>{r['groups']}</b>",
|
| 216 |
+
f"New users per day (7 days): {days}",
|
| 217 |
+
"\n<b>Where they come from</b> (joined โ passed gate โ onboarded โ active 3+ days)",
|
| 218 |
+
]
|
| 219 |
+
for source, joined, gate_ok, onboarded, active in r["by_source"]:
|
| 220 |
+
lines.append(f"โข {source}: {joined} โ {gate_ok} โ {onboarded} โ {active}")
|
| 221 |
+
lines.append("\nMake a new trackable link by putting a tag after ?start= , e.g. t.me/YourBot?start=dorm5")
|
| 222 |
+
await message.answer("\n".join(lines))
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
@router.message(Command("poster"))
|
| 226 |
+
async def poster(message: Message, command: CommandObject) -> None:
|
| 227 |
+
from app.services.poster import make_poster_pdf
|
| 228 |
+
|
| 229 |
+
me = await message.bot.me()
|
| 230 |
+
tag = growth.safe_tag(command.args)
|
| 231 |
+
link = growth.deep_link(me.username, tag)
|
| 232 |
+
pdf = make_poster_pdf(link, me.username, get_settings().watermark_link)
|
| 233 |
+
await message.answer_document(
|
| 234 |
+
BufferedInputFile(pdf, filename=f"poster-{tag}.pdf"),
|
| 235 |
+
caption=f"๐จ Print it. The QR opens {link}\nSource tag: <b>{tag}</b>. See who came from it in /growth. "
|
| 236 |
+
"One poster per place works best: /poster dorm5, /poster cafeteria โฆ",
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
@router.message(Command("emptycourses"))
|
| 241 |
async def empty_courses(message: Message, session: AsyncSession) -> None:
|
| 242 |
items = await repo.empty_courses(session)
|
app/bot/handlers/gate.py
CHANGED
|
@@ -2,6 +2,7 @@ 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.aio import gather_safe
|
| 7 |
from app.core.i18n import t
|
|
@@ -23,5 +24,7 @@ async def gate_check(call: CallbackQuery, user: User, session: AsyncSession) ->
|
|
| 23 |
await referral_service.reward_if_eligible(session, call.bot, user)
|
| 24 |
if call.message:
|
| 25 |
await gather_safe(call.answer(), call.message.delete(), next_step(call.message, user))
|
|
|
|
|
|
|
| 26 |
else:
|
| 27 |
await call.answer()
|
|
|
|
| 2 |
from aiogram.types import CallbackQuery
|
| 3 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
|
| 5 |
+
from app.bot import delivery
|
| 6 |
from app.bot.handlers.start import next_step
|
| 7 |
from app.core.aio import gather_safe
|
| 8 |
from app.core.i18n import t
|
|
|
|
| 24 |
await referral_service.reward_if_eligible(session, call.bot, user)
|
| 25 |
if call.message:
|
| 26 |
await gather_safe(call.answer(), call.message.delete(), next_step(call.message, user))
|
| 27 |
+
if user.year is not None:
|
| 28 |
+
await delivery.deliver_pending(call.bot, session, user, call.from_user.id)
|
| 29 |
else:
|
| 30 |
await call.answer()
|
app/bot/handlers/groups.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The bot inside class / department groups. Only reacts to being added and to /find:
|
| 2 |
+
no scanning of messages, no unsolicited DMs. Results open in the bot (private), so every new
|
| 3 |
+
student passes the join-gate and onboarding."""
|
| 4 |
+
import logging
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from aiogram import F, Router
|
| 8 |
+
from aiogram.enums import ChatMemberStatus
|
| 9 |
+
from aiogram.filters import Command, CommandObject
|
| 10 |
+
from aiogram.types import ChatMemberUpdated, InlineKeyboardButton, InlineKeyboardMarkup, Message
|
| 11 |
+
|
| 12 |
+
from app.bot.keyboards.common import open_bot_kb
|
| 13 |
+
from app.bot.keyboards.library import clip, course_icon, course_label
|
| 14 |
+
from app.core.i18n import t
|
| 15 |
+
from app.db.repos import groups as group_repo
|
| 16 |
+
from app.db.session import SessionFactory
|
| 17 |
+
from app.services import growth
|
| 18 |
+
from app.services import library as lib
|
| 19 |
+
from app.services.classifier import display_title
|
| 20 |
+
|
| 21 |
+
log = logging.getLogger(__name__)
|
| 22 |
+
router = Router()
|
| 23 |
+
GROUP_TYPES = {"group", "supergroup"}
|
| 24 |
+
_last_find: dict[int, float] = {}
|
| 25 |
+
_GONE = (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED)
|
| 26 |
+
_IN = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.my_chat_member()
|
| 30 |
+
async def bot_status_changed(event: ChatMemberUpdated) -> None:
|
| 31 |
+
chat = event.chat
|
| 32 |
+
if chat.type not in GROUP_TYPES:
|
| 33 |
+
return
|
| 34 |
+
now_in = event.new_chat_member.status in _IN
|
| 35 |
+
just_joined = now_in and event.old_chat_member.status in _GONE
|
| 36 |
+
async with SessionFactory() as session:
|
| 37 |
+
await group_repo.upsert(session, chat.id, chat.title, active=now_in)
|
| 38 |
+
await session.commit()
|
| 39 |
+
if just_joined:
|
| 40 |
+
me = await event.bot.me()
|
| 41 |
+
try:
|
| 42 |
+
await event.bot.send_message(
|
| 43 |
+
chat.id, t("am", "group_welcome"), reply_markup=open_bot_kb(growth.deep_link(me.username, "group"))
|
| 44 |
+
)
|
| 45 |
+
except Exception as exc:
|
| 46 |
+
log.info("Could not greet group %s: %r", chat.id, exc)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.message(Command("find"), F.chat.type.in_(GROUP_TYPES))
|
| 50 |
+
async def find_in_group(message: Message, command: CommandObject) -> None:
|
| 51 |
+
now = time.monotonic()
|
| 52 |
+
if now - _last_find.get(message.chat.id, 0) < 4: # one answer per chat every 4s: no flooding
|
| 53 |
+
return
|
| 54 |
+
_last_find[message.chat.id] = now
|
| 55 |
+
me = await message.bot.me()
|
| 56 |
+
open_link = growth.deep_link(me.username, "group")
|
| 57 |
+
query = (command.args or "").strip()
|
| 58 |
+
if len(query) < 2:
|
| 59 |
+
await message.reply(t("am", "group_find_usage"), reply_markup=open_bot_kb(open_link))
|
| 60 |
+
return
|
| 61 |
+
async with SessionFactory() as session:
|
| 62 |
+
courses, materials = await lib.search(session, query, course_limit=4, material_limit=4)
|
| 63 |
+
if not courses and not materials:
|
| 64 |
+
await message.reply(t("am", "group_find_none"), reply_markup=open_bot_kb(open_link))
|
| 65 |
+
return
|
| 66 |
+
rows = [
|
| 67 |
+
[InlineKeyboardButton(text=f"{course_icon(c)} {course_label(c)}", url=growth.deep_link(me.username, f"c{c.id}"))]
|
| 68 |
+
for c in courses
|
| 69 |
+
]
|
| 70 |
+
rows += [
|
| 71 |
+
[InlineKeyboardButton(text=f"๐ {clip(display_title(m.title))}", url=growth.deep_link(me.username, f"f{m.id}"))]
|
| 72 |
+
for m in materials
|
| 73 |
+
]
|
| 74 |
+
safe_q = query[:40].replace("<", "").replace(">", "").replace("&", "")
|
| 75 |
+
await message.reply(t("am", "group_find_result", q=safe_q), reply_markup=InlineKeyboardMarkup(inline_keyboard=rows))
|
app/bot/handlers/library.py
CHANGED
|
@@ -6,6 +6,7 @@ from aiogram.exceptions import TelegramBadRequest
|
|
| 6 |
from aiogram.types import CallbackQuery, Message
|
| 7 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
|
|
|
|
| 9 |
from app.bot.keyboards import library as kb
|
| 10 |
from app.core import season
|
| 11 |
from app.core.aio import gather_results, gather_safe
|
|
@@ -18,6 +19,7 @@ from app.services import xp as xp_service
|
|
| 18 |
|
| 19 |
log = logging.getLogger(__name__)
|
| 20 |
router = Router()
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
async def _show(target: Union[Message, CallbackQuery], text: str, markup) -> None:
|
|
@@ -99,13 +101,6 @@ async def on_file(call: CallbackQuery, user: User, session: AsyncSession) -> Non
|
|
| 99 |
if not material or not material.file_id or material.status != "ok":
|
| 100 |
await call.answer(t(user.lang, "file_missing"), show_alert=True)
|
| 101 |
return
|
| 102 |
-
course = await repo.get_course(session, material.course_id) if material.course_id else None
|
| 103 |
await gather_safe(call.answer(), call.bot.send_chat_action(call.from_user.id, "upload_document"))
|
| 104 |
-
|
| 105 |
-
await lib.send_material(call.bot, call.from_user.id, material, course)
|
| 106 |
-
material.downloads = (material.downloads or 0) + 1
|
| 107 |
-
res = await xp_service.award(session, user, "download", 1, ref=f"{material.id}:{season.day_key()}")
|
| 108 |
-
if res.leveled_up:
|
| 109 |
-
await xp_service.announce_level(call.bot, user, res.new_level)
|
| 110 |
-
except TelegramBadRequest:
|
| 111 |
await call.message.answer(t(user.lang, "file_missing"))
|
|
|
|
| 6 |
from aiogram.types import CallbackQuery, Message
|
| 7 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
|
| 9 |
+
from app.bot import delivery
|
| 10 |
from app.bot.keyboards import library as kb
|
| 11 |
from app.core import season
|
| 12 |
from app.core.aio import gather_results, gather_safe
|
|
|
|
| 19 |
|
| 20 |
log = logging.getLogger(__name__)
|
| 21 |
router = Router()
|
| 22 |
+
router.message.filter(F.chat.type == "private")
|
| 23 |
|
| 24 |
|
| 25 |
async def _show(target: Union[Message, CallbackQuery], text: str, markup) -> None:
|
|
|
|
| 101 |
if not material or not material.file_id or material.status != "ok":
|
| 102 |
await call.answer(t(user.lang, "file_missing"), show_alert=True)
|
| 103 |
return
|
|
|
|
| 104 |
await gather_safe(call.answer(), call.bot.send_chat_action(call.from_user.id, "upload_document"))
|
| 105 |
+
if not await delivery.deliver_material(call.bot, session, user, material, call.from_user.id):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
await call.message.answer(t(user.lang, "file_missing"))
|
app/bot/handlers/menu.py
CHANGED
|
@@ -3,16 +3,18 @@ from aiogram.filters import Command
|
|
| 3 |
from aiogram.types import Message
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
|
| 6 |
-
from app.bot.keyboards.common import main_menu
|
| 7 |
from app.config import get_settings
|
| 8 |
from app.core import season
|
| 9 |
from app.core.i18n import all_translations, t
|
| 10 |
from app.db.models import User
|
| 11 |
from app.db.repos import users as users_repo
|
|
|
|
| 12 |
from app.services import referrals as referral_service
|
| 13 |
from app.services import xp_rules as R
|
| 14 |
|
| 15 |
router = Router()
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
def _first_name(full_name: str) -> str:
|
|
@@ -35,7 +37,11 @@ async def invite(message: Message, user: User, session: AsyncSession) -> None:
|
|
| 35 |
me = await message.bot.me()
|
| 36 |
count, qualified = await referral_service.stats(session, user)
|
| 37 |
link = f"https://t.me/{me.username}?start={user.id}"
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
@router.message(F.text.in_(all_translations("btn_profile")))
|
|
|
|
| 3 |
from aiogram.types import Message
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
|
| 6 |
+
from app.bot.keyboards.common import main_menu, share_kb
|
| 7 |
from app.config import get_settings
|
| 8 |
from app.core import season
|
| 9 |
from app.core.i18n import all_translations, t
|
| 10 |
from app.db.models import User
|
| 11 |
from app.db.repos import users as users_repo
|
| 12 |
+
from app.services import growth
|
| 13 |
from app.services import referrals as referral_service
|
| 14 |
from app.services import xp_rules as R
|
| 15 |
|
| 16 |
router = Router()
|
| 17 |
+
router.message.filter(F.chat.type == "private")
|
| 18 |
|
| 19 |
|
| 20 |
def _first_name(full_name: str) -> str:
|
|
|
|
| 37 |
me = await message.bot.me()
|
| 38 |
count, qualified = await referral_service.stats(session, user)
|
| 39 |
link = f"https://t.me/{me.username}?start={user.id}"
|
| 40 |
+
share = growth.share_url(link, t(user.lang, "share_text"))
|
| 41 |
+
await message.answer(
|
| 42 |
+
t(user.lang, "invite_text", link=link, count=count, qualified=qualified, points=user.points),
|
| 43 |
+
reply_markup=share_kb(user.lang, share),
|
| 44 |
+
)
|
| 45 |
|
| 46 |
|
| 47 |
@router.message(F.text.in_(all_translations("btn_profile")))
|
app/bot/handlers/search.py
CHANGED
|
@@ -8,6 +8,7 @@ from app.db.models import User
|
|
| 8 |
from app.services import library as lib
|
| 9 |
|
| 10 |
router = Router()
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
@router.message(F.text & ~F.text.startswith("/"))
|
|
|
|
| 8 |
from app.services import library as lib
|
| 9 |
|
| 10 |
router = Router()
|
| 11 |
+
router.message.filter(F.chat.type == "private")
|
| 12 |
|
| 13 |
|
| 14 |
@router.message(F.text & ~F.text.startswith("/"))
|
app/bot/handlers/start.py
CHANGED
|
@@ -3,15 +3,16 @@ from aiogram.filters import CommandObject, CommandStart
|
|
| 3 |
from aiogram.types import CallbackQuery, Message
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
|
| 6 |
-
from app.bot
|
| 7 |
from app.bot.keyboards.common import main_menu, stream_kb, year_kb
|
| 8 |
from app.core.aio import gather_safe
|
| 9 |
from app.core.catalog import STREAMS, YEARS
|
| 10 |
from app.core.i18n import t
|
| 11 |
from app.db.models import User
|
| 12 |
-
from app.services import
|
| 13 |
|
| 14 |
router = Router()
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
async def next_step(message: Message, user: User, prefix: str = "") -> None:
|
|
@@ -28,13 +29,14 @@ async def next_step(message: Message, user: User, prefix: str = "") -> None:
|
|
| 28 |
|
| 29 |
@router.message(CommandStart())
|
| 30 |
async def cmd_start(message: Message, command: CommandObject, user: User, session: AsyncSession) -> None:
|
| 31 |
-
payload = command.args or ""
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
| 38 |
welcome = t(user.lang, "welcome", name=message.from_user.full_name)
|
| 39 |
await next_step(message, user, prefix=welcome)
|
| 40 |
|
|
@@ -58,7 +60,7 @@ async def pick_stream(call: CallbackQuery, user: User) -> None:
|
|
| 58 |
|
| 59 |
|
| 60 |
@router.callback_query(F.data.startswith("ob:year:"))
|
| 61 |
-
async def pick_year(call: CallbackQuery, user: User) -> None:
|
| 62 |
value = call.data.split(":")[2]
|
| 63 |
if value.isdigit() and int(value) in YEARS:
|
| 64 |
user.year = int(value)
|
|
@@ -71,5 +73,6 @@ async def pick_year(call: CallbackQuery, user: User) -> None:
|
|
| 71 |
call.message.delete(),
|
| 72 |
call.message.answer(t(user.lang, "onboarded"), reply_markup=main_menu(user.lang)),
|
| 73 |
)
|
|
|
|
| 74 |
else:
|
| 75 |
await gather_safe(call.answer(), call.message.delete(), next_step(call.message, user))
|
|
|
|
| 3 |
from aiogram.types import CallbackQuery, Message
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
|
| 6 |
+
from app.bot import delivery
|
| 7 |
from app.bot.keyboards.common import main_menu, stream_kb, year_kb
|
| 8 |
from app.core.aio import gather_safe
|
| 9 |
from app.core.catalog import STREAMS, YEARS
|
| 10 |
from app.core.i18n import t
|
| 11 |
from app.db.models import User
|
| 12 |
+
from app.services import growth
|
| 13 |
|
| 14 |
router = Router()
|
| 15 |
+
router.message.filter(F.chat.type == "private")
|
| 16 |
|
| 17 |
|
| 18 |
async def next_step(message: Message, user: User, prefix: str = "") -> None:
|
|
|
|
| 29 |
|
| 30 |
@router.message(CommandStart())
|
| 31 |
async def cmd_start(message: Message, command: CommandObject, user: User, session: AsyncSession) -> None:
|
| 32 |
+
payload = (command.args or "").strip()
|
| 33 |
+
kind, value = growth.parse_start_payload(payload)
|
| 34 |
+
if kind in ("course", "file"):
|
| 35 |
+
if user.year is not None:
|
| 36 |
+
if await delivery.deliver_link(message.bot, session, user, message.chat.id, kind, value):
|
| 37 |
+
return
|
| 38 |
+
else:
|
| 39 |
+
user.pending_link = payload[:16] # opened as soon as onboarding is done
|
| 40 |
welcome = t(user.lang, "welcome", name=message.from_user.full_name)
|
| 41 |
await next_step(message, user, prefix=welcome)
|
| 42 |
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
@router.callback_query(F.data.startswith("ob:year:"))
|
| 63 |
+
async def pick_year(call: CallbackQuery, user: User, session: AsyncSession) -> None:
|
| 64 |
value = call.data.split(":")[2]
|
| 65 |
if value.isdigit() and int(value) in YEARS:
|
| 66 |
user.year = int(value)
|
|
|
|
| 73 |
call.message.delete(),
|
| 74 |
call.message.answer(t(user.lang, "onboarded"), reply_markup=main_menu(user.lang)),
|
| 75 |
)
|
| 76 |
+
await delivery.deliver_pending(call.bot, session, user, call.from_user.id)
|
| 77 |
else:
|
| 78 |
await gather_safe(call.answer(), call.message.delete(), next_step(call.message, user))
|
app/bot/keyboards/common.py
CHANGED
|
@@ -18,6 +18,15 @@ def gate_kb(lang: str, missing: list[str]) -> InlineKeyboardMarkup:
|
|
| 18 |
return InlineKeyboardMarkup(inline_keyboard=rows)
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def stream_kb(lang: str) -> InlineKeyboardMarkup:
|
| 22 |
return InlineKeyboardMarkup(
|
| 23 |
inline_keyboard=[
|
|
|
|
| 18 |
return InlineKeyboardMarkup(inline_keyboard=rows)
|
| 19 |
|
| 20 |
|
| 21 |
+
def share_kb(lang: str, url: str) -> InlineKeyboardMarkup:
|
| 22 |
+
"""One tap opens Telegram's own "send to..." picker with a ready message."""
|
| 23 |
+
return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t(lang, "share_btn"), url=url)]])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def open_bot_kb(link: str) -> InlineKeyboardMarkup:
|
| 27 |
+
return InlineKeyboardMarkup(inline_keyboard=[[InlineKeyboardButton(text=t("am", "inline_open_bot"), url=link)]])
|
| 28 |
+
|
| 29 |
+
|
| 30 |
def stream_kb(lang: str) -> InlineKeyboardMarkup:
|
| 31 |
return InlineKeyboardMarkup(
|
| 32 |
inline_keyboard=[
|
app/bot/middlewares/context.py
CHANGED
|
@@ -5,15 +5,17 @@ from aiogram.types import Message, TelegramObject
|
|
| 5 |
|
| 6 |
from app.core import cache
|
| 7 |
from app.db.session import SessionFactory
|
|
|
|
| 8 |
from app.services import users as users_service
|
| 9 |
from app.services import xp as xp_service
|
| 10 |
|
| 11 |
|
| 12 |
-
def
|
|
|
|
| 13 |
if isinstance(event, Message) and event.text and event.text.startswith("/start"):
|
| 14 |
parts = event.text.split(maxsplit=1)
|
| 15 |
-
if len(parts) == 2
|
| 16 |
-
return
|
| 17 |
return None
|
| 18 |
|
| 19 |
|
|
@@ -40,6 +42,8 @@ class ContextMiddleware(BaseMiddleware):
|
|
| 40 |
tg = data.get("event_from_user")
|
| 41 |
if tg is None or tg.is_bot:
|
| 42 |
return await handler(event, data)
|
|
|
|
|
|
|
| 43 |
|
| 44 |
async with cache.lock_for(tg.id):
|
| 45 |
snap = cache.get(tg.id)
|
|
@@ -48,7 +52,11 @@ class ContextMiddleware(BaseMiddleware):
|
|
| 48 |
user = users_service.from_snapshot(snap)
|
| 49 |
session.add(user) # attach without any query
|
| 50 |
else:
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
data.update(session=session, user=user, lang=user.lang)
|
| 53 |
if user.is_banned:
|
| 54 |
return None
|
|
|
|
| 5 |
|
| 6 |
from app.core import cache
|
| 7 |
from app.db.session import SessionFactory
|
| 8 |
+
from app.services import growth
|
| 9 |
from app.services import users as users_service
|
| 10 |
from app.services import xp as xp_service
|
| 11 |
|
| 12 |
|
| 13 |
+
def _payload_of(event: TelegramObject) -> Optional[str]:
|
| 14 |
+
"""The text after /start (referral id, deep link, tracking tag)."""
|
| 15 |
if isinstance(event, Message) and event.text and event.text.startswith("/start"):
|
| 16 |
parts = event.text.split(maxsplit=1)
|
| 17 |
+
if len(parts) == 2:
|
| 18 |
+
return parts[1].strip()
|
| 19 |
return None
|
| 20 |
|
| 21 |
|
|
|
|
| 42 |
tg = data.get("event_from_user")
|
| 43 |
if tg is None or tg.is_bot:
|
| 44 |
return await handler(event, data)
|
| 45 |
+
if isinstance(event, Message) and event.chat.type != "private":
|
| 46 |
+
return await handler(event, data) # group chatter: no user record, no gate, no XP
|
| 47 |
|
| 48 |
async with cache.lock_for(tg.id):
|
| 49 |
snap = cache.get(tg.id)
|
|
|
|
| 52 |
user = users_service.from_snapshot(snap)
|
| 53 |
session.add(user) # attach without any query
|
| 54 |
else:
|
| 55 |
+
payload = _payload_of(event)
|
| 56 |
+
kind, value = growth.parse_start_payload(payload)
|
| 57 |
+
user = await users_service.get_or_create(
|
| 58 |
+
session, tg, int(value) if kind == "ref" else None, growth.source_label(payload)
|
| 59 |
+
)
|
| 60 |
data.update(session=session, user=user, lang=user.lang)
|
| 61 |
if user.is_banned:
|
| 62 |
return None
|
app/bot/middlewares/gate.py
CHANGED
|
@@ -6,7 +6,7 @@ from aiogram.types import CallbackQuery, Message, TelegramObject
|
|
| 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):
|
|
@@ -23,6 +23,10 @@ class GateMiddleware(BaseMiddleware):
|
|
| 23 |
if not left:
|
| 24 |
return await handler(event, data)
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
lang = user.lang
|
| 27 |
text = t(lang, "gate_text") + "\n\n" + t(lang, "gate_missing", list=", ".join(left))
|
| 28 |
kb = gate_kb(lang, left)
|
|
|
|
| 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, growth
|
| 10 |
|
| 11 |
|
| 12 |
class GateMiddleware(BaseMiddleware):
|
|
|
|
| 23 |
if not left:
|
| 24 |
return await handler(event, data)
|
| 25 |
|
| 26 |
+
if is_start: # keep the link they came with, open it right after they join
|
| 27 |
+
kind, _ = growth.parse_start_payload((event.text or "").partition(" ")[2])
|
| 28 |
+
if kind in ("course", "file"):
|
| 29 |
+
user.pending_link = (event.text or "").partition(" ")[2].strip()[:16]
|
| 30 |
lang = user.lang
|
| 31 |
text = t(lang, "gate_text") + "\n\n" + t(lang, "gate_missing", list=", ".join(left))
|
| 32 |
kb = gate_kb(lang, left)
|
app/db/migrate.py
CHANGED
|
@@ -36,6 +36,9 @@ POSTGRES_UPGRADES = [
|
|
| 36 |
"ALTER TABLE materials ADD COLUMN IF NOT EXISTS orig_file_id VARCHAR(512)",
|
| 37 |
"ALTER TABLE materials ADD COLUMN IF NOT EXISTS watermarked BOOLEAN NOT NULL DEFAULT FALSE",
|
| 38 |
"CREATE INDEX IF NOT EXISTS ix_materials_content_hash ON materials (content_hash)",
|
|
|
|
|
|
|
|
|
|
| 39 |
"ALTER TABLE referrals ADD COLUMN IF NOT EXISTS qualified BOOLEAN NOT NULL DEFAULT FALSE",
|
| 40 |
]
|
| 41 |
|
|
|
|
| 36 |
"ALTER TABLE materials ADD COLUMN IF NOT EXISTS orig_file_id VARCHAR(512)",
|
| 37 |
"ALTER TABLE materials ADD COLUMN IF NOT EXISTS watermarked BOOLEAN NOT NULL DEFAULT FALSE",
|
| 38 |
"CREATE INDEX IF NOT EXISTS ix_materials_content_hash ON materials (content_hash)",
|
| 39 |
+
"ALTER TABLE users ADD COLUMN IF NOT EXISTS source VARCHAR(24)",
|
| 40 |
+
"ALTER TABLE users ADD COLUMN IF NOT EXISTS pending_link VARCHAR(16)",
|
| 41 |
+
"ALTER TABLE users ADD COLUMN IF NOT EXISTS share_prompt_day VARCHAR(10)",
|
| 42 |
"ALTER TABLE referrals ADD COLUMN IF NOT EXISTS qualified BOOLEAN NOT NULL DEFAULT FALSE",
|
| 43 |
]
|
| 44 |
|
app/db/models/__init__.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
| 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 |
from app.db.models.xp import SeasonResult, XpEvent
|
| 7 |
|
| 8 |
-
__all__ = ["User", "Course", "Material", "Referral", "PendingMaterial", "XpEvent", "SeasonResult"]
|
|
|
|
| 1 |
from app.db.models.course import Course
|
| 2 |
+
from app.db.models.group import BotGroup
|
| 3 |
from app.db.models.material import Material
|
| 4 |
from app.db.models.pending import PendingMaterial
|
| 5 |
from app.db.models.referral import Referral
|
| 6 |
from app.db.models.user import User
|
| 7 |
from app.db.models.xp import SeasonResult, XpEvent
|
| 8 |
|
| 9 |
+
__all__ = ["User", "Course", "Material", "Referral", "PendingMaterial", "XpEvent", "SeasonResult", "BotGroup"]
|
app/db/models/group.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
from sqlalchemy import BigInteger, Boolean, DateTime, String, func
|
| 5 |
+
from sqlalchemy.orm import Mapped, mapped_column
|
| 6 |
+
|
| 7 |
+
from app.db.base import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class BotGroup(Base):
|
| 11 |
+
"""A class/department group the bot was added to (every member of it can see the bot)."""
|
| 12 |
+
|
| 13 |
+
__tablename__ = "bot_groups"
|
| 14 |
+
|
| 15 |
+
chat_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
|
| 16 |
+
title: Mapped[Optional[str]] = mapped_column(String(128))
|
| 17 |
+
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 18 |
+
added_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
app/db/models/user.py
CHANGED
|
@@ -28,5 +28,8 @@ class User(Base):
|
|
| 28 |
day_key: Mapped[Optional[str]] = mapped_column(String(10))
|
| 29 |
day_xp: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
| 30 |
trust: Mapped[int] = mapped_column(Integer, default=100, server_default="100")
|
|
|
|
|
|
|
|
|
|
| 31 |
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 32 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
| 28 |
day_key: Mapped[Optional[str]] = mapped_column(String(10))
|
| 29 |
day_xp: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
| 30 |
trust: Mapped[int] = mapped_column(Integer, default=100, server_default="100")
|
| 31 |
+
source: Mapped[Optional[str]] = mapped_column(String(24)) # where they came from (poster, inline, referral...)
|
| 32 |
+
pending_link: Mapped[Optional[str]] = mapped_column(String(16)) # deep link to open after joining/onboarding
|
| 33 |
+
share_prompt_day: Mapped[Optional[str]] = mapped_column(String(10)) # last day we suggested sharing
|
| 34 |
is_banned: Mapped[bool] = mapped_column(Boolean, default=False)
|
| 35 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
app/db/repos/groups.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import func, select
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
|
| 6 |
+
from app.db.models import BotGroup
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def upsert(session: AsyncSession, chat_id: int, title: Optional[str], active: bool) -> None:
|
| 10 |
+
group = await session.get(BotGroup, chat_id)
|
| 11 |
+
if group:
|
| 12 |
+
group.title, group.active = title, active
|
| 13 |
+
else:
|
| 14 |
+
session.add(BotGroup(chat_id=chat_id, title=title, active=active))
|
| 15 |
+
await session.flush()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def count_active(session: AsyncSession) -> int:
|
| 19 |
+
res = await session.execute(select(func.count()).select_from(BotGroup).where(BotGroup.active.is_(True)))
|
| 20 |
+
return int(res.scalar_one())
|
app/db/repos/stats.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
from datetime import date
|
| 2 |
|
| 3 |
-
from
|
|
|
|
|
|
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
|
| 6 |
-
from app.db.models import Course, Material, Referral, User
|
| 7 |
|
| 8 |
|
| 9 |
async def _count(session: AsyncSession, stmt) -> int:
|
|
@@ -21,3 +23,36 @@ async def overview(session: AsyncSession, today: date) -> dict[str, int]:
|
|
| 21 |
"downloads": await _count(session, select(func.coalesce(func.sum(Material.downloads), 0))),
|
| 22 |
"referrals": await _count(session, select(func.count()).select_from(Referral)),
|
| 23 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from datetime import date
|
| 2 |
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
|
| 5 |
+
from sqlalchemy import case, func, select
|
| 6 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 7 |
|
| 8 |
+
from app.db.models import BotGroup, Course, Material, Referral, User
|
| 9 |
|
| 10 |
|
| 11 |
async def _count(session: AsyncSession, stmt) -> int:
|
|
|
|
| 23 |
"downloads": await _count(session, select(func.coalesce(func.sum(Material.downloads), 0))),
|
| 24 |
"referrals": await _count(session, select(func.count()).select_from(Referral)),
|
| 25 |
}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def growth_report(session: AsyncSession, since: datetime) -> dict:
|
| 29 |
+
"""New users per day, and for each source the funnel: joined -> passed gate -> onboarded -> active 3+ days."""
|
| 30 |
+
day = func.date(User.created_at)
|
| 31 |
+
by_day = (
|
| 32 |
+
await session.execute(
|
| 33 |
+
select(day, func.count()).where(User.created_at >= since).group_by(day).order_by(day)
|
| 34 |
+
)
|
| 35 |
+
).all()
|
| 36 |
+
source = func.coalesce(User.source, "before tracking")
|
| 37 |
+
by_source = (
|
| 38 |
+
await session.execute(
|
| 39 |
+
select(
|
| 40 |
+
source,
|
| 41 |
+
func.count(),
|
| 42 |
+
func.sum(case((User.gate_passed.is_(True), 1), else_=0)),
|
| 43 |
+
func.sum(case((User.year.is_not(None), 1), else_=0)),
|
| 44 |
+
func.sum(case((User.active_days >= 3, 1), else_=0)),
|
| 45 |
+
)
|
| 46 |
+
.group_by(source)
|
| 47 |
+
.order_by(func.count().desc())
|
| 48 |
+
.limit(12)
|
| 49 |
+
)
|
| 50 |
+
).all()
|
| 51 |
+
groups = await _count(session, select(func.count()).select_from(BotGroup).where(BotGroup.active.is_(True)))
|
| 52 |
+
total = await _count(session, select(func.count()).select_from(User))
|
| 53 |
+
return {
|
| 54 |
+
"by_day": [(str(d), int(n)) for d, n in by_day],
|
| 55 |
+
"by_source": [(s, int(n), int(g or 0), int(o or 0), int(a or 0)) for s, n, g, o, a in by_source],
|
| 56 |
+
"groups": groups,
|
| 57 |
+
"total": total,
|
| 58 |
+
}
|
app/db/repos/users.py
CHANGED
|
@@ -26,6 +26,7 @@ async def create(
|
|
| 26 |
full_name: str,
|
| 27 |
lang: str,
|
| 28 |
referred_by: Optional[int] = None,
|
|
|
|
| 29 |
) -> User:
|
| 30 |
user = User(
|
| 31 |
id=user_id,
|
|
@@ -33,6 +34,7 @@ async def create(
|
|
| 33 |
full_name=full_name,
|
| 34 |
lang=lang,
|
| 35 |
referred_by=referred_by,
|
|
|
|
| 36 |
points=0,
|
| 37 |
streak=0,
|
| 38 |
best_streak=0,
|
|
|
|
| 26 |
full_name: str,
|
| 27 |
lang: str,
|
| 28 |
referred_by: Optional[int] = None,
|
| 29 |
+
source: Optional[str] = None,
|
| 30 |
) -> User:
|
| 31 |
user = User(
|
| 32 |
id=user_id,
|
|
|
|
| 34 |
full_name=full_name,
|
| 35 |
lang=lang,
|
| 36 |
referred_by=referred_by,
|
| 37 |
+
source=source,
|
| 38 |
points=0,
|
| 39 |
streak=0,
|
| 40 |
best_streak=0,
|
app/services/growth.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Organic growth helpers (pure): where did a user come from, links people can share."""
|
| 2 |
+
import re
|
| 3 |
+
import urllib.parse
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
_TAG = re.compile(r"[A-Za-z0-9_]{1,20}")
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def parse_start_payload(payload: Optional[str]) -> tuple[str, str]:
|
| 10 |
+
"""/start <payload> -> (kind, value). kind: ref | course | file | tag | none"""
|
| 11 |
+
p = (payload or "").strip()
|
| 12 |
+
if not p:
|
| 13 |
+
return "none", ""
|
| 14 |
+
if p.isdigit():
|
| 15 |
+
return "ref", p
|
| 16 |
+
if re.fullmatch(r"c\d+", p):
|
| 17 |
+
return "course", p[1:]
|
| 18 |
+
if re.fullmatch(r"f\d+", p):
|
| 19 |
+
return "file", p[1:]
|
| 20 |
+
if _TAG.fullmatch(p):
|
| 21 |
+
return "tag", p.lower()
|
| 22 |
+
return "none", ""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def source_label(payload: Optional[str]) -> str:
|
| 26 |
+
"""Where the user came from, for the /growth report."""
|
| 27 |
+
kind, value = parse_start_payload(payload)
|
| 28 |
+
return {"none": "organic", "ref": "referral", "course": "link", "file": "link"}.get(kind, value)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def deep_link(bot_username: str, payload: str) -> str:
|
| 32 |
+
return f"https://t.me/{bot_username}?start={payload}"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def share_url(link: str, text: str) -> str:
|
| 36 |
+
"""Opens Telegram's own 'send to...' picker with a ready message, so sharing is one tap."""
|
| 37 |
+
return "https://t.me/share/url?" + urllib.parse.urlencode({"url": link, "text": text}, quote_via=urllib.parse.quote)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def safe_tag(raw: Optional[str], default: str = "poster") -> str:
|
| 41 |
+
tag = (raw or "").strip()
|
| 42 |
+
return tag if _TAG.fullmatch(tag) and not tag.isdigit() else default
|
app/services/library.py
CHANGED
|
@@ -84,6 +84,7 @@ async def send_material(bot: Bot, chat_id: int, material: Material, course: Opti
|
|
| 84 |
if course:
|
| 85 |
caption += f"\n๐ {course.name}"
|
| 86 |
caption += f"\n#{material.kind}"
|
|
|
|
| 87 |
kwargs = {material.file_type: material.file_id, "caption": caption}
|
| 88 |
sender = {
|
| 89 |
"document": bot.send_document,
|
|
|
|
| 84 |
if course:
|
| 85 |
caption += f"\n๐ {course.name}"
|
| 86 |
caption += f"\n#{material.kind}"
|
| 87 |
+
caption += f"\n๐ค @{(await bot.me()).username}"
|
| 88 |
kwargs = {material.file_type: material.file_id, "caption": caption}
|
| 89 |
sender = {
|
| 90 |
"document": bot.send_document,
|
app/services/poster.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A printable A4 poster with a QR code that opens the bot (for dorm walls, class boards, cafeterias).
|
| 2 |
+
Latin text only (standard PDF fonts), so it needs no font files."""
|
| 3 |
+
import io
|
| 4 |
+
|
| 5 |
+
from reportlab.graphics import renderPDF
|
| 6 |
+
from reportlab.graphics.barcode.qr import QrCodeWidget
|
| 7 |
+
from reportlab.graphics.shapes import Drawing
|
| 8 |
+
from reportlab.lib.pagesizes import A4
|
| 9 |
+
from reportlab.pdfgen import canvas
|
| 10 |
+
|
| 11 |
+
BLUE = (0.10, 0.30, 0.60)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def make_poster_pdf(link: str, bot_username: str, channel_url: str, headline: str = "DBU Freshman Central") -> bytes:
|
| 15 |
+
w, h = A4
|
| 16 |
+
buf = io.BytesIO()
|
| 17 |
+
c = canvas.Canvas(buf, pagesize=A4)
|
| 18 |
+
c.setTitle(headline)
|
| 19 |
+
|
| 20 |
+
c.setFillColorRGB(*BLUE)
|
| 21 |
+
c.rect(0, h - 190, w, 190, stroke=0, fill=1) # header band
|
| 22 |
+
c.setFillColorRGB(1, 1, 1)
|
| 23 |
+
c.setFont("Helvetica-Bold", 40)
|
| 24 |
+
c.drawCentredString(w / 2, h - 95, headline)
|
| 25 |
+
c.setFont("Helvetica", 18)
|
| 26 |
+
c.drawCentredString(w / 2, h - 135, "Debre Berhan University")
|
| 27 |
+
|
| 28 |
+
c.setFillColorRGB(0.1, 0.1, 0.1)
|
| 29 |
+
c.setFont("Helvetica-Bold", 28)
|
| 30 |
+
c.drawCentredString(w / 2, h - 255, "Free study materials, one tap away")
|
| 31 |
+
c.setFont("Helvetica", 20)
|
| 32 |
+
for i, line in enumerate(("Lecture slides", "Past exams", "Course modules", "Weekly student ranking")):
|
| 33 |
+
c.drawCentredString(w / 2, h - 300 - i * 28, "- " + line + " -")
|
| 34 |
+
|
| 35 |
+
qr = QrCodeWidget(link)
|
| 36 |
+
x0, y0, x1, y1 = qr.getBounds()
|
| 37 |
+
size = 235
|
| 38 |
+
drawing = Drawing(size, size, transform=[size / (x1 - x0), 0, 0, size / (y1 - y0), 0, 0])
|
| 39 |
+
drawing.add(qr)
|
| 40 |
+
qx, qy = (w - size) / 2, 185
|
| 41 |
+
c.setFillColorRGB(1, 1, 1)
|
| 42 |
+
c.setStrokeColorRGB(*BLUE)
|
| 43 |
+
c.setLineWidth(3)
|
| 44 |
+
c.roundRect(qx - 16, qy - 16, size + 32, size + 32, 14, stroke=1, fill=1)
|
| 45 |
+
renderPDF.draw(drawing, c, qx, qy)
|
| 46 |
+
|
| 47 |
+
c.setFillColorRGB(*BLUE)
|
| 48 |
+
c.setFont("Helvetica-Bold", 24)
|
| 49 |
+
c.drawCentredString(w / 2, 128, "Scan with your phone camera")
|
| 50 |
+
c.setFont("Helvetica", 18)
|
| 51 |
+
c.drawCentredString(w / 2, 98, f"or search @{bot_username} on Telegram")
|
| 52 |
+
c.setFont("Helvetica", 14)
|
| 53 |
+
c.drawCentredString(w / 2, 56, channel_url.replace("https://", ""))
|
| 54 |
+
c.showPage()
|
| 55 |
+
c.save()
|
| 56 |
+
return buf.getvalue()
|
app/services/users.py
CHANGED
|
@@ -13,11 +13,12 @@ SNAPSHOT_COLS = (
|
|
| 13 |
"id", "username", "full_name", "lang", "stream", "year", "referred_by",
|
| 14 |
"gate_passed", "points", "streak", "best_streak", "last_active_on", "is_banned",
|
| 15 |
"active_days", "week_key", "week_xp", "day_key", "day_xp", "trust",
|
|
|
|
| 16 |
)
|
| 17 |
|
| 18 |
|
| 19 |
async def get_or_create(
|
| 20 |
-
session: AsyncSession, tg: TgUser, referrer_hint: Optional[int] = None
|
| 21 |
) -> User:
|
| 22 |
user = await repo.get(session, tg.id)
|
| 23 |
if user:
|
|
@@ -32,6 +33,7 @@ async def get_or_create(
|
|
| 32 |
full_name=tg.full_name or "",
|
| 33 |
lang=get_settings().default_lang,
|
| 34 |
referred_by=referred_by,
|
|
|
|
| 35 |
)
|
| 36 |
|
| 37 |
|
|
|
|
| 13 |
"id", "username", "full_name", "lang", "stream", "year", "referred_by",
|
| 14 |
"gate_passed", "points", "streak", "best_streak", "last_active_on", "is_banned",
|
| 15 |
"active_days", "week_key", "week_xp", "day_key", "day_xp", "trust",
|
| 16 |
+
"source", "pending_link", "share_prompt_day",
|
| 17 |
)
|
| 18 |
|
| 19 |
|
| 20 |
async def get_or_create(
|
| 21 |
+
session: AsyncSession, tg: TgUser, referrer_hint: Optional[int] = None, source: Optional[str] = None
|
| 22 |
) -> User:
|
| 23 |
user = await repo.get(session, tg.id)
|
| 24 |
if user:
|
|
|
|
| 33 |
full_name=tg.full_name or "",
|
| 34 |
lang=get_settings().default_lang,
|
| 35 |
referred_by=referred_by,
|
| 36 |
+
source=source,
|
| 37 |
)
|
| 38 |
|
| 39 |
|
locales/am.json
CHANGED
|
@@ -62,5 +62,12 @@
|
|
| 62 |
"level_10": "แแ
",
|
| 63 |
"gate_missing": "แแ แซแแฐแแแแแฝแแตแฆ {list}",
|
| 64 |
"gate_join_one": "๐ข {name} แฐแแแแ",
|
| 65 |
-
"gate_not_yet_list": "แแ แ แแฐแแแแแฝแแแฆ {list}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
|
|
|
| 62 |
"level_10": "แแ
",
|
| 63 |
"gate_missing": "แแ แซแแฐแแแแแฝแแตแฆ {list}",
|
| 64 |
"gate_join_one": "๐ข {name} แฐแแแแ",
|
| 65 |
+
"gate_not_yet_list": "แแ แ แแฐแแแแแฝแแแฆ {list}",
|
| 66 |
+
"group_welcome": "๐ แฐแแ แญแแ! แฅแ แจDBU Freshman แฆแต แแแข\nแตแแญแตแฃ แจแตแฎ แแฐแแแฝแ แแแแฝ แแญ แแจแณแฝแ แแแ แแ ๐\n\nแแแแแ แฅแแฒแ
แปแแฆ <code>/find แแแญแต</code> ๐",
|
| 67 |
+
"group_find_usage": "๐ แจ /find แ แแ แจแฎแญแฑแ แตแ แปแแฃ แแแณแแฆ <code>/find physics</code>",
|
| 68 |
+
"group_find_none": "๐ แแแ แ แแแแแแข แแ แแ แแญแฉแข",
|
| 69 |
+
"group_find_result": "๐ <b>แ ยซ{q}ยป แซแแแแต</b>\nแแฉแ แฆแฑ แแตแฅ แญแแฑ ๐",
|
| 70 |
+
"share_btn": "๐ค แแแฐแ แ แแฉ",
|
| 71 |
+
"share_text": "๐ แDBU แฐแแชแแฝ แแป แแดแชแซแแฝแฆ แตแแญแตแฃ แจแตแฎ แแฐแแแฝแฃ แแแแฝ โ แแแ แ แ แแต แฆแณแข แฅแแ
แแแฉ ๐",
|
| 72 |
+
"share_prompt": "๐ แ แแแฝแ? แแญแแ แแฐแแปแฝแแ แ แแฉแแธแแค แ แแแซแฝแ แฒแแก XP แณแแแแฝแ ๐"
|
| 73 |
}
|
locales/en.json
CHANGED
|
@@ -62,5 +62,12 @@
|
|
| 62 |
"level_10": "Legend",
|
| 63 |
"gate_missing": "Still to join: {list}",
|
| 64 |
"gate_join_one": "๐ข Join {name}",
|
| 65 |
-
"gate_not_yet_list": "You haven't joined yet: {list}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
}
|
|
|
|
| 62 |
"level_10": "Legend",
|
| 63 |
"gate_missing": "Still to join: {list}",
|
| 64 |
"gate_join_one": "๐ข Join {name}",
|
| 65 |
+
"gate_not_yet_list": "You haven't joined yet: {list}",
|
| 66 |
+
"group_welcome": "๐ Hi class! I'm the DBU Freshman bot.\nI have slides, past exams and modules ๐\n\nTo search, type: <code>/find physics</code> ๐",
|
| 67 |
+
"group_find_usage": "๐ Type the course name after /find, e.g. <code>/find physics</code>",
|
| 68 |
+
"group_find_none": "๐ Nothing found. Try another word.",
|
| 69 |
+
"group_find_result": "๐ <b>Found for ยซ{q}ยป</b>\nTap to open it in the bot ๐",
|
| 70 |
+
"share_btn": "๐ค Share with a friend",
|
| 71 |
+
"share_text": "๐ Free study materials for DBU students: slides, past exams, modules, all in one place. Start here ๐",
|
| 72 |
+
"share_prompt": "๐ Was that useful? Share it with your classmates; you get XP when they join with your link ๐"
|
| 73 |
}
|
tests/test_logic.py
CHANGED
|
@@ -361,6 +361,39 @@ def test_locales_stay_in_sync():
|
|
| 361 |
assert sorted(re.findall(r"</?\w+>", am[key])) == sorted(re.findall(r"</?\w+>", en[key])), key
|
| 362 |
|
| 363 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
if __name__ == "__main__":
|
| 365 |
for name, fn in list(globals().items()):
|
| 366 |
if name.startswith("test_"):
|
|
|
|
| 361 |
assert sorted(re.findall(r"</?\w+>", am[key])) == sorted(re.findall(r"</?\w+>", en[key])), key
|
| 362 |
|
| 363 |
|
| 364 |
+
def test_growth_payloads_and_links():
|
| 365 |
+
from app.services import growth as G
|
| 366 |
+
|
| 367 |
+
assert G.parse_start_payload(None) == ("none", "")
|
| 368 |
+
assert G.parse_start_payload("7187749925") == ("ref", "7187749925")
|
| 369 |
+
assert G.parse_start_payload("c12") == ("course", "12")
|
| 370 |
+
assert G.parse_start_payload("f40") == ("file", "40")
|
| 371 |
+
assert G.parse_start_payload("Dorm5") == ("tag", "dorm5")
|
| 372 |
+
assert G.parse_start_payload("x" * 40) == ("none", "") # too long / junk is ignored
|
| 373 |
+
assert [G.source_label(x) for x in (None, "123", "c1", "inline", "poster")] == ["organic", "referral", "link", "inline", "poster"]
|
| 374 |
+
url = G.share_url("https://t.me/Bot?start=1", "แฐแแ & hello")
|
| 375 |
+
assert url.startswith("https://t.me/share/url?url=https%3A%2F%2Ft.me%2FBot%3Fstart%3D1&text=")
|
| 376 |
+
assert "%26" in url and " " not in url # '&' in the text is encoded, no raw spaces
|
| 377 |
+
assert G.safe_tag("dorm_5") == "dorm_5" and G.safe_tag("123") == "poster" and G.safe_tag("bad tag!") == "poster"
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
def test_poster_is_a_valid_one_page_pdf():
|
| 381 |
+
try:
|
| 382 |
+
import io
|
| 383 |
+
|
| 384 |
+
from pypdf import PdfReader
|
| 385 |
+
from reportlab.pdfgen import canvas # noqa: F401
|
| 386 |
+
except ImportError:
|
| 387 |
+
return
|
| 388 |
+
from app.services.poster import make_poster_pdf
|
| 389 |
+
|
| 390 |
+
data = make_poster_pdf("https://t.me/DBUFreshBot?start=poster", "DBUFreshBot", "https://t.me/DBUCentral")
|
| 391 |
+
reader = PdfReader(io.BytesIO(data))
|
| 392 |
+
assert len(reader.pages) == 1
|
| 393 |
+
text = reader.pages[0].extract_text()
|
| 394 |
+
assert "@DBUFreshBot" in text and "DBU Freshman Central" in text and "t.me/DBUCentral" in text
|
| 395 |
+
|
| 396 |
+
|
| 397 |
if __name__ == "__main__":
|
| 398 |
for name, fn in list(globals().items()):
|
| 399 |
if name.startswith("test_"):
|