Skip to content

Admin Panel

Mohammad Hasan Kaman edited this page Oct 21, 2026 · 2 revisions

Admin Panel

Everything the clinic can do from inside the bot: log in, look at the numbers, export the data, push a new counselor roster, send an announcement, refresh availability, and train the matcher.

The admin surface lives in main.py (commands and flows) with the Excel and broadcast helpers in admin_tools.py.


The session model

Two independent gates, and both must pass.

graph LR
    A["Admin sends a command"] --> B{"uid ∈ ADMIN_USER_IDS ?"}
    B -->|"no"| X["❌ دسترسی غیرمجاز"]
    B -->|"yes"| C{"/admin PASSWORD given<br/>and within 4h TTL ?"}
    C -->|"no"| Y["🔐 /admin YOUR_PASSWORD"]
    C -->|"yes"| D["✅ command executes"]
Loading
Rule Value Where it comes from
Who may try The set of Bale user IDs in ADMIN_USER_IDS config.py — parsed from the env var as a comma-separated list of integers
Password ADMIN_PANEL_PASSWORD config.py — env var
Session lifetime 4 hours (ADMIN_SESSION_TTL = 4 * 3600) main.py
Session storage _admin_sessions: dict — {uid: login_timestamp}, in memory main.py
Expiry check _admin_authenticated(uid) pops the entry and returns False once the TTL has passed main.py
Logout /logout removes the entry immediately main.py

Three practical consequences:

  • A bot restart logs every admin out. The session map is in-process memory, not on disk.
  • The password is checked with == against ADMIN_PANEL_PASSWORD — a plain comparison, so use a long, random password and keep it in the environment only.
  • The ID gate applies before the password gate. A non-admin sending /admin anything gets "دسترسی غیرمجاز" and never learns whether the password was right.

Note. The broadcast flow additionally requires a private chat (filters.ChatType.PRIVATE) and an active session. The other admin commands are not chat-type restricted.


Command reference

Command Requires session What it does
/admin <password> — Login. With the correct password it opens a session and prints the menu. With a wrong password: "❌ رمز عبور نادرست است."
/admin yes (if active) Re-opens the menu when a session is still valid; otherwise it prompts for the password.
/logout no (any admin) Drops the session immediately: "از پنل مدیریت خارج شدید."
/myid no Replies with your own Bale user id: شناسه شما: 123456789. Use it to fill ADMIN_USER_IDS.
/stats yes Live statistics: registered users, total assessments, active counselors, slot status breakdown, top-5 counselors with free slots.
/export yes Generates and sends a two-sheet Excel file of all user data.
/upload yes Starts the counselors-spreadsheet upload flow (expects the next document you send to be the .xlsx).
/broadcast yes Starts the mass-message flow (with a confirmation step).
/cancel_broadcast no (any admin) Cancels a broadcast from any step of the flow.
/crawl yes Triggers a manual availability crawl in the background and reports the result.
/feedback <n> <name> <بله|خیر> yes Records a positive or negative outcome for a (counselor × concept) pair.
/learning yes Shows the 20 most recently updated learning records and their current weights.
/cancel — Aborts the user intake flow (not an admin command, but available to everyone).

The menu

/admin <password> replies with the panel menu:

👑 پنل مدیریت مرکز مشاوره خانواده نیک‌روان
مشاوران فعال: 24

📣 ارسال پیام همگانی (برادکست):
/broadcast - شروع ارسال پیام همگانی (یا روی پیام دلخواه ریپلای کنید)
/cancel_broadcast - لغو عملیات برادکست

📥 دیتا:
/export - دریافت فایل اکسل دیتای کاربران
/upload - آپلود فایل اکسل جدید مشاوران (تبدیل + ذخیره در دیتابیس)

⚙️ سیستم:
/crawl - به‌روزرسانی دستی نوبت‌ها از سایت
/stats - آمار نوبت‌ها و مراجعین
/feedback - ثبت بازخورد برای سیستم خودآموز (موفق/ناموفق)
/learning - وضعیت یادگیری فعلی موتور
/logout - خروج از پنل

The "مشاوران فعال" count is len(engine.profiles) — the number of profiles currently indexed by the matching engine.


/stats — the dashboard

One message, assembled from three queries:

Block Source
👥 Registered users SELECT COUNT(*) FROM users
📑 Total assessments SELECT COUNT(*) FROM user_requests
👩‍⚕️ Active counselors len(engine.profiles) — the Excel roster the engine is serving
Slot status SELECT status, COUNT(*) FROM appointments GROUP BY status
🎯 Last-crawl result Distinct counselors with status='free' vs. total distinct counselors in the table, plus the total number of free slots
📌 Of the engine's counselors How many of engine.profiles currently have a free slot — matched with names_match() (fuzzy), so a name spelled differently on the website still counts
🔥 Top free slots Up to 5 counselors with the most free slots, plus a "… and N more" line

Status labels used in the output:

Status Label
free 🟢 نوبت آزاد
waiting 🟡 لیست انتظار
no_available 🔴 بدون نوبت فعلی
no_free_slots 🔴 بدون نوبت آزاد
phone_only 📞 صرفاً تماس تلفنی
no_table ⚠️ جدول نوبت یافت نشد

If the last crawl found no free slots, the message says so explicitly and points at /crawl.


/export — the Excel file

admin_tools.export_users_excel() builds the workbook in memory (no temporary file on disk) and returns the bytes. main.py wraps them in a BytesIO named counseling_users.xlsx and sends it with a caption that names both sheets and stamps the generation time.

Two sheets, one set of columns

Sheet Name Sort order
1 کاربران (Users) full_name COLLATE NOCASE ASC, request_number ASC
2 سوابق درخواست‌ها (Request history) created_at DESC — newest first

Both sheets carry the same 22 columns and the same rows; only the ordering differs. That makes sheet 1 the "who is registered" view and sheet 2 the "what happened when" view.

The 22 columns

USERS_HEADERS = REQUESTS_HEADERS = [...] — one shared list, used for both sheets.

# Column header (Persian) Content
1 ردیف Row number
2 نام و نام خانوادگی Full name
3 شماره تماس Phone number
4 شناسه بله (Chat ID) Bale chat id
5 جنسیت Gender
6 سن Age
7 موضوع مشاوره (مشکل اصلی) Main topic
8 سابقه مراجعه قبلی به روانشناس/روانپزشک Previous therapy → بله / خیر
9 توضیحات درمان قبلی Details of previous treatment
10 انتظار یا رویکرد موردنظر از مشاوره Expectation
11 ترجیح جنسیت مشاور Preferred counselor gender
12 شعبه انتخابی Chosen branch (- when empty)
13 GHQ - جسمانی‌شدن (سوماتیک) Somatic subscale (- if not taken)
14 GHQ - اضطراب و بی‌خوابی Anxiety subscale
15 GHQ - اختلال کارکرد اجتماعی Social subscale
16 GHQ - افسردگی Depression subscale
17 نتیجه نهایی GHQ (امتیاز کل) GHQ total
18 وضعیت GHQ انجام نداده / طبیعی / متوسط / شدید
19 مشاوران پیشنهادی شده Recommended counselor names, `
20 دلیل پیشنهاد Per-counselor reasons, `
21 شماره مراجعه Visit number for that phone number
22 تاریخ ثبت درخواست Timestamp

The GHQ subscales are unpacked from the stored JSON, and the recommendation list is flattened into two human-readable columns by _fmt_recs().

Presentation

The header row is styled (dark green fill #2E5E4E, white bold text, centred and wrapped), the first row is frozen (ws.freeze_panes = "A2"), and every column is auto-sized to max(14, min(30, len(header) * 1.6 + 6)) characters.

None and the literal string "None" are converted to empty cells by _clean(), so the sheet never shows a stray "None" in a cell.


/upload — hot-reloading the counselor roster

This is the operationally most important admin command: it is how a new counselor, a new specialization, or a corrected age range reaches the matching engine.

The flow

  1. /upload → the bot sets context.user_data["awaiting_consultants_file"] = True and explains that the file will be converted, stored, and hot-reloaded.
  2. Send the .xlsx → doc_handler checks that you are an admin, that you have an active session, and that /upload was issued first. Only .xlsx and .xlsm are accepted.
  3. Download → the file is fetched over HTTPS with httpx (60 s timeout). The URL is rewritten from api.telegram.org to tapi.bale.ai, because the download URL must point at Bale. A response that is not 200, or is smaller than 100 bytes, is rejected with "❌ خطا در دانلود فایل".
  4. Save → the file is written next to the dataset as consultants_YYYYMMDD_HHMMSS.xlsx.
  5. Convert → excel_to_json.convert_excel_to_json() produces consultants_profile_full.json.
  6. Replace the database → user_db.replace_consultants(profiles, uploaded_by) deletes the existing rows and inserts the new roster, returning a comparison (old_count, new_count, added, removed).
  7. Hot-reload the engine → engine.reload() re-indexes the JSON. No bot restart.
  8. Report → the uploader gets a four-part report (file, counts, added/removed names, data completeness), and every other admin gets a short summary message.

The data-completeness block is worth knowing, because it is the fastest way to spot a bad spreadsheet: it counts how many records actually have an ability value, a location, an education field, a general area, specializations, an age range, a license, and notes, and prints the branch distribution.

The roster is replaced wholesale. A counselor missing from the uploaded file is removed from the database — but the engine only ever reads the JSON file, so the live roster is exactly the uploaded file's contents.


/broadcast — mass messaging

The broadcast is a separate ConversationHandler with two states (BRWAIT_CONTENT, BRCONFIRM), registered before the main intake conversation so that the intake fallback does not swallow admin messages.

Entry points

How you start What happens
/broadcast The bot asks you to send the message. Any content type is allowed: text, photo, video, voice, file.
Reply to any message (outside the intake form) That message becomes the broadcast candidate immediately, skipping the content step.

The _in_main_conv flag makes sure that while you are filling in the intake form, your replies are not mistaken for broadcast content.

The confirmation step

graph TD
    A["/broadcast or reply to a message"] --> B{"admin + private chat<br/>+ active session?"}
    B -->|"no"| X["🔐 rejected"]
    B -->|"yes"| C["content captured<br/>(text / photo / video / voice / file)"]
    C --> D["👥 تعداد گیرندگان: N نفر<br/>آیا از ارسال همگانی این پیام مطمئن هستید؟"]
    D -->|"✅ تایید ارسال"| E["send to every target"]
    D -->|"❌ انصراف / /cancel_broadcast"| F["cancelled, nothing sent"]
    E --> G["🏁 final report:<br/>sent / failed / total"]
Loading

The confirmation screen shows how many people will receive the message before anything is sent. Nothing is broadcast until you press ✅ تایید ارسال; ❌ انصراف (or /cancel_broadcast) aborts without sending anything. Any other text re-prompts for one of the two buttons.

Sending behaviour

admin_tools.send_broadcast() uses bot.copy_message(), so the exact message you sent is delivered — no reformatting, and any media type works.

Constant Value Effect
BROADCAST_BATCH_DELAY 0.05 s Pause after every recipient
BROADCAST_REPORT_EVERY 20 Every 20 recipients, the progress message is edited with the running counts, and the loop sleeps an extra BROADCAST_BATCH_DELAY * 10 = 0.5 s

The extra half-second pause every 20 messages is the anti-rate-limit measure: a flat 50 ms delay is fast enough to trip Bale's throttling on a large list, so the code periodically backs off.

Recipients come from get_broadcast_targets():

SELECT DISTINCT first_telegram_id FROM users WHERE first_telegram_id IS NOT NULL

— everyone who has ever talked to the bot and is registered in the users table.

Failures are counted and classified: an exception whose message contains blocked, forbidden or deactivated also increments a blocked counter (people who blocked the bot or deactivated their account). The final report shows:

🏁 برادکست تمام شد:
✅ موفق: 412 نفر
❌ ناموفق: 7 نفر
👥 کل گیرندگان: 419 نفر

(ناموفق‌ها معمولاً کاربرانی هستند که بات را بلاک کرده‌اند)

/crawl — refreshing availability

/crawl replies immediately ("⏳ فرآیند کراول در پس‌زمینه آغاز شد...") and runs the crawl in a worker thread (ThreadPoolExecutor(max_workers=2)), so the bot stays responsive. When it finishes, the result is sent as a new message:

✅ کراول نوبت‌ها به پایان رسید.

وضعیت مشاوران در سایت:
🟢 نوبت آزاد: 9 مشاور
🔴 بدون نوبت فعلی: 12 مشاور
📞 صرفاً تماس تلفنی: 3 مشاور

🎯 18 نوبت آزاد پیدا شد و در سیستم ثبت شد.

or, if the crawl failed: ❌ خطا در کراولر: ....

The same crawl also runs every 2 hours on the APScheduler and once at startup from post_init. See Architecture.


/feedback — training the engine

/feedback <concept-number> <counselor-name> <بله|خیر>
  • بله (or بله. or ✅) → the referral worked → the weight goes up.
  • خیر (or خیر. or ❌) → it did not → the weight goes down.

The concept number is looked up in FEEDBACK_CONCEPTS:

# Concept # Concept
1 اضطراب 7 والد_فرزند
2 افسردگی 8 ارتباط_تعارض
3 وسواس 9 توسعه_فردی
4 زوج_ازدواج 10 حقوقی
5 کودک 11 پزشکی_روانپزشکی
6 نوجوان_جوان

Persian digits are accepted (۱ → 1). The counselor name is fuzzy-matched against the active roster with names_match(), so a name with a different spelling, a missing title, or a one-character typo still resolves — if it does not resolve, the bot says the counselor was not found in the active list and records nothing.

The reply confirms the outcome and shows the new weight:

🧠 بازخورد ثبت شد

👩‍⚕️ مشاور: زهرا مجاهدی
📋 موضوع: اضطراب
⬆️ نتیجه: موفق ✅
⚖️ وزن فعلی این مشاور در این موضوع: ×1.08 (خنثی=۱.۰۰، بازه مجاز ۰.۶۰ تا ۱.۶۰)

/learning — what the engine has learned

Prints the 20 most recently updated rows of concept_feedback:

🧠 وضعیت سیستم خودآموز (۲۰ رکورد آخر):

⬆️ زهرا مجاهدی — اضطراب: ✅3 ❌0 → ×1.17
⬇️ محمد رضا کمن — والد_فرزند: ✅1 ❌2 → ×0.80
➖ سارا رضایی — افسردگی: ✅0 ❌0 → ×1

If the table is empty, the bot says so and points at /feedback.


Copy-pasteable examples

# Find your own Bale id (works without any admin rights)
/myid

# Log in
/admin MyStrongPassword

# Check the current state
/stats

# Export everything
/export

# Refresh availability right now
/crawl

# Train the engine: a legal request went well with this counselor
/feedback 10 زهرا مجاهدی بله
/feedback 10 زهرا مجاهدی بله
/feedback 1  سارا رضایی    خیر

# See what changed
/learning

# Replace the counselor roster
/upload
   → then send the new counselors_*.xlsx file

# Announce something (then press ✅ تایید ارسال)
/broadcast
   → then send the message

# Leave
/logout

Operational notes

Topic Note
Restarting the bot Clears all admin sessions — everyone must /admin <password> again.
Two admins at once Sessions are per user id, so several admins can be logged in simultaneously. The upload report is broadcast to the other admins, so everyone sees who changed the roster.
Big exports The workbook is generated in a worker thread, so the bot keeps answering while it is built.
Crawl overlap The scheduled crawl and a manual /crawl share one thread pool with 2 workers. The crawler itself has no lock file (see Troubleshooting), so avoid launching a manual crawl right on top of a scheduled one.
Data sensitivity The export contains names, phone numbers, topics, and GHQ scores. Send it only over a private chat, and delete local copies.

See also