Spaces:
Running on Zero
Running on Zero
Commit Β·
f932c25
1
Parent(s): a48b894
bulk upload
Browse files- app/bot/handlers/admin.py +66 -12
- app/services/ingest.py +11 -0
- tests/test_logic.py +7 -1
app/bot/handlers/admin.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Optional
|
|
| 5 |
|
| 6 |
from aiogram import F, Router
|
| 7 |
from aiogram.filters import BaseFilter, Command, CommandObject
|
| 8 |
-
from aiogram.types import Message
|
| 9 |
from sqlalchemy import text
|
| 10 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 11 |
|
|
@@ -16,7 +16,7 @@ from app.db.models import User
|
|
| 16 |
from app.db.repos import library as repo
|
| 17 |
from app.db.repos import stats as stats_repo
|
| 18 |
from app.services import engagement
|
| 19 |
-
from app.services.ingest import parse_caption
|
| 20 |
|
| 21 |
log = logging.getLogger(__name__)
|
| 22 |
|
|
@@ -38,7 +38,10 @@ HELP = (
|
|
| 38 |
"/courses : list courses with ids\n"
|
| 39 |
"/stats : numbers\n"
|
| 40 |
"/ping : measure database and Telegram speed\n\n"
|
| 41 |
-
"<b>
|
|
|
|
|
|
|
|
|
|
| 42 |
"<code><course_id> <slide|exam|note|book> [title]</code>\n"
|
| 43 |
"e.g. <code>3 exam Midterm 2017</code>"
|
| 44 |
)
|
|
@@ -141,16 +144,59 @@ def _extract(message: Message) -> Optional[tuple[str, str, str, Optional[str]]]:
|
|
| 141 |
return None
|
| 142 |
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
@router.message(F.chat.type == "private", F.document | F.video | F.audio | F.photo)
|
| 145 |
async def ingest(message: Message, user: User, session: AsyncSession) -> None:
|
| 146 |
-
parsed = parse_caption(message.caption)
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
await message.answer(
|
| 149 |
-
"β
|
| 150 |
-
"
|
| 151 |
)
|
| 152 |
return
|
| 153 |
-
course_id, kind, title = parsed
|
| 154 |
course = await repo.get_course(session, course_id)
|
| 155 |
if not course:
|
| 156 |
await message.answer(f"β No course with id {course_id}. See /courses")
|
|
@@ -158,7 +204,11 @@ async def ingest(message: Message, user: User, session: AsyncSession) -> None:
|
|
| 158 |
file_type, file_id, unique_id, file_name = _extract(message)
|
| 159 |
existing = await repo.find_by_unique_id(session, unique_id)
|
| 160 |
if existing:
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
return
|
| 163 |
|
| 164 |
backup_chat = backup_msg = None
|
|
@@ -174,7 +224,7 @@ async def ingest(message: Message, user: User, session: AsyncSession) -> None:
|
|
| 174 |
session,
|
| 175 |
course_id=course.id,
|
| 176 |
kind=kind,
|
| 177 |
-
title=(title or file_name
|
| 178 |
file_id=file_id,
|
| 179 |
file_type=file_type,
|
| 180 |
file_unique_id=unique_id,
|
|
@@ -182,5 +232,9 @@ async def ingest(message: Message, user: User, session: AsyncSession) -> None:
|
|
| 182 |
library_chat_id=backup_chat,
|
| 183 |
library_message_id=backup_msg,
|
| 184 |
)
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from aiogram import F, Router
|
| 7 |
from aiogram.filters import BaseFilter, Command, CommandObject
|
| 8 |
+
from aiogram.types import Message, ReactionTypeEmoji
|
| 9 |
from sqlalchemy import text
|
| 10 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 11 |
|
|
|
|
| 16 |
from app.db.repos import library as repo
|
| 17 |
from app.db.repos import stats as stats_repo
|
| 18 |
from app.services import engagement
|
| 19 |
+
from app.services.ingest import KIND_ALIASES, clean_title, parse_caption
|
| 20 |
|
| 21 |
log = logging.getLogger(__name__)
|
| 22 |
|
|
|
|
| 38 |
"/courses : list courses with ids\n"
|
| 39 |
"/stats : numbers\n"
|
| 40 |
"/ping : measure database and Telegram speed\n\n"
|
| 41 |
+
"<b>Bulk upload (easiest):</b>\n"
|
| 42 |
+
"<code>/upload 3 exam</code> then send or FORWARD many files (e.g. from your channel).\n"
|
| 43 |
+
"π = saved. Send /done when finished.\n\n"
|
| 44 |
+
"<b>Single file:</b> send it with caption\n"
|
| 45 |
"<code><course_id> <slide|exam|note|book> [title]</code>\n"
|
| 46 |
"e.g. <code>3 exam Midterm 2017</code>"
|
| 47 |
)
|
|
|
|
| 144 |
return None
|
| 145 |
|
| 146 |
|
| 147 |
+
# admin_id -> {"course_id": int, "kind": str, "saved": int, "dupes": int}
|
| 148 |
+
_upload_mode: dict[int, dict] = {}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.message(Command("upload"))
|
| 152 |
+
async def upload_on(message: Message, command: CommandObject, session: AsyncSession) -> None:
|
| 153 |
+
parts = (command.args or "").split()
|
| 154 |
+
kind = KIND_ALIASES.get(parts[1].lower().lstrip("#")) if len(parts) == 2 else None
|
| 155 |
+
if len(parts) != 2 or not parts[0].isdigit() or not kind:
|
| 156 |
+
await message.answer("Usage: <code>/upload <course_id> <slide|exam|note|book></code>\nSee /courses for ids.")
|
| 157 |
+
return
|
| 158 |
+
course = await repo.get_course(session, int(parts[0]))
|
| 159 |
+
if not course:
|
| 160 |
+
await message.answer(f"β No course with id {parts[0]}. See /courses")
|
| 161 |
+
return
|
| 162 |
+
_upload_mode[message.from_user.id] = {"course_id": course.id, "kind": kind, "saved": 0, "dupes": 0}
|
| 163 |
+
await message.answer(
|
| 164 |
+
f"π₯ Upload mode <b>ON</b>: {course.name} Β· {kind}\n"
|
| 165 |
+
"Send or forward files now (many at once is fine). π = saved.\n"
|
| 166 |
+
"Change course: send /upload again. Finish: /done"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@router.message(Command("done"))
|
| 171 |
+
async def upload_off(message: Message) -> None:
|
| 172 |
+
mode = _upload_mode.pop(message.from_user.id, None)
|
| 173 |
+
if not mode:
|
| 174 |
+
await message.answer("Upload mode was not on.")
|
| 175 |
+
return
|
| 176 |
+
await message.answer(f"β
Done. Saved <b>{mode['saved']}</b> files, skipped {mode['dupes']} duplicates.")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
async def _ack(message: Message) -> None:
|
| 180 |
+
try:
|
| 181 |
+
await message.react([ReactionTypeEmoji(emoji="π")])
|
| 182 |
+
except Exception: # reactions unavailable in this chat/client: stay quiet
|
| 183 |
+
pass
|
| 184 |
+
|
| 185 |
+
|
| 186 |
@router.message(F.chat.type == "private", F.document | F.video | F.audio | F.photo)
|
| 187 |
async def ingest(message: Message, user: User, session: AsyncSession) -> None:
|
| 188 |
+
parsed = parse_caption(message.caption) # explicit caption wins
|
| 189 |
+
mode = _upload_mode.get(user.id)
|
| 190 |
+
if parsed:
|
| 191 |
+
course_id, kind, title = parsed
|
| 192 |
+
elif mode:
|
| 193 |
+
course_id, kind, title = mode["course_id"], mode["kind"], None
|
| 194 |
+
else:
|
| 195 |
await message.answer(
|
| 196 |
+
"β Use <code>/upload <course_id> <kind></code> for bulk upload, or send the file with caption\n"
|
| 197 |
+
"<code><course_id> <" + "|".join(KINDS) + "> [title]</code>\nSee /courses for ids."
|
| 198 |
)
|
| 199 |
return
|
|
|
|
| 200 |
course = await repo.get_course(session, course_id)
|
| 201 |
if not course:
|
| 202 |
await message.answer(f"β No course with id {course_id}. See /courses")
|
|
|
|
| 204 |
file_type, file_id, unique_id, file_name = _extract(message)
|
| 205 |
existing = await repo.find_by_unique_id(session, unique_id)
|
| 206 |
if existing:
|
| 207 |
+
if mode and not parsed:
|
| 208 |
+
mode["dupes"] += 1
|
| 209 |
+
await _ack(message)
|
| 210 |
+
else:
|
| 211 |
+
await message.answer(f"β οΈ Already saved as file #{existing.id}: {existing.title}")
|
| 212 |
return
|
| 213 |
|
| 214 |
backup_chat = backup_msg = None
|
|
|
|
| 224 |
session,
|
| 225 |
course_id=course.id,
|
| 226 |
kind=kind,
|
| 227 |
+
title=(title or clean_title(file_name, message.caption, f"{course.name} {kind}"))[:250],
|
| 228 |
file_id=file_id,
|
| 229 |
file_type=file_type,
|
| 230 |
file_unique_id=unique_id,
|
|
|
|
| 232 |
library_chat_id=backup_chat,
|
| 233 |
library_message_id=backup_msg,
|
| 234 |
)
|
| 235 |
+
if mode and not parsed:
|
| 236 |
+
mode["saved"] += 1
|
| 237 |
+
await _ack(message)
|
| 238 |
+
else:
|
| 239 |
+
note = "" if backup_msg else " (no backup copy)"
|
| 240 |
+
await message.answer(f"β
Saved #{material.id}: {material.title}\nπ {course.name} Β· {kind}{note}")
|
app/services/ingest.py
CHANGED
|
@@ -20,3 +20,14 @@ def parse_caption(caption: Optional[str]) -> Optional[tuple[int, str, Optional[s
|
|
| 20 |
return None
|
| 21 |
title = parts[2].strip() if len(parts) == 3 and parts[2].strip() else None
|
| 22 |
return int(parts[0]), kind, title
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return None
|
| 21 |
title = parts[2].strip() if len(parts) == 3 and parts[2].strip() else None
|
| 22 |
return int(parts[0]), kind, title
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def clean_title(file_name: Optional[str], caption: Optional[str], fallback: str) -> str:
|
| 26 |
+
"""Readable title for bulk uploads: file name without extension, else first caption line."""
|
| 27 |
+
import os
|
| 28 |
+
|
| 29 |
+
name = os.path.splitext(file_name)[0] if file_name else ""
|
| 30 |
+
name = name.replace("_", " ").strip()
|
| 31 |
+
if not name and caption:
|
| 32 |
+
name = caption.strip().split("\n")[0].strip()
|
| 33 |
+
return (name or fallback)[:250]
|
tests/test_logic.py
CHANGED
|
@@ -11,7 +11,7 @@ import asyncio # noqa: E402
|
|
| 11 |
from app.core import cache # noqa: E402
|
| 12 |
from app.core.aio import gather_results, gather_safe # noqa: E402
|
| 13 |
from app.services.engagement import DAILY_POINTS, MILESTONES, touch # noqa: E402
|
| 14 |
-
from app.services.ingest import parse_caption # noqa: E402
|
| 15 |
|
| 16 |
|
| 17 |
def _user(**kw):
|
|
@@ -85,6 +85,12 @@ def test_plain_gather_fails_but_ours_works():
|
|
| 85 |
asyncio.run(go())
|
| 86 |
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
if __name__ == "__main__":
|
| 89 |
for name, fn in list(globals().items()):
|
| 90 |
if name.startswith("test_"):
|
|
|
|
| 11 |
from app.core import cache # noqa: E402
|
| 12 |
from app.core.aio import gather_results, gather_safe # noqa: E402
|
| 13 |
from app.services.engagement import DAILY_POINTS, MILESTONES, touch # noqa: E402
|
| 14 |
+
from app.services.ingest import clean_title, parse_caption # noqa: E402
|
| 15 |
|
| 16 |
|
| 17 |
def _user(**kw):
|
|
|
|
| 85 |
asyncio.run(go())
|
| 86 |
|
| 87 |
|
| 88 |
+
def test_clean_title():
|
| 89 |
+
assert clean_title("Math1003_Module_Final.pdf", None, "x") == "Math1003 Module Final"
|
| 90 |
+
assert clean_title(None, "Physics slides\n#physics", "x") == "Physics slides"
|
| 91 |
+
assert clean_title(None, None, "fallback") == "fallback"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
if __name__ == "__main__":
|
| 95 |
for name, fn in list(globals().items()):
|
| 96 |
if name.startswith("test_"):
|