Spaces:
Runtime error
Runtime error
hh
Browse files- handlers/user.py +101 -82
- main.py +24 -28
handlers/user.py
CHANGED
|
@@ -1,6 +1,10 @@
|
|
| 1 |
"""
|
| 2 |
handlers/user.py β Handles all interactions for regular users.
|
| 3 |
Users can only browse and view content; no editing capability.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
import logging
|
|
@@ -12,130 +16,146 @@ from config import config
|
|
| 12 |
from database.db import AsyncSessionFactory
|
| 13 |
from database import crud
|
| 14 |
from keyboards.inline import (
|
| 15 |
-
main_menu_user, folder_list_keyboard, item_view_keyboard
|
| 16 |
)
|
| 17 |
|
| 18 |
logger = logging.getLogger(__name__)
|
| 19 |
router = Router()
|
| 20 |
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
async def _get_role(session, user_id: int, username: str | None) -> str:
|
| 23 |
-
"""
|
| 24 |
if username and username.lower() == config.OWNER_USERNAME.lower():
|
| 25 |
return "owner"
|
| 26 |
-
|
| 27 |
-
if
|
| 28 |
return "admin"
|
| 29 |
return "user"
|
| 30 |
|
| 31 |
|
|
|
|
|
|
|
| 32 |
@router.message(CommandStart())
|
| 33 |
async def cmd_start(message: Message):
|
| 34 |
"""
|
| 35 |
-
Entry point for
|
| 36 |
-
|
|
|
|
| 37 |
"""
|
| 38 |
user = message.from_user
|
| 39 |
async with AsyncSessionFactory() as session:
|
| 40 |
await crud.upsert_user(session, user.id, user.username, user.full_name)
|
| 41 |
-
role = await _get_role(session, user.id, user.username)
|
| 42 |
|
| 43 |
welcome_text = (
|
| 44 |
f"π <b>Welcome, {user.first_name}!</b>\n\n"
|
| 45 |
f"π§ This is your interactive <b>Knowledge Base</b>.\n"
|
| 46 |
-
f"Browse through
|
| 47 |
-
f"
|
| 48 |
f"<i>Use the buttons below to navigate.</i>"
|
| 49 |
)
|
|
|
|
| 50 |
|
| 51 |
-
# Show role-specific menu β imported and used in main.py via the
|
| 52 |
-
# role-based dispatcher. This handler only runs for pure 'user' role.
|
| 53 |
-
if role == "user":
|
| 54 |
-
await message.answer(welcome_text, reply_markup=main_menu_user(), parse_mode="HTML")
|
| 55 |
-
# Admin/Owner start is handled in their respective routers
|
| 56 |
|
|
|
|
| 57 |
|
| 58 |
@router.callback_query(F.data == "home")
|
| 59 |
async def cb_home(callback: CallbackQuery):
|
| 60 |
-
"""Return to the main menu
|
| 61 |
-
# This callback is used as a catch-all; role-specific routers intercept first.
|
| 62 |
user = callback.from_user
|
| 63 |
async with AsyncSessionFactory() as session:
|
| 64 |
role = await _get_role(session, user.id, user.username)
|
| 65 |
|
|
|
|
|
|
|
| 66 |
if role == "user":
|
| 67 |
await callback.message.edit_text(
|
| 68 |
"π <b>Main Menu</b>\n\nChoose an option below:",
|
| 69 |
reply_markup=main_menu_user(),
|
| 70 |
-
parse_mode="HTML"
|
| 71 |
)
|
| 72 |
await callback.answer()
|
| 73 |
|
| 74 |
|
|
|
|
|
|
|
| 75 |
@router.callback_query(F.data.startswith("browse:"))
|
| 76 |
async def cb_browse(callback: CallbackQuery):
|
| 77 |
"""
|
| 78 |
-
|
| 79 |
-
|
|
|
|
|
|
|
|
|
|
| 80 |
"""
|
| 81 |
-
parts
|
| 82 |
-
|
| 83 |
-
# browse:folder:ID:page β parts = ["browse","folder","123","0"]
|
| 84 |
-
user = callback.from_user
|
| 85 |
|
| 86 |
async with AsyncSessionFactory() as session:
|
| 87 |
role = await _get_role(session, user.id, user.username)
|
| 88 |
is_admin = role in ("admin", "owner")
|
| 89 |
|
| 90 |
-
# Check if admin is in user-view mode (stored in bot's state or session)
|
| 91 |
-
# We'll use a simple DB-less approach: check callback origin
|
| 92 |
-
# Full view-toggle logic lives in admin handler; here we just render
|
| 93 |
-
|
| 94 |
if parts[1] == "root":
|
| 95 |
-
page
|
| 96 |
parent_id = None
|
| 97 |
-
folders
|
| 98 |
-
items
|
| 99 |
-
|
| 100 |
else:
|
| 101 |
-
#
|
| 102 |
folder_id = int(parts[2])
|
| 103 |
-
page
|
| 104 |
parent_id = folder_id
|
| 105 |
-
folders
|
| 106 |
-
items
|
| 107 |
-
folder
|
| 108 |
-
|
|
|
|
|
|
|
|
|
|
| 109 |
|
| 110 |
-
# Log
|
| 111 |
-
|
|
|
|
| 112 |
|
| 113 |
-
# Determine
|
| 114 |
-
#
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
show_admin = is_admin and not in_user_view
|
| 118 |
|
| 119 |
-
total_items = len(folders) + len(items)
|
| 120 |
text = (
|
| 121 |
-
f"{
|
| 122 |
f"π <i>{len(folders)} folder(s) Β· {len(items)} item(s)</i>\n\n"
|
| 123 |
f"<i>Select an item to view its content.</i>"
|
| 124 |
)
|
| 125 |
|
| 126 |
await callback.message.edit_text(
|
| 127 |
text,
|
| 128 |
-
reply_markup=folder_list_keyboard(
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
| 130 |
)
|
| 131 |
await callback.answer()
|
| 132 |
|
| 133 |
|
|
|
|
|
|
|
| 134 |
@router.callback_query(F.data.startswith("view:item:"))
|
| 135 |
async def cb_view_item(callback: CallbackQuery):
|
| 136 |
-
"""Displays a specific content item
|
| 137 |
item_id = int(callback.data.split(":")[2])
|
| 138 |
-
user
|
| 139 |
|
| 140 |
async with AsyncSessionFactory() as session:
|
| 141 |
item = await crud.get_item(session, item_id)
|
|
@@ -147,57 +167,51 @@ async def cb_view_item(callback: CallbackQuery):
|
|
| 147 |
await callback.answer("β Item not found.", show_alert=True)
|
| 148 |
return
|
| 149 |
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
caption = f"<b>{item.title}</b>"
|
| 155 |
-
markup = item_view_keyboard(item.id, item.folder_id, is_admin)
|
| 156 |
|
| 157 |
try:
|
| 158 |
if item.content_type == "photo":
|
| 159 |
await callback.message.delete()
|
| 160 |
await callback.bot.send_photo(
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
caption=caption,
|
| 164 |
-
reply_markup=markup,
|
| 165 |
-
parse_mode="HTML"
|
| 166 |
)
|
|
|
|
| 167 |
elif item.content_type == "video":
|
| 168 |
await callback.message.delete()
|
| 169 |
await callback.bot.send_video(
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
caption=caption,
|
| 173 |
-
reply_markup=markup,
|
| 174 |
-
parse_mode="HTML"
|
| 175 |
)
|
|
|
|
| 176 |
elif item.content_type == "document":
|
| 177 |
await callback.message.delete()
|
| 178 |
await callback.bot.send_document(
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
caption=caption,
|
| 182 |
-
reply_markup=markup,
|
| 183 |
-
parse_mode="HTML"
|
| 184 |
)
|
|
|
|
| 185 |
elif item.content_type == "audio":
|
| 186 |
await callback.message.delete()
|
| 187 |
await callback.bot.send_audio(
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
caption=caption,
|
| 191 |
-
reply_markup=markup,
|
| 192 |
-
parse_mode="HTML"
|
| 193 |
)
|
|
|
|
| 194 |
elif item.content_type == "link":
|
| 195 |
text = f"π <b>{item.title}</b>\n\n{item.text_content}"
|
| 196 |
-
await callback.message.edit_text(
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
| 198 |
elif item.content_type == "text":
|
| 199 |
text = f"π <b>{item.title}</b>\n\n{item.text_content}"
|
| 200 |
await callback.message.edit_text(text, reply_markup=markup, parse_mode="HTML")
|
|
|
|
| 201 |
except Exception as e:
|
| 202 |
logger.error(f"Error sending item {item_id}: {e}")
|
| 203 |
await callback.answer("β Failed to load content.", show_alert=True)
|
|
@@ -206,16 +220,21 @@ async def cb_view_item(callback: CallbackQuery):
|
|
| 206 |
await callback.answer()
|
| 207 |
|
| 208 |
|
|
|
|
|
|
|
| 209 |
@router.callback_query(F.data == "about")
|
| 210 |
async def cb_about(callback: CallbackQuery):
|
| 211 |
text = (
|
| 212 |
"βΉοΈ <b>About This Knowledge Base</b>\n\n"
|
| 213 |
-
"This bot is an interactive,
|
| 214 |
"documents, videos, links, and text resources.\n\n"
|
| 215 |
"π Use folders to navigate topics.\n"
|
| 216 |
"π Click any item to view its content.\n\n"
|
| 217 |
-
"<i>Content is managed by
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
)
|
| 219 |
-
from keyboards.inline import back_home_keyboard
|
| 220 |
-
await callback.message.edit_text(text, reply_markup=back_home_keyboard(), parse_mode="HTML")
|
| 221 |
await callback.answer()
|
|
|
|
| 1 |
"""
|
| 2 |
handlers/user.py β Handles all interactions for regular users.
|
| 3 |
Users can only browse and view content; no editing capability.
|
| 4 |
+
|
| 5 |
+
FIX: Replaced callback.bot.get("view_states", {}) with a direct import of
|
| 6 |
+
VIEW_STATES from handlers.admin. The Bot object in aiogram v3 does not
|
| 7 |
+
support dictionary-style access.
|
| 8 |
"""
|
| 9 |
|
| 10 |
import logging
|
|
|
|
| 16 |
from database.db import AsyncSessionFactory
|
| 17 |
from database import crud
|
| 18 |
from keyboards.inline import (
|
| 19 |
+
main_menu_user, folder_list_keyboard, item_view_keyboard, back_home_keyboard
|
| 20 |
)
|
| 21 |
|
| 22 |
logger = logging.getLogger(__name__)
|
| 23 |
router = Router()
|
| 24 |
|
| 25 |
|
| 26 |
+
# ββ Shared view-toggle state (defined in admin.py, imported here) βββββββββ
|
| 27 |
+
# We do a lazy import inside functions to avoid circular imports at module load.
|
| 28 |
+
def _get_view_states() -> dict:
|
| 29 |
+
"""Returns the shared VIEW_STATES dict from handlers.admin."""
|
| 30 |
+
from handlers.admin import VIEW_STATES
|
| 31 |
+
return VIEW_STATES
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
# βββββββββββββββββββββββββββ ROLE HELPER βββββββββββββββββββββββββββββββββ
|
| 35 |
+
|
| 36 |
async def _get_role(session, user_id: int, username: str | None) -> str:
|
| 37 |
+
"""Returns 'owner', 'admin', or 'user'."""
|
| 38 |
if username and username.lower() == config.OWNER_USERNAME.lower():
|
| 39 |
return "owner"
|
| 40 |
+
admin_rec = await crud.get_admin(session, user_id)
|
| 41 |
+
if admin_rec:
|
| 42 |
return "admin"
|
| 43 |
return "user"
|
| 44 |
|
| 45 |
|
| 46 |
+
# βββββββββββββββββββββββββββ /start ββββββββββββββββββββββββββββββββββββββ
|
| 47 |
+
|
| 48 |
@router.message(CommandStart())
|
| 49 |
async def cmd_start(message: Message):
|
| 50 |
"""
|
| 51 |
+
Entry point for regular users.
|
| 52 |
+
Admin/Owner /start is handled first by their own routers (higher priority).
|
| 53 |
+
This handler only fires if neither owner.router nor admin.router claimed it.
|
| 54 |
"""
|
| 55 |
user = message.from_user
|
| 56 |
async with AsyncSessionFactory() as session:
|
| 57 |
await crud.upsert_user(session, user.id, user.username, user.full_name)
|
|
|
|
| 58 |
|
| 59 |
welcome_text = (
|
| 60 |
f"π <b>Welcome, {user.first_name}!</b>\n\n"
|
| 61 |
f"π§ This is your interactive <b>Knowledge Base</b>.\n"
|
| 62 |
+
f"Browse through organised folders to find documents, "
|
| 63 |
+
f"videos, links, and more.\n\n"
|
| 64 |
f"<i>Use the buttons below to navigate.</i>"
|
| 65 |
)
|
| 66 |
+
await message.answer(welcome_text, reply_markup=main_menu_user(), parse_mode="HTML")
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
+
# βββββββββββββββββββββββββββ HOME ββββββββββββββββββββββββββββββββββββββββ
|
| 70 |
|
| 71 |
@router.callback_query(F.data == "home")
|
| 72 |
async def cb_home(callback: CallbackQuery):
|
| 73 |
+
"""Return to the main menu for regular users."""
|
|
|
|
| 74 |
user = callback.from_user
|
| 75 |
async with AsyncSessionFactory() as session:
|
| 76 |
role = await _get_role(session, user.id, user.username)
|
| 77 |
|
| 78 |
+
# Admin/Owner home is handled by admin.router (registered before this router).
|
| 79 |
+
# This handler only runs for pure 'user' role.
|
| 80 |
if role == "user":
|
| 81 |
await callback.message.edit_text(
|
| 82 |
"π <b>Main Menu</b>\n\nChoose an option below:",
|
| 83 |
reply_markup=main_menu_user(),
|
| 84 |
+
parse_mode="HTML",
|
| 85 |
)
|
| 86 |
await callback.answer()
|
| 87 |
|
| 88 |
|
| 89 |
+
# βββββββββββββββββββββββββββ BROWSE ββββββββββββββββββββββββββββββββββββββ
|
| 90 |
+
|
| 91 |
@router.callback_query(F.data.startswith("browse:"))
|
| 92 |
async def cb_browse(callback: CallbackQuery):
|
| 93 |
"""
|
| 94 |
+
Folder browsing navigation.
|
| 95 |
+
|
| 96 |
+
Callback formats:
|
| 97 |
+
browse:root:<page>
|
| 98 |
+
browse:folder:<folder_id>:<page>
|
| 99 |
"""
|
| 100 |
+
parts = callback.data.split(":")
|
| 101 |
+
user = callback.from_user
|
|
|
|
|
|
|
| 102 |
|
| 103 |
async with AsyncSessionFactory() as session:
|
| 104 |
role = await _get_role(session, user.id, user.username)
|
| 105 |
is_admin = role in ("admin", "owner")
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
if parts[1] == "root":
|
| 108 |
+
page = int(parts[2]) if len(parts) > 2 else 0
|
| 109 |
parent_id = None
|
| 110 |
+
folders = await crud.get_root_folders(session)
|
| 111 |
+
items = []
|
| 112 |
+
loc_name = "π Knowledge Base"
|
| 113 |
else:
|
| 114 |
+
# browse:folder:<id>:<page>
|
| 115 |
folder_id = int(parts[2])
|
| 116 |
+
page = int(parts[3]) if len(parts) > 3 else 0
|
| 117 |
parent_id = folder_id
|
| 118 |
+
folders = await crud.get_subfolders(session, folder_id)
|
| 119 |
+
items = await crud.get_folder_items(session, folder_id)
|
| 120 |
+
folder = await crud.get_folder(session, folder_id)
|
| 121 |
+
loc_name = (
|
| 122 |
+
f"{folder.emoji} <b>{folder.name}</b>"
|
| 123 |
+
if folder else "π Folder"
|
| 124 |
+
)
|
| 125 |
|
| 126 |
+
# Log navigation
|
| 127 |
+
clean = loc_name.replace("<b>", "").replace("</b>", "")
|
| 128 |
+
await crud.log_user_action(session, user.id, f"Browsed: {clean}")
|
| 129 |
|
| 130 |
+
# Determine whether to show admin controls
|
| 131 |
+
# (hidden when the admin toggled "View as User")
|
| 132 |
+
in_user_view = _get_view_states().get(user.id, False)
|
| 133 |
+
show_admin = is_admin and not in_user_view
|
|
|
|
| 134 |
|
|
|
|
| 135 |
text = (
|
| 136 |
+
f"{loc_name}\n\n"
|
| 137 |
f"π <i>{len(folders)} folder(s) Β· {len(items)} item(s)</i>\n\n"
|
| 138 |
f"<i>Select an item to view its content.</i>"
|
| 139 |
)
|
| 140 |
|
| 141 |
await callback.message.edit_text(
|
| 142 |
text,
|
| 143 |
+
reply_markup=folder_list_keyboard(
|
| 144 |
+
folders, items, parent_id, page,
|
| 145 |
+
show_admin, role == "owner",
|
| 146 |
+
),
|
| 147 |
+
parse_mode="HTML",
|
| 148 |
)
|
| 149 |
await callback.answer()
|
| 150 |
|
| 151 |
|
| 152 |
+
# βββββββββββββββββββββββββββ VIEW ITEM βββββββββββββββββββββββββββββββββββ
|
| 153 |
+
|
| 154 |
@router.callback_query(F.data.startswith("view:item:"))
|
| 155 |
async def cb_view_item(callback: CallbackQuery):
|
| 156 |
+
"""Displays a specific content item."""
|
| 157 |
item_id = int(callback.data.split(":")[2])
|
| 158 |
+
user = callback.from_user
|
| 159 |
|
| 160 |
async with AsyncSessionFactory() as session:
|
| 161 |
item = await crud.get_item(session, item_id)
|
|
|
|
| 167 |
await callback.answer("β Item not found.", show_alert=True)
|
| 168 |
return
|
| 169 |
|
| 170 |
+
in_user_view = _get_view_states().get(user.id, False)
|
| 171 |
+
is_admin = role in ("admin", "owner") and not in_user_view
|
| 172 |
+
caption = f"<b>{item.title}</b>"
|
| 173 |
+
markup = item_view_keyboard(item.id, item.folder_id, is_admin)
|
|
|
|
|
|
|
| 174 |
|
| 175 |
try:
|
| 176 |
if item.content_type == "photo":
|
| 177 |
await callback.message.delete()
|
| 178 |
await callback.bot.send_photo(
|
| 179 |
+
user.id, photo=item.file_id,
|
| 180 |
+
caption=caption, reply_markup=markup, parse_mode="HTML",
|
|
|
|
|
|
|
|
|
|
| 181 |
)
|
| 182 |
+
|
| 183 |
elif item.content_type == "video":
|
| 184 |
await callback.message.delete()
|
| 185 |
await callback.bot.send_video(
|
| 186 |
+
user.id, video=item.file_id,
|
| 187 |
+
caption=caption, reply_markup=markup, parse_mode="HTML",
|
|
|
|
|
|
|
|
|
|
| 188 |
)
|
| 189 |
+
|
| 190 |
elif item.content_type == "document":
|
| 191 |
await callback.message.delete()
|
| 192 |
await callback.bot.send_document(
|
| 193 |
+
user.id, document=item.file_id,
|
| 194 |
+
caption=caption, reply_markup=markup, parse_mode="HTML",
|
|
|
|
|
|
|
|
|
|
| 195 |
)
|
| 196 |
+
|
| 197 |
elif item.content_type == "audio":
|
| 198 |
await callback.message.delete()
|
| 199 |
await callback.bot.send_audio(
|
| 200 |
+
user.id, audio=item.file_id,
|
| 201 |
+
caption=caption, reply_markup=markup, parse_mode="HTML",
|
|
|
|
|
|
|
|
|
|
| 202 |
)
|
| 203 |
+
|
| 204 |
elif item.content_type == "link":
|
| 205 |
text = f"π <b>{item.title}</b>\n\n{item.text_content}"
|
| 206 |
+
await callback.message.edit_text(
|
| 207 |
+
text, reply_markup=markup,
|
| 208 |
+
parse_mode="HTML", disable_web_page_preview=False,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
elif item.content_type == "text":
|
| 212 |
text = f"π <b>{item.title}</b>\n\n{item.text_content}"
|
| 213 |
await callback.message.edit_text(text, reply_markup=markup, parse_mode="HTML")
|
| 214 |
+
|
| 215 |
except Exception as e:
|
| 216 |
logger.error(f"Error sending item {item_id}: {e}")
|
| 217 |
await callback.answer("β Failed to load content.", show_alert=True)
|
|
|
|
| 220 |
await callback.answer()
|
| 221 |
|
| 222 |
|
| 223 |
+
# βββββββββββββββββββββββββββ ABOUT βββββββββββββββββββββββββββββββββββββββ
|
| 224 |
+
|
| 225 |
@router.callback_query(F.data == "about")
|
| 226 |
async def cb_about(callback: CallbackQuery):
|
| 227 |
text = (
|
| 228 |
"βΉοΈ <b>About This Knowledge Base</b>\n\n"
|
| 229 |
+
"This bot is an interactive, organised repository of "
|
| 230 |
"documents, videos, links, and text resources.\n\n"
|
| 231 |
"π Use folders to navigate topics.\n"
|
| 232 |
"π Click any item to view its content.\n\n"
|
| 233 |
+
"<i>Content is managed by authorised administrators.</i>"
|
| 234 |
+
)
|
| 235 |
+
await callback.message.edit_text(
|
| 236 |
+
text,
|
| 237 |
+
reply_markup=back_home_keyboard(),
|
| 238 |
+
parse_mode="HTML",
|
| 239 |
)
|
|
|
|
|
|
|
| 240 |
await callback.answer()
|
main.py
CHANGED
|
@@ -5,8 +5,9 @@ Runs TWO concurrent async services:
|
|
| 5 |
1. An aiohttp web server on port 7860 (Hugging Face keepalive β starts FIRST)
|
| 6 |
2. The aiogram Telegram bot (long polling)
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import asyncio
|
|
@@ -77,7 +78,7 @@ async def handle_health(request: web.Request) -> web.Response:
|
|
| 77 |
async def run_web_server() -> None:
|
| 78 |
"""
|
| 79 |
Starts aiohttp on 0.0.0.0:7860.
|
| 80 |
-
This coroutine NEVER exits β
|
| 81 |
"""
|
| 82 |
app = web.Application()
|
| 83 |
app.router.add_get("/", handle_root)
|
|
@@ -102,21 +103,20 @@ async def run_web_server() -> None:
|
|
| 102 |
def build_dispatcher() -> Dispatcher:
|
| 103 |
"""
|
| 104 |
Creates the Dispatcher and registers all routers.
|
| 105 |
-
Order
|
| 106 |
-
intercept callbacks before the generic user handlers do.
|
| 107 |
"""
|
| 108 |
dp = Dispatcher(storage=MemoryStorage())
|
| 109 |
-
dp.include_router(owner.router)
|
| 110 |
-
dp.include_router(admin.router)
|
| 111 |
-
dp.include_router(user.router)
|
| 112 |
return dp
|
| 113 |
|
| 114 |
|
| 115 |
async def run_bot() -> None:
|
| 116 |
"""
|
| 117 |
Initialises the database, then starts the Telegram bot.
|
| 118 |
-
Retries the DB connection up to 5 times before giving up
|
| 119 |
-
|
| 120 |
"""
|
| 121 |
# ββ 1. Config validation ββββββββββββββββββββββββββββββββββ
|
| 122 |
try:
|
|
@@ -124,11 +124,11 @@ async def run_bot() -> None:
|
|
| 124 |
except EnvironmentError as e:
|
| 125 |
logger.critical(str(e))
|
| 126 |
BOT_STATUS["error"] = str(e)
|
| 127 |
-
return
|
| 128 |
|
| 129 |
# ββ 2. DB initialisation with retry ββββββββββββββββββββββ
|
| 130 |
-
MAX_RETRIES
|
| 131 |
-
RETRY_DELAY
|
| 132 |
|
| 133 |
for attempt in range(1, MAX_RETRIES + 1):
|
| 134 |
try:
|
|
@@ -143,8 +143,8 @@ async def run_bot() -> None:
|
|
| 143 |
logger.info(f"β³ Retrying in {RETRY_DELAY}sβ¦")
|
| 144 |
await asyncio.sleep(RETRY_DELAY)
|
| 145 |
else:
|
| 146 |
-
logger.critical("π₯ Could not connect to database after all retries.
|
| 147 |
-
return
|
| 148 |
|
| 149 |
# ββ 3. Create Bot instance ββββββββββββββββββββββββββββββββ
|
| 150 |
bot = Bot(
|
|
@@ -152,9 +152,10 @@ async def run_bot() -> None:
|
|
| 152 |
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
| 153 |
)
|
| 154 |
|
| 155 |
-
#
|
| 156 |
-
|
| 157 |
-
|
|
|
|
| 158 |
|
| 159 |
dp = build_dispatcher()
|
| 160 |
|
|
@@ -165,7 +166,6 @@ async def run_bot() -> None:
|
|
| 165 |
logger.info("π Bot is now polling for updatesβ¦")
|
| 166 |
|
| 167 |
try:
|
| 168 |
-
# Drop stale updates from while the bot was offline
|
| 169 |
await bot.delete_webhook(drop_pending_updates=True)
|
| 170 |
await dp.start_polling(
|
| 171 |
bot,
|
|
@@ -186,21 +186,17 @@ async def run_bot() -> None:
|
|
| 186 |
|
| 187 |
async def main() -> None:
|
| 188 |
"""
|
| 189 |
-
Runs the web server and the bot concurrently.
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
asyncio.gather starts both coroutines "at the same time", but
|
| 193 |
-
run_web_server() has no awaits before site.start(), so it binds
|
| 194 |
-
port 7860 within milliseconds. The bot's DB retry loop can take
|
| 195 |
-
up to 25 s without blocking the web server.
|
| 196 |
"""
|
| 197 |
print(f"\n{'='*50}")
|
| 198 |
print(f" Application Startup at {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
| 199 |
print(f"{'='*50}\n")
|
| 200 |
|
| 201 |
await asyncio.gather(
|
| 202 |
-
run_web_server(),
|
| 203 |
-
run_bot(),
|
| 204 |
)
|
| 205 |
|
| 206 |
|
|
|
|
| 5 |
1. An aiohttp web server on port 7860 (Hugging Face keepalive β starts FIRST)
|
| 6 |
2. The aiogram Telegram bot (long polling)
|
| 7 |
|
| 8 |
+
FIX: Removed bot["view_states"] assignment β aiogram v3 Bot object does NOT
|
| 9 |
+
support item assignment. VIEW_STATES is a plain module-level dict in
|
| 10 |
+
handlers/admin.py and is imported directly by any handler that needs it.
|
| 11 |
"""
|
| 12 |
|
| 13 |
import asyncio
|
|
|
|
| 78 |
async def run_web_server() -> None:
|
| 79 |
"""
|
| 80 |
Starts aiohttp on 0.0.0.0:7860.
|
| 81 |
+
This coroutine NEVER exits β keeps the HF Space alive.
|
| 82 |
"""
|
| 83 |
app = web.Application()
|
| 84 |
app.router.add_get("/", handle_root)
|
|
|
|
| 103 |
def build_dispatcher() -> Dispatcher:
|
| 104 |
"""
|
| 105 |
Creates the Dispatcher and registers all routers.
|
| 106 |
+
Order: owner > admin > user β privileged handlers intercept first.
|
|
|
|
| 107 |
"""
|
| 108 |
dp = Dispatcher(storage=MemoryStorage())
|
| 109 |
+
dp.include_router(owner.router)
|
| 110 |
+
dp.include_router(admin.router)
|
| 111 |
+
dp.include_router(user.router)
|
| 112 |
return dp
|
| 113 |
|
| 114 |
|
| 115 |
async def run_bot() -> None:
|
| 116 |
"""
|
| 117 |
Initialises the database, then starts the Telegram bot.
|
| 118 |
+
Retries the DB connection up to 5 times before giving up.
|
| 119 |
+
The web server keeps running even if the bot fails to start.
|
| 120 |
"""
|
| 121 |
# ββ 1. Config validation ββββββββββββββββββββββββββββββββββ
|
| 122 |
try:
|
|
|
|
| 124 |
except EnvironmentError as e:
|
| 125 |
logger.critical(str(e))
|
| 126 |
BOT_STATUS["error"] = str(e)
|
| 127 |
+
return
|
| 128 |
|
| 129 |
# ββ 2. DB initialisation with retry ββββββββββββββββββββββ
|
| 130 |
+
MAX_RETRIES = 5
|
| 131 |
+
RETRY_DELAY = 5 # seconds
|
| 132 |
|
| 133 |
for attempt in range(1, MAX_RETRIES + 1):
|
| 134 |
try:
|
|
|
|
| 143 |
logger.info(f"β³ Retrying in {RETRY_DELAY}sβ¦")
|
| 144 |
await asyncio.sleep(RETRY_DELAY)
|
| 145 |
else:
|
| 146 |
+
logger.critical("π₯ Could not connect to database after all retries.")
|
| 147 |
+
return
|
| 148 |
|
| 149 |
# ββ 3. Create Bot instance ββββββββββββββββββββββββββββββββ
|
| 150 |
bot = Bot(
|
|
|
|
| 152 |
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
| 153 |
)
|
| 154 |
|
| 155 |
+
# NOTE: VIEW_STATES is a plain module-level dict in handlers/admin.py.
|
| 156 |
+
# Any handler that needs it imports it directly:
|
| 157 |
+
# from handlers.admin import VIEW_STATES
|
| 158 |
+
# No need to attach anything to the Bot object.
|
| 159 |
|
| 160 |
dp = build_dispatcher()
|
| 161 |
|
|
|
|
| 166 |
logger.info("π Bot is now polling for updatesβ¦")
|
| 167 |
|
| 168 |
try:
|
|
|
|
| 169 |
await bot.delete_webhook(drop_pending_updates=True)
|
| 170 |
await dp.start_polling(
|
| 171 |
bot,
|
|
|
|
| 186 |
|
| 187 |
async def main() -> None:
|
| 188 |
"""
|
| 189 |
+
Runs the web server and the bot concurrently via asyncio.gather.
|
| 190 |
+
The web server binds port 7860 within milliseconds.
|
| 191 |
+
The bot's DB retry loop runs in the background without blocking it.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
"""
|
| 193 |
print(f"\n{'='*50}")
|
| 194 |
print(f" Application Startup at {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S')} UTC")
|
| 195 |
print(f"{'='*50}\n")
|
| 196 |
|
| 197 |
await asyncio.gather(
|
| 198 |
+
run_web_server(),
|
| 199 |
+
run_bot(),
|
| 200 |
)
|
| 201 |
|
| 202 |
|