Ahmad3g commited on
Commit
c31b50d
Β·
1 Parent(s): 780f9b7
Files changed (2) hide show
  1. handlers/user.py +101 -82
  2. 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
- """Helper to determine user's role: 'owner', 'admin', or 'user'."""
24
  if username and username.lower() == config.OWNER_USERNAME.lower():
25
  return "owner"
26
- admin = await crud.get_admin(session, user_id)
27
- if admin:
28
  return "admin"
29
  return "user"
30
 
31
 
 
 
32
  @router.message(CommandStart())
33
  async def cmd_start(message: Message):
34
  """
35
- Entry point for all users. Registers them in the DB and shows
36
- the appropriate main menu based on their role.
 
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 organized folders to find information, "
47
- f"files, links, and more.\n\n"
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 (role-determined)."""
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
- Handles folder browsing navigation.
79
- Format: browse:root:page OR browse:folder:folder_id:page
 
 
 
80
  """
81
- parts = callback.data.split(":")
82
- # browse:root:page β†’ parts = ["browse","root","0"]
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 = int(parts[2]) if len(parts) > 2 else 0
96
  parent_id = None
97
- folders = await crud.get_root_folders(session)
98
- items = [] # Root level shows only folders
99
- location_name = "πŸ“š Knowledge Base"
100
  else:
101
- # parts = ["browse", "folder", "123", "0"]
102
  folder_id = int(parts[2])
103
- page = int(parts[3]) if len(parts) > 3 else 0
104
  parent_id = folder_id
105
- folders = await crud.get_subfolders(session, folder_id)
106
- items = await crud.get_folder_items(session, folder_id)
107
- folder = await crud.get_folder(session, folder_id)
108
- location_name = f"{folder.emoji} <b>{folder.name}</b>" if folder else "πŸ“ Folder"
 
 
 
109
 
110
- # Log this navigation event
111
- await crud.log_user_action(session, user.id, f"Browsed: {location_name.replace('<b>','').replace('</b>','')}")
 
112
 
113
- # Determine if showing admin controls (admin not in user-view mode)
114
- # View toggle state is managed via user data (see admin handler)
115
- view_state = callback.bot.get("view_states", {})
116
- in_user_view = view_state.get(user.id, False) if view_state else False
117
- show_admin = is_admin and not in_user_view
118
 
119
- total_items = len(folders) + len(items)
120
  text = (
121
- f"{location_name}\n\n"
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(folders, items, parent_id, page, show_admin, role == "owner"),
129
- parse_mode="HTML"
 
 
 
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 to the user."""
137
  item_id = int(callback.data.split(":")[2])
138
- user = callback.from_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
- view_state = callback.bot.get("view_states", {})
151
- in_user_view = view_state.get(user.id, False) if view_state else False
152
- is_admin = role in ("admin", "owner") and not in_user_view
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
- callback.from_user.id,
162
- photo=item.file_id,
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
- callback.from_user.id,
171
- video=item.file_id,
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
- callback.from_user.id,
180
- document=item.file_id,
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
- callback.from_user.id,
189
- audio=item.file_id,
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(text, reply_markup=markup, parse_mode="HTML",
197
- disable_web_page_preview=False)
 
 
 
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, organized repository of "
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 authorized administrators.</i>"
 
 
 
 
 
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
- ⚠️ FIX APPLIED: Web server starts immediately so HF Space doesn't time out
9
- while the DB is still connecting. DB errors won't kill the web server.
 
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 β€” it keeps looping so asyncio.gather keeps it alive.
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 matters: owner > admin > user so privileged handlers
106
- intercept callbacks before the generic user handlers do.
107
  """
108
  dp = Dispatcher(storage=MemoryStorage())
109
- dp.include_router(owner.router) # Owner-only actions
110
- dp.include_router(admin.router) # Admin actions + FSM
111
- dp.include_router(user.router) # Public browsing
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
- so a slow Supabase cold-start doesn't crash the whole app.
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 # Web server keeps running so HF shows a page
128
 
129
  # ── 2. DB initialisation with retry ──────────────────────
130
- MAX_RETRIES = 5
131
- RETRY_DELAY = 5 # seconds between retries
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. Bot will NOT start.")
147
- return # Web server stays alive
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
- # Share the VIEW_STATES dict across handlers via the bot object
156
- from handlers.admin import VIEW_STATES
157
- bot["view_states"] = VIEW_STATES
 
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
- ⚠️ IMPORTANT ORDER:
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(), # Never exits β€” keeps the Space alive
203
- run_bot(), # Polls Telegram; restarts on /start after crash
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