cipher-attacks commited on
Commit
c3b9247
ยท
1 Parent(s): 663fdd6

natural amharic

Browse files
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 &lt;id&gt; [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 &lt;id&gt; [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
- try:
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
- await message.answer(t(user.lang, "invite_text", link=link, count=count, qualified=qualified, points=user.points))
 
 
 
 
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.keyboards import library as lib_kb
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 library as lib
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
- if payload.startswith("c") and payload[1:].isdigit() and user.year is not None:
33
- # deep link from inline results: open that course directly
34
- course, counts = await lib.kinds_of(session, int(payload[1:]))
35
- if course and any(counts.values()):
36
- await message.answer(t(user.lang, "lib_kinds", course=course.name), reply_markup=lib_kb.kinds(user.lang, course.id, counts))
37
- return
 
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 _referrer_from(event: TelegramObject) -> Optional[int]:
 
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 and parts[1].isdigit():
16
- return int(parts[1])
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
- user = await users_service.get_or_create(session, tg, _referrer_from(event))
 
 
 
 
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 sqlalchemy import func, select
 
 
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_"):