Repository navigation
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.
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"]
| 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
==againstADMIN_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 anythinggets "دسترسی غیرمجاز" 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 | 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). |
/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.
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.
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.
| 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.
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().
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.
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.
-
/upload→ the bot setscontext.user_data["awaiting_consultants_file"] = Trueand explains that the file will be converted, stored, and hot-reloaded. -
Send the
.xlsx→doc_handlerchecks that you are an admin, that you have an active session, and that/uploadwas issued first. Only.xlsxand.xlsmare accepted. -
Download → the file is fetched over HTTPS with
httpx(60 s timeout). The URL is rewritten fromapi.telegram.orgtotapi.bale.ai, because the download URL must point at Bale. A response that is not200, or is smaller than 100 bytes, is rejected with "❌ خطا در دانلود فایل". -
Save → the file is written next to the dataset as
consultants_YYYYMMDD_HHMMSS.xlsx. -
Convert →
excel_to_json.convert_excel_to_json()producesconsultants_profile_full.json. -
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). -
Hot-reload the engine →
engine.reload()re-indexes the JSON. No bot restart. - 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.
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.
| 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.
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"]
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.
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 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 <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 (خنثی=۱.۰۰، بازه مجاز ۰.۶۰ تا ۱.۶۰)
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.
# 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
| 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. |
- Architecture — where the admin surface sits in the system.
-
Matching Engine — the learning weights that
/feedbackmoves. -
Setup and Deployment — how to configure
ADMIN_USER_IDSand the password. - Troubleshooting — admin-side failure modes.