cipher-attacks commited on
Commit
f932c25
Β·
1 Parent(s): a48b894

bulk upload

Browse files
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>Upload a file:</b> send it here with caption\n"
 
 
 
42
  "<code>&lt;course_id&gt; &lt;slide|exam|note|book&gt; [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
- if not parsed:
 
 
 
 
 
148
  await message.answer(
149
- "❗ Caption needed: <code>&lt;course_id&gt; &lt;" + "|".join(KINDS) + "&gt; [title]</code>\n"
150
- "See /courses for ids."
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
- await message.answer(f"⚠️ Already saved as file #{existing.id}: {existing.title}")
 
 
 
 
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 or f"{course.name} {kind}")[:250],
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
- note = "" if backup_msg else " (no backup copy)"
186
- await message.answer(f"βœ… Saved #{material.id}: {material.title}\nπŸ“˜ {course.name} Β· {kind}{note}")
 
 
 
 
 
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>&lt;course_id&gt; &lt;slide|exam|note|book&gt; [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 &lt;course_id&gt; &lt;slide|exam|note|book&gt;</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 &lt;course_id&gt; &lt;kind&gt;</code> for bulk upload, or send the file with caption\n"
197
+ "<code>&lt;course_id&gt; &lt;" + "|".join(KINDS) + "&gt; [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_"):