├── src
├── __pycache__
│ ├── bot.cpython-313.pyc
│ ├── app_gui.cpython-313.pyc
│ ├── web_app.cpython-313.pyc
│ └── telegram_bot.cpython-313.pyc
├── wsgi.py
├── templates
│ ├── add.html
│ ├── auto.html
│ └── manage.html
├── static
│ └── style.css
├── web_app.py
├── app_gui.py
├── bot.py
└── telegram_bot.py
├── requirements.txt
├── render.yaml
├── fly.toml
├── docker-compose.yml
├── Dockerfile
├── DEPLOYMENT.md
├── README.md
└── config
└── config.json
/src/__pycache__/bot.cpython-313.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/our-services/UCSI/main/src/__pycache__/bot.cpython-313.pyc
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | playwright==1.47.0
2 | python-dotenv==1.0.1
3 | Flask==3.0.0
4 | gunicorn==23.0.0
5 | python-telegram-bot==21.6
6 |
--------------------------------------------------------------------------------
/src/__pycache__/app_gui.cpython-313.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/our-services/UCSI/main/src/__pycache__/app_gui.cpython-313.pyc
--------------------------------------------------------------------------------
/src/__pycache__/web_app.cpython-313.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/our-services/UCSI/main/src/__pycache__/web_app.cpython-313.pyc
--------------------------------------------------------------------------------
/src/__pycache__/telegram_bot.cpython-313.pyc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/our-services/UCSI/main/src/__pycache__/telegram_bot.cpython-313.pyc
--------------------------------------------------------------------------------
/src/wsgi.py:
--------------------------------------------------------------------------------
1 | from src.web_app import app as application
2 |
3 | # This file allows hosting providers (e.g., Render/Heroku/PythonAnywhere)
4 | # to load the Flask app via a WSGI server like Gunicorn.
--------------------------------------------------------------------------------
/render.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | - type: web
3 | name: ucsi-web
4 | env: python
5 | plan: free
6 | autoDeploy: true
7 | healthCheckPath: /status
8 | buildCommand: "pip install --upgrade pip && pip install Flask==3.* gunicorn python-dotenv"
9 | startCommand: "gunicorn -w 2 -b 0.0.0.0:$PORT src.wsgi:application"
10 | envVars:
11 | - key: FLASK_SECRET_KEY
12 | sync: false
13 | - key: WEB_APP_URL
14 | sync: false
15 | - key: TELEGRAM_TOKEN
16 | sync: false
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | app = "ucsi"
2 | primary_region = "sin"
3 |
4 | [build]
5 | dockerfile = "Dockerfile"
6 |
7 | [env]
8 | PORT = "8080"
9 | HOST = "0.0.0.0"
10 | HEADLESS = "1"
11 |
12 | [processes]
13 | web = "gunicorn -b 0.0.0.0:8080 src.wsgi:application"
14 | bot = "python -u src/telegram_bot.py"
15 |
16 | [[services]]
17 | processes = ["web"]
18 | internal_port = 8080
19 | protocol = "tcp"
20 | [[services.ports]]
21 | handlers = ["http"]
22 | port = 80
23 | [[services.ports]]
24 | handlers = ["tls","http"]
25 | port = 443
26 |
27 | [[services.http_checks]]
28 | interval = "15s"
29 | timeout = "2s"
30 | path = "/status"
31 | protocol = "http"
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 |
3 | services:
4 | bot:
5 | build: .
6 | container_name: ucsi-telegram-bot
7 | environment:
8 | # Provide your Telegram bot token here or via .env file loaded by Compose
9 | TELEGRAM_TOKEN: ${TELEGRAM_TOKEN}
10 | # Optional: HEADLESS, URL etc.
11 | # HEADLESS: "1"
12 | # URL: "https://example.com/attendance"
13 | volumes:
14 | - ./config:/app/config
15 | - ./output:/app/output
16 | restart: always
17 |
18 | web:
19 | build: .
20 | container_name: ucsi-web
21 | command: python -u src/web_app.py
22 | environment:
23 | HOST: 0.0.0.0
24 | PORT: 5000
25 | FLASK_SECRET_KEY: ${FLASK_SECRET_KEY}
26 | volumes:
27 | - ./config:/app/config
28 | - ./output:/app/output
29 | ports:
30 | - "5000:5000"
31 | restart: always
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Minimal image to run the Telegram bot 24/7
2 | FROM python:3.13-slim
3 |
4 | ENV PYTHONUNBUFFERED=1 \
5 | PIP_NO_CACHE_DIR=1
6 |
7 | WORKDIR /app
8 |
9 | # Install deps first for better caching
10 | COPY requirements.txt /app/requirements.txt
11 | RUN python -m pip install --upgrade pip \
12 | && pip install -r /app/requirements.txt \
13 | && python -m playwright install --with-deps chromium
14 |
15 | # Copy application code
16 | COPY src /app/src
17 | COPY config /app/config
18 |
19 | # Create output directory (can be mounted as a volume)
20 | RUN mkdir -p /app/output
21 |
22 | # Runtime env variables used by the bot
23 | # Set TELEGRAM_TOKEN at runtime via environment
24 |
25 | # Default env for web service on Fly.io (overridable)
26 | ENV PORT=8080 \
27 | HOST=0.0.0.0 \
28 | HEADLESS=1
29 |
30 | CMD ["python", "-u", "src/telegram_bot.py"]
--------------------------------------------------------------------------------
/src/templates/add.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Add New User
7 |
8 |
9 |
10 |
15 |
16 |
Add New User
17 | {% with messages = get_flashed_messages(with_categories=true) %}
18 | {% if messages %}
19 | {% for category, message in messages %}
20 |
{{ message }}
21 | {% endfor %}
22 | {% endif %}
23 | {% endwith %}
24 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/static/style.css:
--------------------------------------------------------------------------------
1 | /* Basic light theme and mobile-friendly layout */
2 | * { box-sizing: border-box; }
3 | html, body { height: 100%; }
4 | :root {
5 | --bg: #f7f8fa;
6 | --text: #1f2937;
7 | --muted: #6b7280;
8 | --primary: #2563eb;
9 | --primary-contrast: #ffffff;
10 | --card-bg: #ffffff;
11 | --border: #e5e7eb;
12 | }
13 | body { margin: 0; background: var(--bg); color: var(--text); font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; }
14 |
15 | .container { max-width: 960px; margin: 0 auto; padding: 16px; }
16 | .nav { background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
17 | .nav .container { display: flex; gap: 12px; align-items: center; justify-content: flex-start; }
18 | .nav a { color: var(--text); text-decoration: none; padding: 12px 8px; border-radius: 6px; }
19 | .nav a:hover { background: #f3f4f6; }
20 |
21 | h1, h2, h3 { margin: 16px 0; }
22 | .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 16px; box-shadow: 0 1px 2px rgba(0,0,0,0.04); }
23 | .card + .card { margin-top: 16px; }
24 | .card-header { font-weight: 600; margin-bottom: 12px; }
25 |
26 | .grid { display: grid; gap: 12px; }
27 | .grid.cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
28 | .grid.cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
29 | @media (max-width: 640px) { .grid.cols-2, .grid.cols-3 { grid-template-columns: 1fr; } }
30 |
31 | label { display: block; font-size: 0.9rem; color: var(--muted); margin-bottom: 6px; }
32 | input[type=text], input[type=password], input[type=number], select { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); }
33 | input[type=radio], input[type=checkbox] { transform: scale(1.1); }
34 |
35 | .row { display: grid; gap: 12px; }
36 | .actions { margin-top: 12px; display: flex; gap: 10px; }
37 | .btn { display: inline-block; padding: 10px 16px; border-radius: 10px; border: 1px solid transparent; cursor: pointer; font-weight: 600; }
38 | .btn-primary { background: var(--primary); color: var(--primary-contrast); }
39 | .btn-secondary { background: #f3f4f6; color: var(--text); border-color: var(--border); }
40 | .btn:hover { filter: brightness(0.98); }
41 |
42 | .status { margin-top: 12px; padding: 12px; background: #f9fafb; border: 1px solid var(--border); border-radius: 10px; }
43 |
44 | table { width: 100%; border-collapse: collapse; }
45 | th, td { border: 1px solid var(--border); padding: 10px; text-align: start; }
46 | thead { background: #f3f4f6; }
47 |
48 | .flash { margin: 12px 0; padding: 12px; border-radius: 10px; }
49 | .flash.info { background: #eff6ff; border: 1px solid #bfdbfe; }
50 | .flash.success { background: #ecfdf5; border: 1px solid #a7f3d0; }
51 | .flash.error { background: #fef2f2; border: 1px solid #fecaca; }
52 |
53 | .radio-group { display: flex; flex-wrap: wrap; gap: 16px; }
54 | .radio-group label { display: inline-flex; align-items: center; gap: 6px; margin: 0; color: var(--text); }
--------------------------------------------------------------------------------
/DEPLOYMENT.md:
--------------------------------------------------------------------------------
1 | Deployment Guide
2 | ================
3 |
4 | Prerequisites
5 | -------------
6 | - A server or VPS with Docker and Docker Compose installed; or a hosting that supports WSGI/Gunicorn.
7 | - A domain with HTTPS (recommended). You can use Cloudflare Tunnel/ngrok for a quick HTTPS URL.
8 |
9 | Environment Variables
10 | ---------------------
11 | - `TELEGRAM_TOKEN`: Your Telegram bot token.
12 | - `WEB_APP_URL`: Public HTTPS URL to the admin panel, e.g. `https://your-domain.com/manage` or HTTPS tunnel URL.
13 | - `FLASK_SECRET_KEY`: A strong random string used by Flask sessions.
14 |
15 | Docker Compose (Bot + Web)
16 | --------------------------
17 | 1. Copy `.env.example` to `.env` and fill values.
18 | 2. Ensure `docker-compose.yml` contains both `bot` and `web` services (already added):
19 | - `web` shares `./config` and `./output` volumes with the bot.
20 | - `web` exposes port `5000` for the Flask app.
21 | 3. Start services:
22 | ```sh
23 | docker compose up -d
24 | ```
25 | 4. Point your reverse proxy (Caddy/Nginx) to `http://127.0.0.1:5000` and enable HTTPS on your domain.
26 | 5. Update `.env` with `WEB_APP_URL=https://YOUR-DOMAIN/manage` so the Telegram bot opens the admin panel.
27 |
28 | WSGI Hosting (Render/Heroku/PythonAnywhere)
29 | -------------------------------------------
30 | 1. Use `src/wsgi.py` as the entrypoint (`from src.web_app import app as application`).
31 | 2. Install dependencies from `requirements.txt`.
32 | 3. Set environment variables in the provider settings (`TELEGRAM_TOKEN`, `WEB_APP_URL`, `FLASK_SECRET_KEY`).
33 | 4. Configure HTTPS (provider managed or via custom domain).
34 |
35 | Render (Free) — Web Admin Panel
36 | --------------------------------
37 | This repo includes a `render.yaml` for one‑click deploy of the Flask admin panel.
38 |
39 | Steps:
40 | - Push this project to a Git repository (GitHub/GitLab).
41 | - Create a free account on Render and click “New +” → “Blueprint”.
42 | - Point to your repo; Render will detect `render.yaml` and create a Web Service.
43 | - Set environment variables:
44 | - `FLASK_SECRET_KEY`: any strong random string.
45 | - `WEB_APP_URL`: your Render URL + `/manage` (e.g. `https://ucsi-web.onrender.com/manage`).
46 | - `TELEGRAM_TOKEN`: only needed if you plan to send notifications from the web to Telegram.
47 | - Deploy. The service listens on `/$` for redirect to `/manage` and health check at `/status`.
48 |
49 | Notes:
50 | - The Telegram bot (`src/telegram_bot.py`) is not part of this web service. If you also want the bot online 24/7, deploy a second service (Docker or Python) that runs `python -u src/telegram_bot.py` and shares the same `config/` and `output/` storage.
51 | - Playwright browsers are heavy; keep automation on your PC/server if free tier resources are limited. The admin panel will work fine without running automation on the same host.
52 |
53 | Minimal dependencies on Render
54 | ------------------------------
55 | - To avoid build failures on Render Free (e.g., `greenlet` compiling via `playwright`), the web service installs only `Flask`, `gunicorn`, and `python-dotenv` via `render.yaml`.
56 | - This is sufficient for the admin panel. Bot/automation dependencies remain in `requirements.txt` for local or worker deployment.
57 | - If you decide to run the bot as a separate Background Worker on Render, use a Docker-based worker or a host with system build tools enabled.
58 |
59 | Quick HTTPS Tunnel (No Server)
60 | ------------------------------
61 | 1. Run Flask locally, bind to all interfaces:
62 | ```sh
63 | python src/web_app.py
64 | ```
65 | 2. Create an HTTPS tunnel (Cloudflare Tunnel/ngrok) to port `5000`.
66 | 3. Set `WEB_APP_URL` to the tunnel URL + `/manage`.
67 |
68 | Data Sharing
69 | ------------
70 | - Both bot and web app read/write `config/config.json`. Keep them on the same machine or provide a shared storage to ensure synchronization.
71 |
72 | Validation Checklist
73 | --------------------
74 | - Open `https://YOUR-DOMAIN/manage`, log in as admin, and perform user edits.
75 | - In Telegram, use the admin flow; the “Open Admin Panel” button should open the WebApp when `WEB_APP_URL` is HTTPS.
--------------------------------------------------------------------------------
/src/templates/auto.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | UCSI Attendance Bot - Auto Attendance
7 |
8 |
9 |
10 |
15 |
16 |
Auto Attendance
17 |
18 | {% with messages = get_flashed_messages(with_categories=true) %}
19 | {% if messages %}
20 | {% for category, message in messages %}
21 |
{{ message }}
22 | {% endfor %}
23 | {% endif %}
24 | {% endwith %}
25 |
26 |
92 |
93 |
94 |
Status: {{ data.status.state }}
95 | {% if data.status.error %}
Error: {{ data.status.error }}
{% endif %}
96 |
97 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # بوت إدخال بيانات باستخدام Playwright (Python)
2 |
3 | مشروع صغير لأتمتة تعبئة نموذج في موقع ثابت البنية، يتغير رابط الصفحة في كل مرة.
4 |
5 | ## المتطلبات
6 | - Python 3.10+
7 | - تثبيت الاعتماديات:
8 | - `pip install -r requirements.txt`
9 | - ثم مرة واحدة لتنزيل المتصفحات: `python -m playwright install`
10 |
11 | ## الإعداد
12 | - عدّل ملف `config/config.json`:
13 | - `url`: ضع رابط الصفحة المطلوب كل مرة.
14 | - `fields`: قائمة حقول الإدخال مع محددات CSS وقيمها.
15 | - `submit.selector`: محدد زر الإرسال (إن وجد).
16 | - `wait_for.selector`: عنصر يظهر بعد النجاح (اختياري).
17 | - `screenshot_path`: مسار لقطة الشاشة الناتجة.
18 |
19 | مثال:
20 | ```json
21 | {
22 | "url": "https://example.com/form",
23 | "fields": [
24 | { "selector": "#name", "value": "أحمد" },
25 | { "selector": "#email", "value": "ahmed@example.com" }
26 | ],
27 | "submit": { "selector": "button[type='submit']" },
28 | "wait_for": { "selector": ".success, .alert-success", "timeout_ms": 12000 },
29 | "screenshot_path": "output/last.png"
30 | }
31 | ```
32 |
33 | ## التشغيل (مستخدمون متعددون)
34 | - عدّل `config/config.json`:
35 | - `url`: ضع رابط صفحة الحضور الحالي (يتغير كل مرة).
36 | - `users`: قائمة من `{ studentId, password }` لجميع من تريد تسجيل حضورهم.
37 | - `geolocation`: مصدر الموقع. إذا أردته تلقائيًا من موقع تشغيل السكربت:
38 | - `{ "source": "ip", "accuracy": 50 }`
39 | - يعتمد على عنوان IP العام لتحديد الإحداثيات تلقائيًا.
40 | - إن رغبت بإحداثيات ثابتة، استخدم `{ "source": "fixed", "latitude": , "longitude": , "accuracy": 50 }`.
41 | - `login`: تخصيص أسماء الحقول/الأزرار، أو محددات CSS إن لزم.
42 | - `checkin`: أسماء زر الحضور ومحدد نجاح (اختياري).
43 |
44 | - شغّل:
45 | - `python src/bot.py --config config/config.json --url https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p...`
46 | - إذا أردت التشغيل خفيًا: أنشئ `.env` وضع `HEADLESS=1`.
47 |
48 | - ينتج صورًا لكل مستخدم في `output/{studentId}.png`.
49 |
50 | ## ملاحظات
51 | - إن اختلفت أسماء الأزرار (مثل "Sign In"/"Login"/"Check-In")، السكربت يجرب تلقائيًا وفقًا للقائمة في `config.json`.
52 | - إن تطلب الموقع إذن الموقع، يتم منحه تلقائيًا وتحديد الإحداثيات من `geolocation`.
53 | - إن تغيّرت محددات الحقول، استخدم `login.overrides` لتعريف محددات CSS مباشرة.
54 |
55 | ## التحكم عبر تيليجرام (اختياري)
56 | - يمكنك تشغيل البوت عبر تيليجرام بدل فتح الواجهة:
57 | 1. أنشئ بوت عبر BotFather واحصل على `TELEGRAM_TOKEN`.
58 | 2. ضع المتغيرات في `.env`:
59 | - `TELEGRAM_TOKEN=<توكن البوت>`
60 | - `TELEGRAM_ALLOWED_IDS=<معرفات المستخدم المسموح لهم، مفصولة بفواصل>` مثل `123456789,987654321`.
61 | 3. ثبّت الاعتماديات (إن لم تكن مثبتة): `pip install python-telegram-bot==21.6`.
62 | 4. شغّل: `python src/telegram_bot.py`.
63 | 5. الأوامر المتاحة داخل تيليجرام:
64 | - `/start` لعرض المساعدة.
65 | - `/run` لتشغيل الأتمتة باستخدام إعدادات `config/config.json` الحالية.
66 | - `/status` لعرض حالة التشغيل الحالية.
67 | - `/seturl ` لتحديث رابط الحضور.
68 | - `/setheadless <0|1>` لتبديل وضع التشغيل الخفي.
69 |
70 | 6. قائمة أزرار داخل `/start`:
71 | - يظهر زر "تشغيل" و"الحالة" للتنفيذ السريع.
72 | - يظهر زر "إنشاء تحضير جديد" لفتح معالج التحضير (رابط + مادة + الموقع).
73 | - يظهر زر "إضافة مستخدم جديد" و"إدارة المستخدمين".
74 | - يظهر زر "عرض آخر 10 تحضيرات".
75 |
76 | ### الاستضافة 24/7 (دائمًا شغال)
77 | لجعل بوت تيليجرام يعمل 24/7 حتى لو أغلقت جهازك المحلي، شغّله على خادم دائم التشغيل أو منصة حاويات:
78 | - خيار A — Docker على خادم (موصى به):
79 | 1. انسخ المشروع إلى الخادم.
80 | 2. عرّف متغير البيئة `TELEGRAM_TOKEN` هناك (لا تحفظه داخل المستودع).
81 | 3. شغّل عبر Docker Compose:
82 | - `docker compose up -d`
83 | - يستخدم `restart: always` ويثبت مجلدَي `config/` و `output/` للحفظ.
84 | 4. تأكد من تشغيل نسخة واحدة فقط حتى لا يظهر خطأ "terminated by other getUpdates request" من تيليجرام.
85 | - خيار B — منصات حاويات مُدارة (Render, Railway, Fly.io):
86 | - استخدم `Dockerfile` المرفق.
87 | - اضبط `TELEGRAM_TOKEN` كمتغير سري في المنصة.
88 | - انشر الخدمة؛ وضع polling يعمل دون الحاجة لمنفذ عام.
89 | - خيار C — عملية طويلة على خادم Linux:
90 | - ثبّت بايثون والاعتماديات.
91 | - استخدم مدير عمليات (`systemd` أو `pm2`) مع إعادة تشغيل تلقائي.
92 |
93 | ملاحظات:
94 | - وضع polling يحتاج إنترنت خارجي فقط، لا يتطلب HTTPS داخِل.
95 | - لا تشغّل أكثر من نسخة للبوت في نفس الوقت.
96 | - احفظ `config/config.json` و`output/` للاستفادة من السجل واللقطات.
97 |
98 | - ملاحظة: التنفيذ الفعلي يتم على الجهاز/الخادم الذي يشغّل سكربت تيليجرام، وليس على هاتفك.
99 | - لتشغيل واجهة الويب:
100 | - شغّل الخدمة: `HOST=0.0.0.0 PORT=5000 python src/web_app.py`.
101 | - افتح من الهاتف على نفس الشبكة: `http://:5000/auto`.
--------------------------------------------------------------------------------
/config/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535",
3 | "users": [
4 | {
5 | "studentId": "1002476196",
6 | "password": "1111"
7 | },
8 | {
9 | "studentId": "1002476111",
10 | "password": "2222"
11 | },
12 | {
13 | "studentId": "1002411111",
14 | "password": "21212112",
15 | "subjects": [
16 | "Mathematical Methods for Engineering II"
17 | ]
18 | },
19 | {
20 | "username": "1002476197",
21 | "phone": "+966500900329",
22 | "studentId": "10024198",
23 | "password": "Ahmad20062",
24 | "subjects": [
25 | "Engineering Statics",
26 | "Mathematical Methods for Engineering II"
27 | ]
28 | },
29 | {
30 | "studentId": "11111111",
31 | "password": "Ahmed2ajgs",
32 | "username": "AHMED",
33 | "phone": "05009003290",
34 | "telegram_chat_id": 1270242206,
35 | "subjects": [
36 | "Technical Communication",
37 | "Effective Writing"
38 | ]
39 | }
40 | ],
41 | "geolocation": {
42 | "source": "fixed",
43 | "latitude": 3.0794,
44 | "longitude": 101.7334,
45 | "accuracy": 25
46 | },
47 | "login": {
48 | "student_id_label": "Student ID",
49 | "password_label": "Password",
50 | "submit_button_names": [
51 | "CHECK-IN",
52 | "Sign In",
53 | "Login",
54 | "تسجيل الدخول"
55 | ],
56 | "overrides": {
57 | "student_id_selector": "#P8888_USERNAME",
58 | "password_selector": "",
59 | "submit_selector": ".t-Login-buttons button:has(span.t-Button-label:has-text(\"CHECK-IN\"))"
60 | }
61 | },
62 | "checkin": {
63 | "button_names": [
64 | "CHECK-IN",
65 | "Check-In",
66 | "Sign In",
67 | "تسجيل حضور"
68 | ],
69 | "container_selector": ".t-Login-buttons",
70 | "selector": ".t-Login-buttons button:has(span.t-Button-label:has-text(\"CHECK-IN\"))",
71 | "success_selector": ".success, .alert-success, text=Successfully",
72 | "timeout_ms": 20000
73 | },
74 | "screenshot_template": "output/{studentId}.png",
75 | "screenshots": {
76 | "full_page": false,
77 | "scroll_top_before": true,
78 | "capture_prepared": false,
79 | "capture_after_checkin": true,
80 | "suffix_after_checkin": "checked-in",
81 | "delay_ms_before_prepared": 10000,
82 | "prepared_wait_timeout_ms": 15000,
83 | "prepared_wait_selector": [
84 | ".fa-spinner",
85 | ".t-Icon--spinner",
86 | ".u-Processing",
87 | ".apex_wait_mask",
88 | "div.u-Processing-spinner"
89 | ]
90 | },
91 | "ui": {
92 | "scroll_back_after_click": true,
93 | "scroll_back_delay_ms": 200
94 | },
95 | "cloudflare": {
96 | "handle_challenge": "auto",
97 | "timeout_ms": 20000,
98 | "after_check_delay_ms": 1500
99 | },
100 | "parallel_browsers": 0,
101 | "open_output_dir_after_run": true,
102 | "browser": {
103 | "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36",
104 | "launch_args": [
105 | "--disable-blink-features=AutomationControlled",
106 | "--no-sandbox",
107 | "--disable-dev-shm-usage",
108 | "--disable-features=IsolateOrigins,site-per-process",
109 | "--no-first-run",
110 | "--no-default-browser-check"
111 | ]
112 | },
113 | "history": [
114 | {
115 | "subject": "Digital Electronics",
116 | "timestamp": "2025-11-05T19:23:22",
117 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
118 | },
119 | {
120 | "subject": "Engineering Statics",
121 | "timestamp": "2025-11-05T19:36:14",
122 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
123 | },
124 | {
125 | "subject": "Engineering Statics",
126 | "timestamp": "2025-11-06T01:59:39",
127 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
128 | },
129 | {
130 | "subject": "Mathematical Methods for Engineering II",
131 | "timestamp": "2025-11-06T02:12:59",
132 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
133 | },
134 | {
135 | "subject": "Mathematical Methods for Engineering II",
136 | "timestamp": "2025-11-06T02:18:56",
137 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
138 | },
139 | {
140 | "subject": "Effective Writing",
141 | "timestamp": "2025-11-06T02:58:55",
142 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343139383736373930"
143 | },
144 | {
145 | "subject": "Effective Writing",
146 | "timestamp": "2025-11-06T03:40:22",
147 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343139383736373930"
148 | },
149 | {
150 | "subject": "Effective Writing",
151 | "timestamp": "2025-11-06T03:42:16",
152 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343139383736373930"
153 | },
154 | {
155 | "subject": "Effective Writing",
156 | "timestamp": "2025-11-06T03:50:08",
157 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
158 | },
159 | {
160 | "subject": "Effective Writing",
161 | "timestamp": "2025-11-06T04:00:02",
162 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
163 | },
164 | {
165 | "subject": "Effective Writing",
166 | "timestamp": "2025-11-06T04:06:57",
167 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
168 | },
169 | {
170 | "subject": "Effective Writing",
171 | "timestamp": "2025-11-06T04:13:49",
172 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
173 | },
174 | {
175 | "subject": "Effective Writing",
176 | "timestamp": "2025-11-06T04:20:09",
177 | "url": "https://iisv2.ucsiuniversity.edu.my/apex/iisv2/r/attendance/attendance-sign-in?p8888qc=343230313833373535"
178 | }
179 | ],
180 | "subjects": [
181 | "Engineering Statics",
182 | "Mathematical Methods for Engineering II",
183 | "Digital Electronics",
184 | "Effective Writing",
185 | "Technical Communication"
186 | ],
187 | "selected_subject": "Effective Writing",
188 | "pending_users": [],
189 | "notify": {
190 | "initiator_chat_id": 8454509603,
191 | "milestones": [],
192 | "started_message_id": 366,
193 | "started_message_chat_id": 8454509603
194 | }
195 | }
--------------------------------------------------------------------------------
/src/templates/manage.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Manage Accounts
7 |
8 |
9 |
10 |
15 |
16 |
Manage Accounts
17 |
18 | {% with messages = get_flashed_messages(with_categories=true) %}
19 | {% if messages %}
20 | {% for category, message in messages %}
21 |
{{ message }}
22 | {% endfor %}
23 | {% endif %}
24 | {% endwith %}
25 |
26 | {% if not authed %}
27 |
45 |
Manage student data (locked)
46 | {% else %}
47 |
52 |
53 | {% if view == 'req' %}
54 |
55 |
56 | {% if not pendings %}
57 |
No pending requests.
58 | {% else %}
59 |
60 |
61 | | Student ID | Password | Name | Phone | Actions |
62 |
63 |
64 | {% for p in pendings %}
65 |
66 | | {{ p.get('studentId','') }} |
67 | {{ p.get('password','') }} |
68 | {{ p.get('username','') }} |
69 | {{ p.get('phone','') }} |
70 |
71 |
75 |
79 | |
80 |
81 | {% endfor %}
82 |
83 |
84 | {% endif %}
85 |
86 | {% else %}
87 |
88 |
89 |
90 |
91 | | Student ID | Password | Name | Phone | Subjects | Actions |
92 |
93 |
94 | {% for u in users %}
95 | {% set subs = u.get('subjects', []) or [] %}
96 |
97 | | {{ u.get('studentId','') }} |
98 | {{ u.get('password','') }} |
99 | {{ u.get('username','') }} |
100 | {{ u.get('phone','') }} |
101 | {{ subs | join(', ') if subs else '—' }} |
102 |
103 |
104 |
111 |
112 |
116 |
117 |
130 | {% for s in subs %}
131 |
136 | {% endfor %}
137 | |
138 |
139 | {% endfor %}
140 |
141 |
142 |
143 | {% endif %}
144 | {% endif %}
145 |
146 |
147 | {% if authed and view == 'users' %}
148 |
149 |
150 |
151 |
155 | {% if subjects_library %}
156 |
Available: {{ subjects_library | join(', ') }}
157 | {% else %}
158 |
No subjects defined yet.
159 | {% endif %}
160 |
161 |
162 | {% endif %}
163 |
164 |
165 |
--------------------------------------------------------------------------------
/src/web_app.py:
--------------------------------------------------------------------------------
1 | import os
2 | import threading
3 | from pathlib import Path
4 | from typing import Any, Dict, List
5 |
6 | from flask import Flask, render_template, redirect, url_for, request, flash, session
7 | from urllib.parse import urlencode
8 | from urllib.request import Request, urlopen
9 |
10 |
11 | app = Flask(__name__, template_folder=str(Path(__file__).resolve().parent / "templates"), static_folder=str(Path(__file__).resolve().parent / "static"))
12 | app.secret_key = os.getenv("FLASK_SECRET_KEY", "ucsi-web-secret")
13 |
14 |
15 | # -----------------------------
16 | # Output images auto-cleaner
17 | # -----------------------------
18 | def _clean_output_once(max_age_hours: int = 6) -> int:
19 | """Delete images in ./output older than max_age_hours. Returns count deleted."""
20 | try:
21 | import time
22 | root = Path("output")
23 | if not root.exists():
24 | return 0
25 | cutoff = time.time() - max_age_hours * 3600
26 | patterns = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif")
27 | deleted = 0
28 | for pat in patterns:
29 | for p in root.glob(pat):
30 | try:
31 | if p.is_file() and p.stat().st_mtime < cutoff:
32 | p.unlink(missing_ok=True)
33 | deleted += 1
34 | except Exception:
35 | # Ignore files we cannot delete
36 | pass
37 | # Optionally remove empty subdirectories
38 | for sub in root.glob("*/"):
39 | try:
40 | if sub.is_dir() and not any(sub.iterdir()):
41 | sub.rmdir()
42 | except Exception:
43 | pass
44 | return deleted
45 | except Exception:
46 | return 0
47 |
48 |
49 | def start_output_cleanup_daemon():
50 | """Start a background cleaner that runs every N hours.
51 |
52 | Env vars:
53 | - OUTPUT_MAX_AGE_HOURS (default: 6) — delete files older than this age
54 | - OUTPUT_CLEAN_INTERVAL_HOURS (default: same as max age) — run frequency
55 | - OUTPUT_AUTO_CLEAN (default: 1) — set to 0 to disable
56 | """
57 | if os.getenv("OUTPUT_AUTO_CLEAN", "1") not in ("1", "true", "True"):
58 | return
59 | try:
60 | max_age = int(os.getenv("OUTPUT_MAX_AGE_HOURS", "6") or 6)
61 | except Exception:
62 | max_age = 6
63 | try:
64 | interval = int(os.getenv("OUTPUT_CLEAN_INTERVAL_HOURS", str(max_age)) or max_age)
65 | except Exception:
66 | interval = max_age
67 |
68 | def _loop():
69 | import time
70 | while True:
71 | try:
72 | deleted = _clean_output_once(max_age)
73 | if deleted:
74 | print(f"[output-clean] Deleted {deleted} old image(s) from ./output")
75 | except Exception:
76 | pass
77 | time.sleep(max(1, interval) * 3600)
78 |
79 | try:
80 | t = threading.Thread(target=_loop, daemon=True)
81 | t.start()
82 | except Exception:
83 | pass
84 |
85 |
86 | # Launch the output cleaner in the background when the web app starts
87 | start_output_cleanup_daemon()
88 |
89 |
90 | PRESETS = {
91 | "building_c": {"latitude": 0.0, "longitude": 0.0, "accuracy": 10},
92 | "building_g": {"latitude": 0.0, "longitude": 0.0, "accuracy": 10},
93 | }
94 |
95 | # Initial subjects that will seed the shared library if empty
96 | DEFAULT_SUBJECTS: List[str] = [
97 | "Engineering Statics",
98 | "Mathematical Methods for Engineering II",
99 | "Digital Electronics",
100 | "Effective Writing",
101 | "Technical Communication",
102 | ]
103 |
104 | ADMIN_USER = "1002476196"
105 | ADMIN_PWD = "Ahmad@2006"
106 |
107 |
108 | status_lock = threading.Lock()
109 | run_status: Dict[str, Any] = {"state": "idle", "error": None}
110 |
111 |
112 | def set_status(state: str, error: str | None = None):
113 | with status_lock:
114 | run_status["state"] = state
115 | run_status["error"] = error
116 |
117 |
118 | def read_cfg() -> Dict[str, Any]:
119 | try:
120 | import json
121 | cfg_path = Path("config/config.json")
122 | if not cfg_path.exists():
123 | return {}
124 | with cfg_path.open("r", encoding="utf-8") as f:
125 | return json.load(f)
126 | except Exception:
127 | return {}
128 |
129 |
130 | def write_cfg(data: Dict[str, Any]) -> None:
131 | cfg_path = Path("config/config.json")
132 | cfg_path.parent.mkdir(parents=True, exist_ok=True)
133 | import json
134 | with cfg_path.open("w", encoding="utf-8") as f:
135 | import json as _json
136 | _json.dump(data, f, ensure_ascii=False, indent=2)
137 |
138 |
139 | @app.route("/")
140 | def index():
141 | # Default to the admin manage interface instead of auto attendance
142 | return redirect(url_for("manage"))
143 |
144 |
145 | @app.route("/auto", methods=["GET"])
146 | def auto():
147 | cfg = read_cfg()
148 | # Defaults mirroring desktop GUI
149 | page_data = {
150 | "url": str(cfg.get("url", "")),
151 | "loc_mode": "browser",
152 | "lat": str(PRESETS["building_c"]["latitude"]),
153 | "lon": str(PRESETS["building_c"]["longitude"]),
154 | "acc": str(PRESETS["building_c"].get("accuracy", 10)),
155 | "headless": (os.getenv("HEADLESS", "0") in ("1", "true", "True")),
156 | "parallel": int(cfg.get("parallel_browsers", 0) or 0),
157 | "cf_mode": str((cfg.get("cloudflare") or {}).get("handle_challenge", "auto")),
158 | "prep_shot_delay": int(((cfg.get("screenshots") or {}).get("delay_ms_before_prepared", 10000)) / 1000),
159 | "status": run_status,
160 | }
161 | return render_template("auto.html", data=page_data)
162 |
163 |
164 | def build_config_from_form(form) -> Dict[str, Any]:
165 | base = read_cfg() or {}
166 | base["url"] = str(form.get("url", "")).strip()
167 | try:
168 | base["parallel_browsers"] = int(form.get("parallel", "0") or 0)
169 | except Exception:
170 | base["parallel_browsers"] = 0
171 | base["open_output_dir_after_run"] = True
172 | # Cloudflare
173 | base["cloudflare"] = {
174 | "handle_challenge": str(form.get("cf_mode", "auto")).strip() or "auto",
175 | "timeout_ms": int((base.get("cloudflare") or {}).get("timeout_ms", 20000)),
176 | "after_check_delay_ms": int((base.get("cloudflare") or {}).get("after_check_delay_ms", 1500)),
177 | }
178 | # Geo
179 | mode = str(form.get("loc_mode", "browser"))
180 | if mode == "browser":
181 | base["geolocation"] = {
182 | "source": "browser",
183 | "require_browser": True,
184 | "wait_ms": int((base.get("geolocation") or {}).get("wait_ms", 4000)),
185 | }
186 | else:
187 | try:
188 | lat = float(form.get("lat", "0") or 0)
189 | lon = float(form.get("lon", "0") or 0)
190 | except Exception:
191 | raise ValueError("Please enter numeric values for latitude/longitude.")
192 | try:
193 | acc = int(float(form.get("acc", "10") or 10))
194 | except Exception:
195 | acc = 10
196 | base["geolocation"] = {
197 | "source": "fixed",
198 | "latitude": lat,
199 | "longitude": lon,
200 | "accuracy": acc,
201 | }
202 | # Screenshots config
203 | shots = (base.get("screenshots") or {})
204 | try:
205 | shots["delay_ms_before_prepared"] = int(form.get("prep_shot_delay", "10") or 10) * 1000
206 | except Exception:
207 | shots["delay_ms_before_prepared"] = int(shots.get("delay_ms_before_prepared", 3000))
208 | base["screenshots"] = shots
209 | return base
210 |
211 |
212 | @app.route("/auto/run", methods=["POST"])
213 | def auto_run():
214 | # HEADLESS from checkbox
215 | os.environ["HEADLESS"] = "1" if request.form.get("headless") == "on" else "0"
216 | try:
217 | cfg = build_config_from_form(request.form)
218 | except Exception as e:
219 | flash(str(e), "error")
220 | return redirect(url_for("auto"))
221 |
222 | # Persist updated config
223 | try:
224 | write_cfg(cfg)
225 | except Exception:
226 | pass
227 |
228 | def worker():
229 | try:
230 | set_status("running")
231 | # Lazy import bot only when automation is triggered
232 | try:
233 | from bot import run_bot as _run_bot
234 | except Exception:
235 | import sys
236 | from pathlib import Path as P
237 | sys.path.append(str(P(__file__).resolve().parent))
238 | from bot import run_bot as _run_bot
239 | _run_bot(cfg)
240 | set_status("done")
241 | except Exception as e: # noqa
242 | set_status("error", str(e))
243 |
244 | t = threading.Thread(target=worker, daemon=True)
245 | t.start()
246 | flash("Started automation in background.", "info")
247 | return redirect(url_for("auto"))
248 |
249 |
250 | @app.route("/add", methods=["GET", "POST"])
251 | def add_user():
252 | if request.method == "GET":
253 | return render_template("add.html")
254 | # POST
255 | username = (request.form.get("username") or "").strip()
256 | phone = (request.form.get("phone") or "").strip()
257 | sid = (request.form.get("studentId") or "").strip()
258 | pwd = (request.form.get("password") or "").strip()
259 | if not sid or not pwd:
260 | flash("Student ID and Password are required.", "error")
261 | return redirect(url_for("add_user"))
262 | cfg = read_cfg()
263 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
264 | pendings: List[Dict[str, Any]] = list(cfg.get("pending_users", []) or [])
265 | # Prevent duplicates across both lists
266 | if any(u.get("studentId") == sid for u in users) or any(p.get("studentId") == sid for p in pendings):
267 | flash("Student ID already exists (in users or pending).", "error")
268 | return redirect(url_for("add_user"))
269 | pendings.append({"studentId": sid, "password": pwd, "username": username, "phone": phone, "subjects": []})
270 | cfg["pending_users"] = pendings
271 | write_cfg(cfg)
272 | flash("Request submitted. Awaiting admin approval.", "success")
273 | return redirect(url_for("add_user"))
274 |
275 |
276 | @app.route("/manage", methods=["GET"])
277 | def manage():
278 | authed = bool(session.get("admin", False))
279 | cfg = read_cfg()
280 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
281 | pendings: List[Dict[str, Any]] = list(cfg.get("pending_users", []) or [])
282 | subjects_library: List[str] = list(cfg.get("subjects", []) or [])
283 | # Seed default subjects into the library once if empty
284 | if not subjects_library:
285 | subjects_library = list(DEFAULT_SUBJECTS)
286 | cfg["subjects"] = subjects_library
287 | try:
288 | write_cfg(cfg)
289 | except Exception:
290 | pass
291 | view = (request.args.get("view") or "req").strip()
292 | if view not in ("req", "users"):
293 | view = "req"
294 | return render_template("manage.html", authed=authed, users=users, pendings=pendings, subjects_library=subjects_library, view=view)
295 |
296 |
297 | @app.route("/manage/login", methods=["POST"])
298 | def manage_login():
299 | user = (request.form.get("admin_user") or "").strip()
300 | pwd = (request.form.get("admin_pwd") or "").strip()
301 | if user == ADMIN_USER and pwd == ADMIN_PWD:
302 | session["admin"] = True
303 | flash("Admin access granted.", "success")
304 | else:
305 | session["admin"] = False
306 | flash("Invalid admin credentials.", "error")
307 | return redirect(url_for("manage"))
308 |
309 |
310 | def require_admin() -> bool:
311 | return bool(session.get("admin", False))
312 |
313 |
314 | @app.route("/manage/add", methods=["POST"])
315 | def manage_add():
316 | if not require_admin():
317 | flash("Admin login required.", "error")
318 | return redirect(url_for("manage"))
319 | sid = (request.form.get("studentId") or "").strip()
320 | pwd = (request.form.get("password") or "").strip()
321 | if not sid or not pwd:
322 | flash("Student ID and Password are required.", "error")
323 | return redirect(url_for("manage"))
324 | cfg = read_cfg()
325 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
326 | if any(u.get("studentId") == sid for u in users):
327 | flash("Student ID already exists.", "error")
328 | return redirect(url_for("manage"))
329 | users.append({"studentId": sid, "password": pwd})
330 | cfg["users"] = users
331 | write_cfg(cfg)
332 | flash("User added.", "success")
333 | return redirect(url_for("manage"))
334 |
335 |
336 | @app.route("/manage/update", methods=["POST"])
337 | def manage_update():
338 | if not require_admin():
339 | flash("Admin login required.", "error")
340 | return redirect(url_for("manage"))
341 | sid = (request.form.get("studentId") or "").strip()
342 | pwd = (request.form.get("password") or "").strip()
343 | username = (request.form.get("username") or "").strip()
344 | phone = (request.form.get("phone") or "").strip()
345 | if not sid:
346 | flash("Student ID is required.", "error")
347 | return redirect(url_for("manage", view="users"))
348 | cfg = read_cfg()
349 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
350 | updated = False
351 | for u in users:
352 | if u.get("studentId") == sid:
353 | if pwd:
354 | u["password"] = pwd
355 | if username:
356 | u["username"] = username
357 | if phone:
358 | u["phone"] = phone
359 | updated = True
360 | break
361 | if not updated:
362 | flash("Student ID not found.", "error")
363 | return redirect(url_for("manage", view="users"))
364 | cfg["users"] = users
365 | write_cfg(cfg)
366 | flash("Password updated.", "success")
367 | return redirect(url_for("manage", view="users"))
368 |
369 |
370 | @app.route("/manage/delete", methods=["POST"])
371 | def manage_delete():
372 | if not require_admin():
373 | flash("Admin login required.", "error")
374 | return redirect(url_for("manage"))
375 | sid = (request.form.get("studentId") or "").strip()
376 | cfg = read_cfg()
377 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
378 | users = [u for u in users if u.get("studentId") != sid]
379 | cfg["users"] = users
380 | write_cfg(cfg)
381 | flash("User deleted.", "success")
382 | return redirect(url_for("manage", view="users"))
383 |
384 | # --- Pending approvals ---
385 | @app.route("/manage/approve", methods=["POST"])
386 | def manage_approve():
387 | if not require_admin():
388 | flash("Admin login required.", "error")
389 | return redirect(url_for("manage"))
390 | sid = (request.form.get("studentId") or "").strip()
391 | cfg = read_cfg()
392 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
393 | pendings: List[Dict[str, Any]] = list(cfg.get("pending_users", []) or [])
394 | # Find pending
395 | idx = None
396 | for i, p in enumerate(pendings):
397 | if p.get("studentId") == sid:
398 | idx = i
399 | break
400 | if idx is None:
401 | flash("Pending request not found.", "error")
402 | return redirect(url_for("manage"))
403 | # Move to users if not duplicate
404 | if any(u.get("studentId") == sid for u in users):
405 | flash("Student already exists.", "error")
406 | return redirect(url_for("manage"))
407 | user = pendings.pop(idx)
408 | users.append({
409 | "studentId": user.get("studentId"),
410 | "password": user.get("password"),
411 | "username": user.get("username"),
412 | "phone": user.get("phone"),
413 | # احفظ معرف التلغرام إن توفر ليُستخدم في إرسال صورة التوثيق لاحقًا
414 | "telegram_chat_id": user.get("telegram_chat_id"),
415 | "subjects": list(user.get("subjects", []) or []),
416 | })
417 | cfg["users"] = users
418 | cfg["pending_users"] = pendings
419 | write_cfg(cfg)
420 | # Notify the user via Telegram if chat id is known
421 | try:
422 | chat_id = user.get("telegram_chat_id")
423 | token = os.getenv("TELEGRAM_TOKEN")
424 | if chat_id and token:
425 | text = (
426 | f"Your account request has been approved.\n"
427 | f"Student ID: {user.get('studentId','')}\n"
428 | f"You can now create or receive preparations."
429 | )
430 | api_url = f"https://api.telegram.org/bot{token}/sendMessage"
431 | data = urlencode({"chat_id": int(chat_id), "text": text}).encode("utf-8")
432 | req = Request(api_url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"})
433 | try:
434 | urlopen(req, timeout=5).read()
435 | except Exception:
436 | pass
437 | except Exception:
438 | pass
439 | flash("User approved.", "success")
440 | return redirect(url_for("manage"))
441 |
442 |
443 | @app.route("/manage/reject", methods=["POST"])
444 | def manage_reject():
445 | if not require_admin():
446 | flash("Admin login required.", "error")
447 | return redirect(url_for("manage"))
448 | sid = (request.form.get("studentId") or "").strip()
449 | cfg = read_cfg()
450 | pendings: List[Dict[str, Any]] = list(cfg.get("pending_users", []) or [])
451 | new_p = [p for p in pendings if p.get("studentId") != sid]
452 | cfg["pending_users"] = new_p
453 | write_cfg(cfg)
454 | flash("User rejected.", "success")
455 | return redirect(url_for("manage"))
456 |
457 | # --- Subject management ---
458 | @app.route("/manage/subject/add", methods=["POST"])
459 | def manage_subject_add():
460 | if not require_admin():
461 | flash("Admin login required.", "error")
462 | return redirect(url_for("manage"))
463 | sid = (request.form.get("studentId") or "").strip()
464 | subject = (request.form.get("subject") or "").strip()
465 | if not subject:
466 | flash("Subject is required.", "error")
467 | return redirect(url_for("manage"))
468 | # Ensure subject exists in global library
469 | cfg = read_cfg()
470 | lib: List[str] = list(cfg.get("subjects", []) or [])
471 | if subject not in lib:
472 | lib.append(subject)
473 | cfg["subjects"] = lib
474 | write_cfg(cfg)
475 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
476 | found = False
477 | for u in users:
478 | if u.get("studentId") == sid:
479 | subs = list(u.get("subjects", []) or [])
480 | if subject not in subs:
481 | subs.append(subject)
482 | u["subjects"] = subs
483 | found = True
484 | break
485 | if not found:
486 | flash("Student ID not found.", "error")
487 | return redirect(url_for("manage"))
488 | cfg["users"] = users
489 | write_cfg(cfg)
490 | flash("Subject added.", "success")
491 | return redirect(url_for("manage"))
492 |
493 |
494 | @app.route("/manage/subject/remove", methods=["POST"])
495 | def manage_subject_remove():
496 | if not require_admin():
497 | flash("Admin login required.", "error")
498 | return redirect(url_for("manage"))
499 | sid = (request.form.get("studentId") or "").strip()
500 | subject = (request.form.get("subject") or "").strip()
501 | cfg = read_cfg()
502 | users: List[Dict[str, Any]] = list(cfg.get("users", []) or [])
503 | found = False
504 | for u in users:
505 | if u.get("studentId") == sid:
506 | subs = [s for s in list(u.get("subjects", []) or []) if s != subject]
507 | u["subjects"] = subs
508 | found = True
509 | break
510 | if not found:
511 | flash("Student ID not found.", "error")
512 | return redirect(url_for("manage"))
513 | cfg["users"] = users
514 | write_cfg(cfg)
515 | flash("Subject removed.", "success")
516 | return redirect(url_for("manage"))
517 |
518 | # --- Global subjects library management ---
519 | @app.route("/manage/subjects/add", methods=["POST"])
520 | def manage_subjects_add_global():
521 | if not require_admin():
522 | flash("Admin login required.", "error")
523 | return redirect(url_for("manage", view="users"))
524 | subject = (request.form.get("subject") or "").strip()
525 | if not subject:
526 | flash("Subject name is required.", "error")
527 | return redirect(url_for("manage", view="users"))
528 | cfg = read_cfg()
529 | lib: List[str] = list(cfg.get("subjects", []) or [])
530 | if subject not in lib:
531 | lib.append(subject)
532 | cfg["subjects"] = lib
533 | write_cfg(cfg)
534 | flash("Subject added to library.", "success")
535 | else:
536 | flash("Subject already exists in library.", "info")
537 | return redirect(url_for("manage", view="users"))
538 |
539 |
540 | @app.route("/status")
541 | def status():
542 | return {"state": run_status.get("state"), "error": run_status.get("error")}
543 |
544 |
545 | def _host():
546 | # Bind to all interfaces by default so devices on LAN or tunnels can reach it
547 | host = os.getenv("HOST", "0.0.0.0")
548 | port = int(os.getenv("PORT", "5000"))
549 | return host, port
550 |
551 |
552 | if __name__ == "__main__":
553 | h, p = _host()
554 | dbg = (os.getenv("FLASK_DEBUG", "0") in ("1", "true", "True"))
555 | app.run(host=h, port=p, debug=dbg, use_reloader=False)
--------------------------------------------------------------------------------
/src/app_gui.py:
--------------------------------------------------------------------------------
1 | import os
2 | import json
3 | import sys
4 | import threading
5 | from pathlib import Path
6 |
7 | import tkinter as tk
8 | from tkinter import ttk, messagebox
9 |
10 | try:
11 | from bot import run_bot, load_config # داخل نفس مجلد src
12 | except Exception:
13 | # دعم التشغيل حين يكون المسار غير مضاف تلقائيًا
14 | sys.path.append(str(Path(__file__).resolve().parent))
15 | from bot import run_bot, load_config
16 |
17 |
18 | # إعدادات افتراضية يمكن تعديلها لاحقًا (ضع الإحداثيات الدقيقة للمباني هنا إن رغبت)
19 | PRESETS = {
20 | "building_c": {"latitude": 0.0, "longitude": 0.0, "accuracy": 10},
21 | "building_g": {"latitude": 0.0, "longitude": 0.0, "accuracy": 10},
22 | }
23 |
24 |
25 | class App(tk.Tk):
26 | def __init__(self):
27 | super().__init__()
28 | self.title("UCSI Attendance Bot")
29 | self.geometry("800x600")
30 | self.resizable(True, True)
31 |
32 | # Load current config
33 | try:
34 | cfg = load_config("config/config.json")
35 | except Exception:
36 | cfg = {}
37 |
38 | # App state variables
39 | self.status_var = tk.StringVar(value="Ready")
40 | self.admin_authenticated = False
41 |
42 | # Auto Attendance variables
43 | self.url_var = tk.StringVar(value=str(cfg.get("url", "")))
44 | self.loc_mode = tk.StringVar(value="browser") # building_c, building_g, custom, browser
45 | self.lat_var = tk.StringVar(value=str(PRESETS["building_c"]["latitude"]))
46 | self.lon_var = tk.StringVar(value=str(PRESETS["building_c"]["longitude"]))
47 | self.acc_var = tk.StringVar(value=str(PRESETS["building_c"].get("accuracy", 10)))
48 | self.headless_var = tk.BooleanVar(value=(os.getenv("HEADLESS", "0") in ("1", "true", "True")))
49 | # 0 means auto: run in parallel for all users
50 | self.parallel_var = tk.IntVar(value=int(cfg.get("parallel_browsers", 0) or 0))
51 | self.cf_mode_var = tk.StringVar(value=str((cfg.get("cloudflare") or {}).get("handle_challenge", "auto")))
52 | # Screenshots: delay before prepared shot (seconds)
53 | self.prep_shot_delay_var = tk.IntVar(value=int(((cfg.get("screenshots") or {}).get("delay_ms_before_prepared", 10000)) / 1000))
54 |
55 | # Add User variables
56 | self.new_username_var = tk.StringVar()
57 | self.new_phone_var = tk.StringVar()
58 | self.new_sid_var = tk.StringVar()
59 | self.new_pwd_var = tk.StringVar()
60 |
61 | # Manage Accounts variables
62 | self.admin_user_var = tk.StringVar()
63 | self.admin_pwd_var = tk.StringVar()
64 | self.m_sid_var = tk.StringVar()
65 | self.m_pwd_var = tk.StringVar()
66 | self.m_username_var = tk.StringVar()
67 | self.m_phone_var = tk.StringVar()
68 |
69 | # Build UI
70 | self._build_ui()
71 |
72 | def _build_ui(self):
73 | outer = ttk.Frame(self)
74 | outer.pack(fill=tk.BOTH, expand=True)
75 |
76 | notebook = ttk.Notebook(outer)
77 | notebook.pack(fill=tk.BOTH, expand=True)
78 |
79 | auto_tab = ttk.Frame(notebook)
80 | add_tab = ttk.Frame(notebook)
81 | manage_tab = ttk.Frame(notebook)
82 |
83 | notebook.add(auto_tab, text="Auto Attendance")
84 | notebook.add(add_tab, text="Add New User")
85 | notebook.add(manage_tab, text="Manage Accounts")
86 |
87 | self._build_auto_tab(auto_tab)
88 | self._build_add_user_tab(add_tab)
89 | self._build_manage_tab(manage_tab)
90 |
91 | # Status bar
92 | status_frm = ttk.Frame(outer)
93 | status_frm.pack(fill=tk.X)
94 | ttk.Separator(status_frm).pack(fill=tk.X, padx=10, pady=4)
95 | ttk.Label(status_frm, textvariable=self.status_var).pack(side=tk.LEFT, padx=10, pady=6)
96 | ttk.Label(status_frm, text="AHMAD2039 - 2025").pack(side=tk.RIGHT, padx=10, pady=6)
97 |
98 | def _build_auto_tab(self, frm):
99 | pad = {"padx": 10, "pady": 8}
100 |
101 | # URL row
102 | ttk.Label(frm, text="Attendance URL:").grid(row=0, column=0, sticky="w", **pad)
103 | ttk.Entry(frm, textvariable=self.url_var, width=60).grid(row=0, column=1, columnspan=3, sticky="ew", **pad)
104 | ttk.Button(frm, text="Paste URL", command=self._paste_url).grid(row=0, column=4, sticky="ew", **pad)
105 |
106 | # Location source
107 | ttk.Label(frm, text="Location Source:").grid(row=1, column=0, sticky="w", **pad)
108 | rb_c = ttk.Radiobutton(frm, text="Building C", variable=self.loc_mode, value="building_c", command=self._on_loc_change)
109 | rb_g = ttk.Radiobutton(frm, text="Building G", variable=self.loc_mode, value="building_g", command=self._on_loc_change)
110 | rb_custom = ttk.Radiobutton(frm, text="Custom", variable=self.loc_mode, value="custom", command=self._on_loc_change)
111 | rb_browser = ttk.Radiobutton(frm, text="From Browser (Device)", variable=self.loc_mode, value="browser", command=self._on_loc_change)
112 | rb_c.grid(row=1, column=1, sticky="w", **pad)
113 | rb_g.grid(row=1, column=2, sticky="w", **pad)
114 | rb_custom.grid(row=1, column=3, sticky="w", **pad)
115 | rb_browser.grid(row=1, column=4, sticky="w", **pad)
116 |
117 | # Coordinates
118 | ttk.Label(frm, text="Latitude (lat):").grid(row=2, column=0, sticky="w", **pad)
119 | self.lat_entry = ttk.Entry(frm, textvariable=self.lat_var, width=20)
120 | self.lat_entry.grid(row=2, column=1, sticky="w", **pad)
121 | ttk.Label(frm, text="Longitude (lon):").grid(row=2, column=2, sticky="w", **pad)
122 | self.lon_entry = ttk.Entry(frm, textvariable=self.lon_var, width=20)
123 | self.lon_entry.grid(row=2, column=3, sticky="w", **pad)
124 | ttk.Label(frm, text="Accuracy (m):").grid(row=2, column=4, sticky="w", **pad)
125 | self.acc_entry = ttk.Entry(frm, textvariable=self.acc_var, width=8)
126 | self.acc_entry.grid(row=2, column=5, sticky="w", **pad)
127 |
128 | # Exec options
129 | ttk.Label(frm, text="Execution Options:").grid(row=3, column=0, sticky="w", **pad)
130 | ttk.Checkbutton(frm, text="Headless", variable=self.headless_var).grid(row=3, column=1, sticky="w", **pad)
131 | ttk.Label(frm, text="Parallel Browsers (0 = Auto):").grid(row=3, column=2, sticky="e", **pad)
132 | ttk.Spinbox(frm, from_=0, to=20, textvariable=self.parallel_var, width=6).grid(row=3, column=3, sticky="w", **pad)
133 | ttk.Label(frm, text="Cloudflare:").grid(row=3, column=4, sticky="e", **pad)
134 | cf_combo = ttk.Combobox(frm, values=["auto", "manual", "off"], textvariable=self.cf_mode_var, width=8)
135 | cf_combo.grid(row=3, column=5, sticky="w", **pad)
136 |
137 | # Screenshot options
138 | ttk.Label(frm, text="Screenshot Delay (sec, after prepared):").grid(row=4, column=0, sticky="w", **pad)
139 | ttk.Spinbox(frm, from_=0, to=30, textvariable=self.prep_shot_delay_var, width=6).grid(row=4, column=1, sticky="w", **pad)
140 |
141 | # Buttons
142 | self.run_btn = ttk.Button(frm, text="Run", command=self._on_run)
143 | self.run_btn.grid(row=5, column=1, sticky="ew", **pad)
144 | ttk.Button(frm, text="Exit", command=self.destroy).grid(row=5, column=2, sticky="ew", **pad)
145 |
146 | # Initialize enable/disable fields
147 | self._on_loc_change()
148 |
149 | def _build_add_user_tab(self, frm):
150 | pad = {"padx": 10, "pady": 8}
151 |
152 | # Top: Username and Phone
153 | ttk.Label(frm, text="Username:").grid(row=0, column=0, sticky="w", **pad)
154 | ttk.Entry(frm, textvariable=self.new_username_var, width=30).grid(row=0, column=1, sticky="w", **pad)
155 | ttk.Label(frm, text="Phone:").grid(row=0, column=2, sticky="w", **pad)
156 | ttk.Entry(frm, textvariable=self.new_phone_var, width=18).grid(row=0, column=3, sticky="w", **pad)
157 |
158 | # Separator between name/phone and studentId/password
159 | ttk.Separator(frm).grid(row=1, column=0, columnspan=4, sticky="ew", **pad)
160 |
161 | # Student ID and Password
162 | ttk.Label(frm, text="Student ID:").grid(row=2, column=0, sticky="w", **pad)
163 | ttk.Entry(frm, textvariable=self.new_sid_var, width=30).grid(row=2, column=1, sticky="w", **pad)
164 | ttk.Label(frm, text="Password:").grid(row=2, column=2, sticky="w", **pad)
165 | ttk.Entry(frm, textvariable=self.new_pwd_var, show="*", width=18).grid(row=2, column=3, sticky="w", **pad)
166 |
167 | # Buttons
168 | ttk.Button(frm, text="Save", command=self._save_new_user).grid(row=3, column=1, sticky="ew", **pad)
169 | ttk.Button(frm, text="Clear", command=self._clear_new_user).grid(row=3, column=2, sticky="ew", **pad)
170 |
171 | def _build_manage_tab(self, frm):
172 | pad = {"padx": 10, "pady": 8}
173 |
174 | # Admin login pane
175 | self.manage_container = ttk.Frame(frm)
176 | self.manage_container.grid(row=0, column=0, sticky="nsew")
177 | frm.grid_rowconfigure(0, weight=1)
178 | frm.grid_columnconfigure(0, weight=1)
179 |
180 | login_frame = ttk.LabelFrame(self.manage_container, text="Admin Login")
181 | login_frame.pack(fill=tk.X, padx=10, pady=10)
182 | ttk.Label(login_frame, text="Admin Username:").grid(row=0, column=0, sticky="w", **pad)
183 | ttk.Entry(login_frame, textvariable=self.admin_user_var, width=24).grid(row=0, column=1, sticky="w", **pad)
184 | ttk.Label(login_frame, text="Admin Password:").grid(row=0, column=2, sticky="w", **pad)
185 | ttk.Entry(login_frame, textvariable=self.admin_pwd_var, show="*", width=24).grid(row=0, column=3, sticky="w", **pad)
186 | ttk.Button(login_frame, text="Login", command=self._admin_login).grid(row=0, column=4, sticky="ew", **pad)
187 |
188 | # Management pane (hidden until login)
189 | self.admin_area = ttk.LabelFrame(self.manage_container, text="Manage Student Credentials")
190 | self.admin_area.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
191 |
192 | # List of users
193 | list_frame = ttk.Frame(self.admin_area)
194 | list_frame.pack(fill=tk.BOTH, expand=True)
195 | ttk.Label(list_frame, text="Students:").grid(row=0, column=0, sticky="w", **pad)
196 | self.users_listbox = tk.Listbox(list_frame, height=10)
197 | self.users_listbox.grid(row=1, column=0, columnspan=1, sticky="nsew", **pad)
198 | list_frame.grid_rowconfigure(1, weight=1)
199 | list_frame.grid_columnconfigure(0, weight=1)
200 |
201 | # Edit area
202 | edit_frame = ttk.Frame(self.admin_area)
203 | edit_frame.pack(fill=tk.X, expand=False)
204 | ttk.Label(edit_frame, text="Student ID:").grid(row=0, column=0, sticky="w", **pad)
205 | ttk.Entry(edit_frame, textvariable=self.m_sid_var, width=24).grid(row=0, column=1, sticky="w", **pad)
206 | ttk.Label(edit_frame, text="Password:").grid(row=0, column=2, sticky="w", **pad)
207 | ttk.Entry(edit_frame, textvariable=self.m_pwd_var, width=24).grid(row=0, column=3, sticky="w", **pad)
208 |
209 | ttk.Label(edit_frame, text="Name:").grid(row=1, column=0, sticky="w", **pad)
210 | ttk.Label(edit_frame, textvariable=self.m_username_var).grid(row=1, column=1, sticky="w", **pad)
211 | ttk.Label(edit_frame, text="Phone:").grid(row=1, column=2, sticky="w", **pad)
212 | ttk.Label(edit_frame, textvariable=self.m_phone_var).grid(row=1, column=3, sticky="w", **pad)
213 |
214 | ttk.Button(edit_frame, text="Add", command=self._admin_add).grid(row=1, column=0, sticky="ew", **pad)
215 | ttk.Button(edit_frame, text="Update", command=self._admin_update).grid(row=1, column=1, sticky="ew", **pad)
216 | ttk.Button(edit_frame, text="Delete", command=self._admin_delete).grid(row=1, column=2, sticky="ew", **pad)
217 | ttk.Button(edit_frame, text="Refresh", command=self._admin_refresh).grid(row=1, column=3, sticky="ew", **pad)
218 |
219 | self.users_listbox.bind("<>", self._on_user_select)
220 |
221 | # Initially hide admin area until authenticated
222 | self._set_admin_area_visible(False)
223 | self._admin_refresh()
224 |
225 | def _on_loc_change(self):
226 | mode = self.loc_mode.get()
227 | # حدّث الحقول وفق الاختيار
228 | editable = mode in ("building_c", "building_g", "custom")
229 | state = "normal" if editable else "disabled"
230 | for entry in (self.lat_entry, self.lon_entry, self.acc_entry):
231 | entry.configure(state=state)
232 |
233 | if mode == "building_c":
234 | p = PRESETS["building_c"]
235 | self.lat_var.set(str(p["latitude"]))
236 | self.lon_var.set(str(p["longitude"]))
237 | self.acc_var.set(str(p.get("accuracy", 10)))
238 | elif mode == "building_g":
239 | p = PRESETS["building_g"]
240 | self.lat_var.set(str(p["latitude"]))
241 | self.lon_var.set(str(p["longitude"]))
242 | self.acc_var.set(str(p.get("accuracy", 10)))
243 | elif mode == "custom":
244 | # اترك القيم كما هي ليدخلها المستخدم
245 | pass
246 | elif mode == "browser":
247 | # في وضع المتصفح، لا حاجة لإحداثيات ثابتة
248 | self.lat_var.set("")
249 | self.lon_var.set("")
250 | self.acc_var.set("")
251 |
252 | def _paste_url(self):
253 | try:
254 | value = self.clipboard_get()
255 | if not isinstance(value, str) or not value.strip():
256 | raise ValueError("Clipboard empty")
257 | self.url_var.set(value.strip())
258 | self.status_var.set("URL pasted from clipboard.")
259 | except Exception:
260 | messagebox.showerror("Error", "Clipboard is empty or contains unsupported data.")
261 |
262 | # --- Add User actions ---
263 | def _save_new_user(self):
264 | sid = self.new_sid_var.get().strip()
265 | pwd = self.new_pwd_var.get().strip()
266 | username = self.new_username_var.get().strip()
267 | phone = self.new_phone_var.get().strip()
268 | if not sid or not pwd:
269 | messagebox.showerror("Error", "Student ID and Password are required.")
270 | return
271 | users = self._read_users()
272 | if any(u.get("studentId") == sid for u in users):
273 | messagebox.showerror("Error", "Student ID already exists.")
274 | return
275 | users.append({"studentId": sid, "password": pwd, "username": username, "phone": phone})
276 | self._write_users(users)
277 | messagebox.showinfo("Saved", "New user saved successfully.")
278 | self._clear_new_user()
279 |
280 | def _clear_new_user(self):
281 | self.new_username_var.set("")
282 | self.new_phone_var.set("")
283 | self.new_sid_var.set("")
284 | self.new_pwd_var.set("")
285 |
286 | # --- Admin manage actions ---
287 | def _admin_login(self):
288 | user = self.admin_user_var.get().strip()
289 | pwd = self.admin_pwd_var.get().strip()
290 | if user == "1002476196" and pwd == "Ahmad@2006":
291 | self.admin_authenticated = True
292 | self._set_admin_area_visible(True)
293 | messagebox.showinfo("Authenticated", "Admin access granted.")
294 | else:
295 | self.admin_authenticated = False
296 | self._set_admin_area_visible(False)
297 | messagebox.showerror("Error", "Invalid admin credentials.")
298 |
299 | def _set_admin_area_visible(self, visible: bool):
300 | # Enable/disable only widgets that support 'state'
301 | try:
302 | self._set_children_state(self.admin_area, enabled=visible)
303 | except Exception:
304 | pass
305 | self.admin_area.configure(text=("Manage Student Credentials" if visible else "Manage Student Credentials (Locked)"))
306 |
307 | def _set_children_state(self, container, enabled: bool):
308 | for child in container.winfo_children():
309 | try:
310 | opts = child.configure()
311 | if isinstance(opts, dict) and ('state' in opts):
312 | child.configure(state=('normal' if enabled else 'disabled'))
313 | # Recurse into nested containers
314 | if child.winfo_children():
315 | self._set_children_state(child, enabled)
316 | except Exception:
317 | # Safely ignore widgets not supporting state
318 | pass
319 |
320 | def _on_user_select(self, event=None):
321 | try:
322 | idxs = self.users_listbox.curselection()
323 | if not idxs:
324 | return
325 | idx = idxs[0]
326 | users = self._read_users()
327 | if idx < 0 or idx >= len(users):
328 | return
329 | self.m_sid_var.set(users[idx].get("studentId", ""))
330 | self.m_pwd_var.set(users[idx].get("password", ""))
331 | self.m_username_var.set(users[idx].get("username", ""))
332 | self.m_phone_var.set(users[idx].get("phone", ""))
333 | except Exception:
334 | pass
335 |
336 | def _admin_refresh(self):
337 | users = self._read_users()
338 | self.users_listbox.delete(0, tk.END)
339 | for u in users:
340 | sid = u.get("studentId", "")
341 | name = u.get("username", "")
342 | phone = u.get("phone", "")
343 | self.users_listbox.insert(tk.END, f"{sid} | {name} | {phone}")
344 |
345 | def _admin_add(self):
346 | if not self.admin_authenticated:
347 | messagebox.showerror("Error", "Admin login required.")
348 | return
349 | sid = self.m_sid_var.get().strip()
350 | pwd = self.m_pwd_var.get().strip()
351 | if not sid or not pwd:
352 | messagebox.showerror("Error", "Student ID and Password are required.")
353 | return
354 | users = self._read_users()
355 | if any(u.get("studentId") == sid for u in users):
356 | messagebox.showerror("Error", "Student ID already exists.")
357 | return
358 | users.append({"studentId": sid, "password": pwd})
359 | self._write_users(users)
360 | self._admin_refresh()
361 | messagebox.showinfo("Saved", "User added.")
362 |
363 | def _admin_update(self):
364 | if not self.admin_authenticated:
365 | messagebox.showerror("Error", "Admin login required.")
366 | return
367 | sid = self.m_sid_var.get().strip()
368 | pwd = self.m_pwd_var.get().strip()
369 | if not sid or not pwd:
370 | messagebox.showerror("Error", "Student ID and Password are required.")
371 | return
372 | users = self._read_users()
373 | updated = False
374 | for u in users:
375 | if u.get("studentId") == sid:
376 | u["password"] = pwd
377 | updated = True
378 | break
379 | if not updated:
380 | messagebox.showerror("Error", "Student ID not found.")
381 | return
382 | self._write_users(users)
383 | self._admin_refresh()
384 | messagebox.showinfo("Updated", "Password updated.")
385 |
386 | def _admin_delete(self):
387 | if not self.admin_authenticated:
388 | messagebox.showerror("Error", "Admin login required.")
389 | return
390 | sid = self.m_sid_var.get().strip()
391 | if not sid:
392 | messagebox.showerror("Error", "Student ID is required.")
393 | return
394 | users = self._read_users()
395 | new_users = [u for u in users if u.get("studentId") != sid]
396 | if len(new_users) == len(users):
397 | messagebox.showerror("Error", "Student ID not found.")
398 | return
399 | self._write_users(new_users)
400 | self._admin_refresh()
401 | self.m_sid_var.set("")
402 | self.m_pwd_var.set("")
403 | messagebox.showinfo("Deleted", "User deleted.")
404 |
405 | # --- Config helpers ---
406 | def _read_users(self):
407 | try:
408 | cfg = load_config("config/config.json")
409 | except Exception:
410 | return []
411 | return list(cfg.get("users", []) or [])
412 |
413 | def _write_users(self, users):
414 | cfg_path = Path("config/config.json")
415 | try:
416 | with cfg_path.open(encoding="utf-8") as f:
417 | cfg = json.load(f)
418 | except Exception:
419 | cfg = {}
420 | cfg["users"] = users
421 | try:
422 | with cfg_path.open("w", encoding="utf-8") as f:
423 | json.dump(cfg, f, ensure_ascii=False, indent=2)
424 | except Exception as e:
425 | messagebox.showerror("Error", f"Failed to write config: {e}")
426 |
427 | def _build_config(self):
428 | # ابدأ من الإعداد الحالي ثم طبّق التغييرات
429 | try:
430 | base = load_config("config/config.json")
431 | except Exception:
432 | base = {}
433 |
434 | base["url"] = self.url_var.get().strip()
435 | # 0 => auto parallel equal to number of users
436 | try:
437 | base["parallel_browsers"] = int(self.parallel_var.get() or 0)
438 | except Exception:
439 | base["parallel_browsers"] = 0
440 | base["open_output_dir_after_run"] = True
441 |
442 | # Cloudflare
443 | base["cloudflare"] = {
444 | "handle_challenge": self.cf_mode_var.get().strip() or "auto",
445 | "timeout_ms": int((base.get("cloudflare") or {}).get("timeout_ms", 20000)),
446 | "after_check_delay_ms": int((base.get("cloudflare") or {}).get("after_check_delay_ms", 1500)),
447 | }
448 |
449 | # Geolocation
450 | mode = self.loc_mode.get()
451 | if mode == "browser":
452 | base["geolocation"] = {
453 | "source": "browser",
454 | "require_browser": True,
455 | "wait_ms": int((base.get("geolocation") or {}).get("wait_ms", 4000)),
456 | }
457 | else:
458 | # قيم lat/lon/acc
459 | try:
460 | lat = float(self.lat_var.get())
461 | lon = float(self.lon_var.get())
462 | except Exception:
463 | messagebox.showerror("Error", "Please enter numeric values for latitude/longitude.")
464 | raise
465 | try:
466 | acc = int(float(self.acc_var.get()))
467 | except Exception:
468 | acc = 10
469 | base["geolocation"] = {
470 | "source": "fixed",
471 | "latitude": lat,
472 | "longitude": lon,
473 | "accuracy": acc,
474 | }
475 |
476 | # Screenshots config: delay before prepared shot
477 | shots = (base.get("screenshots") or {})
478 | try:
479 | shots["delay_ms_before_prepared"] = int(self.prep_shot_delay_var.get()) * 1000
480 | except Exception:
481 | shots["delay_ms_before_prepared"] = int(shots.get("delay_ms_before_prepared", 3000))
482 | base["screenshots"] = shots
483 |
484 | return base
485 |
486 | def _on_run(self):
487 | # ضبط HEADLESS
488 | os.environ["HEADLESS"] = "1" if self.headless_var.get() else "0"
489 |
490 | def worker():
491 | try:
492 | cfg = self._build_config()
493 | except Exception:
494 | self.status_var.set("Failed to build config.")
495 | return
496 | try:
497 | self.status_var.set("Running...")
498 | self.run_btn.configure(state="disabled")
499 | run_bot(cfg)
500 | self.status_var.set("Run complete.")
501 | except Exception as e:
502 | self.status_var.set(f"Error occurred: {e}")
503 | messagebox.showerror("Error", str(e))
504 | finally:
505 | self.run_btn.configure(state="normal")
506 |
507 | threading.Thread(target=worker, daemon=True).start()
508 |
509 |
510 | if __name__ == "__main__":
511 | app = App()
512 | app.mainloop()
--------------------------------------------------------------------------------
/src/bot.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import re
4 | import sys
5 | import time
6 | from pathlib import Path
7 | from typing import Any, Dict, List
8 | from urllib.parse import urlparse, urlencode
9 | import urllib.request
10 | from concurrent.futures import ThreadPoolExecutor, as_completed
11 |
12 | from dotenv import load_dotenv
13 | from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
14 |
15 | # -----------------------------------------------
16 | # ملاحظات إعداد سريعة (اقرأني):
17 | #
18 | # 1) أين أضع الرابط؟
19 | # - في الملف: config/config.json داخل المفتاح "url".
20 | # - أو يمكنك تمرير الرابط عند التشغيل عبر وسيط: --url
21 | # مثال: python src/bot.py --url https://example.com...
22 | #
23 | # 2) أين أضع أسماء المستخدمين وكلمات السر؟
24 | # - في الملف: config/config.json داخل المفتاح "users" كمصفوفة عناصر.
25 | # كل عنصر يحتوي على: studentId و password
26 | # مثال:
27 | # "users": [
28 | # { "studentId": "2023012345", "password": "pass1" },
29 | # { "studentId": "2023012346", "password": "pass2" }
30 | # ]
31 | #
32 | # 3) ماذا لو لم يتعرّف السكربت على الحقول/الأزرار؟
33 | # - استخدم "login.overrides" في config.json لتعريف محددات CSS مباشرة:
34 | # - student_id_selector، password_selector، submit_selector
35 | #
36 | # 4) الموقع يطلب تمكين الموقع (GPS):
37 | # - حدّد إحداثياتك في "geolocation" داخل config.json ليتم منح الإذن تلقائيًا.
38 | # -----------------------------------------------
39 |
40 |
41 | def load_config(path: str) -> Dict[str, Any]:
42 | cfg_path = Path(path)
43 | if not cfg_path.exists():
44 | print(f"[Error] Configuration file not found: {cfg_path}")
45 | sys.exit(1)
46 | with cfg_path.open(encoding="utf-8") as f:
47 | return json.load(f)
48 |
49 |
50 | def get_arg(key: str, default: str | None = None) -> str | None:
51 | for i, a in enumerate(sys.argv):
52 | if a == key and i + 1 < len(sys.argv):
53 | return sys.argv[i + 1]
54 | if a.startswith(f"{key}="):
55 | return a.split("=", 1)[1]
56 | return default
57 |
58 |
59 | def grant_geo_permissions(context, origin: str, geolocation: Dict[str, Any] | None):
60 | if geolocation:
61 | context.set_default_navigation_timeout(30000)
62 | try:
63 | context.grant_permissions(["geolocation"], origin=origin)
64 | except Exception:
65 | # بعض الإصدارات لا تحتاج origin
66 | context.grant_permissions(["geolocation"]) # noqa
67 | # إعداد موقع جغرافي افتراضي عند إنشاء الصفحة
68 | # ملاحظة: new_context يدعم geolocation مباشرة أيضًا، لكن نستخدم منطقًا موحدًا هنا.
69 |
70 |
71 | def fetch_ip_geolocation() -> Dict[str, Any] | None:
72 | """Attempts to fetch the geolocation based on the public IP address of the device."""
73 | # First attempt: ipapi.co
74 | try:
75 | with urllib.request.urlopen("https://ipapi.co/json", timeout=10) as resp:
76 | data = json.loads(resp.read().decode("utf-8"))
77 | lat = data.get("latitude") or data.get("lat")
78 | lon = data.get("longitude") or data.get("lon")
79 | if lat is not None and lon is not None:
80 | return {"latitude": float(lat), "longitude": float(lon), "accuracy": 1000}
81 | except Exception:
82 | pass
83 | # المحاولة الثانية: ip-api.com
84 | try:
85 | with urllib.request.urlopen("http://ip-api.com/json", timeout=10) as resp:
86 | data = json.loads(resp.read().decode("utf-8"))
87 | if data.get("status") == "success":
88 | lat = data.get("lat")
89 | lon = data.get("lon")
90 | if lat is not None and lon is not None:
91 | return {"latitude": float(lat), "longitude": float(lon), "accuracy": 2000}
92 | except Exception:
93 | pass
94 | return None
95 |
96 |
97 | def resolve_geolocation(config: Dict[str, Any]) -> Dict[str, Any] | None:
98 | """Resolves the geolocation based on the configuration: either fixed or via IP."""
99 | geo_cfg = config.get("geolocation") or {}
100 | source = str(geo_cfg.get("source", "fixed")).lower()
101 | if source == "browser":
102 | # اترك المتصفح يحدّد الموقع بنفسه. سنمنح الإذن فقط دون تمرير إحداثيات.
103 | return None
104 | if source == "ip":
105 | ip_geo = fetch_ip_geolocation()
106 | if ip_geo:
107 | # استخدم دقة من الإعداد إن وُجدت
108 | acc = geo_cfg.get("accuracy")
109 | if acc is not None:
110 | ip_geo["accuracy"] = acc
111 | return ip_geo
112 | # إن فشل الجلب عبر IP، أعد محاولة باستخدام القيم الثابتة إن وُجدت
113 | # مصدر ثابت: استخدم القيم كما هي إن وُجدت
114 | lat = geo_cfg.get("latitude")
115 | lon = geo_cfg.get("longitude")
116 | acc = geo_cfg.get("accuracy", 1000)
117 | if lat is not None and lon is not None:
118 | return {"latitude": float(lat), "longitude": float(lon), "accuracy": int(acc)}
119 | return None
120 |
121 | def probe_browser_geolocation(page, config: Dict[str, Any]) -> None:
122 | """Attempts to learn the browser's geolocation if the source is 'browser'."""
123 | geo_cfg = config.get("geolocation") or {}
124 | if str(geo_cfg.get("source", "fixed")).lower() != "browser":
125 | return
126 | wait_ms = int(geo_cfg.get("wait_ms", 4000))
127 | require = bool(geo_cfg.get("require_browser", True))
128 | try:
129 | page.wait_for_timeout(500) # فسحة قصيرة قبل الطلب
130 | except Exception:
131 | pass
132 | try:
133 | result = page.evaluate(
134 | """
135 | () => new Promise((resolve) => {
136 | try {
137 | navigator.geolocation.getCurrentPosition(
138 | (pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude, acc: pos.coords.accuracy }),
139 | (err) => resolve({ error: String(err && err.message || 'geolocation-error') })
140 | );
141 | } catch (e) {
142 | resolve({ error: String(e && e.message || 'geolocation-exception') });
143 | }
144 | })
145 | """
146 | )
147 | if isinstance(result, dict) and not result.get("error"):
148 | print(f"[Info] Browser geolocation: lat={result['lat']}, lon={result['lon']}, acc≈{int(result['acc'])}m")
149 | return
150 | else:
151 | if require:
152 | print(f"[Warning] Failed to get browser geolocation: {result.get('error')}")
153 | return
154 | except Exception as e:
155 | if require:
156 | print(f"[Error] Exception while querying browser geolocation: {e}")
157 | # انتظار إضافي اختياري حين يتطلب الموقع طلبًا لاحقًا
158 | if wait_ms > 0:
159 | try:
160 | page.wait_for_timeout(wait_ms)
161 | except Exception:
162 | pass
163 |
164 |
165 | def click_first_matching_button(page, names: List[str]) -> bool:
166 | for name in names:
167 | try:
168 | page.get_by_role("button", name=re.compile(name, re.I)).click()
169 | return True
170 | except Exception:
171 | # جرّب مطابقة نص مباشرة
172 | try:
173 | page.locator(f"text={name}").first.click()
174 | return True
175 | except Exception:
176 | # محاولات إضافية وفق البنية الظاهرة (زر بداخله span.t-Button-label)
177 | try:
178 | page.locator("button").filter(has_text=re.compile(name, re.I)).first.click()
179 | return True
180 | except Exception:
181 | try:
182 | page.locator("button:has(span.t-Button-label)").filter(has_text=re.compile(name, re.I)).first.click()
183 | return True
184 | except Exception:
185 | try:
186 | page.locator("span.t-Button-label").filter(has_text=re.compile(name, re.I)).first.click()
187 | return True
188 | except Exception:
189 | continue
190 | return False
191 |
192 | def wait_and_click_first_matching(page, names: List[str], timeout_ms: int) -> bool:
193 | """Waits until any of the buttons with the specified names appears, then clicks it."""
194 | deadline = time.monotonic() + (timeout_ms / 1000.0)
195 | while time.monotonic() < deadline:
196 | if click_first_matching_button(page, names):
197 | return True
198 | try:
199 | page.wait_for_timeout(400)
200 | except Exception:
201 | pass
202 | return False
203 |
204 |
205 | def _spinner_selectors(shots_cfg: Dict[str, Any]) -> List[str]:
206 | prepared_wait_selector_cfg = shots_cfg.get("prepared_wait_selector")
207 | if isinstance(prepared_wait_selector_cfg, list):
208 | selectors = [str(s).strip() for s in prepared_wait_selector_cfg if str(s).strip()]
209 | elif isinstance(prepared_wait_selector_cfg, str) and prepared_wait_selector_cfg.strip():
210 | selectors = [prepared_wait_selector_cfg.strip()]
211 | else:
212 | selectors = [
213 | ".fa-spinner",
214 | ".t-Icon--spinner",
215 | ".u-Processing",
216 | ".apex_wait_mask",
217 | "div.u-Processing-spinner",
218 | ".spinner",
219 | ".spinner-border",
220 | ".loading-spinner",
221 | ".lds-spinner",
222 | ".MuiCircularProgress-root",
223 | ]
224 | return selectors
225 |
226 |
227 | def _wait_idle_and_hide_spinners(page, shots_cfg: Dict[str, Any], timeout_ms: int) -> None:
228 | # أولاً: انتظر حالة تحميل الصفحة
229 | try:
230 | page.wait_for_load_state("networkidle")
231 | except Exception:
232 | try:
233 | page.wait_for_load_state("domcontentloaded")
234 | except Exception:
235 | pass
236 | # ثانيًا: حاول إخفاء مؤشرات التحميل الشائعة
237 | selectors = _spinner_selectors(shots_cfg)
238 | for sel in selectors:
239 | try:
240 | page.locator(sel).first.wait_for(state="hidden", timeout=timeout_ms)
241 | break
242 | except Exception:
243 | continue
244 |
245 |
246 | def _send_telegram_message(chat_id: int | str, text: str) -> None:
247 | """Send a simple text message via Telegram Bot API.
248 | Requires TELEGRAM_TOKEN in environment. Swallows all errors."""
249 | try:
250 | token = os.getenv("TELEGRAM_TOKEN")
251 | if not token:
252 | return
253 | api = f"https://api.telegram.org/bot{token}/sendMessage"
254 | payload = urlencode({"chat_id": int(chat_id), "text": text})
255 | req = urllib.request.Request(api, data=payload.encode("utf-8"), headers={"Content-Type": "application/x-www-form-urlencoded"})
256 | urllib.request.urlopen(req, timeout=10).read()
257 | except Exception:
258 | pass
259 |
260 |
261 | def _notify_initiator(config: Dict[str, Any], text: str) -> None:
262 | notify = (config.get("notify") or {})
263 | chat_id = notify.get("initiator_chat_id") or notify.get("chat_id")
264 | if chat_id:
265 | _send_telegram_message(chat_id, text)
266 |
267 | def _delete_started_message_if_any(config: Dict[str, Any]) -> None:
268 | try:
269 | notify = (config.get("notify") or {})
270 | chat_id = notify.get("started_message_chat_id")
271 | msg_id = notify.get("started_message_id")
272 | token = os.getenv("TELEGRAM_TOKEN")
273 | if token and chat_id and msg_id:
274 | import requests as _requests
275 | api_url = f"https://api.telegram.org/bot{token}/deleteMessage"
276 | payload = {"chat_id": chat_id, "message_id": msg_id}
277 | try:
278 | _requests.post(api_url, json=payload, timeout=10)
279 | except Exception:
280 | pass
281 | except Exception:
282 | pass
283 |
284 |
285 | def screenshot_for(page, sid: str, config: Dict[str, Any], suffix: str | None = None) -> str | None:
286 | """Captures a screenshot and appends an optional suffix to the filename to indicate the state.
287 | Returns the file path on success, or None on failure."""
288 | tmpl = config.get("screenshot_template", "output/{studentId}.png")
289 | shots_cfg = config.get("screenshots", {})
290 | full_page = bool(shots_cfg.get("full_page", False))
291 | scroll_top_before = bool(shots_cfg.get("scroll_top_before", True))
292 | # مهلة قبل لقطة الشاشة بعد التحضير (قابلة للضبط)
293 | delay_ms_prepared = int(shots_cfg.get("delay_ms_before_prepared", 3000))
294 | # اختياري: انتظر زوال مؤشرات التحميل قبل لقطة "prepared"
295 | prepared_wait_timeout = int(shots_cfg.get("prepared_wait_timeout_ms", 15000))
296 | path_str = tmpl.format(studentId=sid)
297 | # Append optional suffix and a timestamp YYYYMMDD-HHMMSS to filename
298 | timestamp = time.strftime("%Y%m%d-%H%M%S")
299 | p = Path(path_str)
300 | new_stem = p.stem
301 | if suffix:
302 | new_stem = f"{new_stem}-{suffix}"
303 | new_stem = f"{new_stem}-{timestamp}"
304 | path_str = str(p.with_name(new_stem + p.suffix))
305 | out_path = Path(path_str)
306 | out_path.parent.mkdir(parents=True, exist_ok=True)
307 | try:
308 | # انتظر قبل الالتقاط إذا كانت اللقطة "prepared" لتجنب مشاكل العرض
309 | if suffix == "prepared":
310 | try:
311 | _wait_idle_and_hide_spinners(page, shots_cfg, prepared_wait_timeout)
312 | except Exception:
313 | pass
314 | if delay_ms_prepared > 0:
315 | try:
316 | page.wait_for_timeout(delay_ms_prepared)
317 | except Exception:
318 | pass
319 | if scroll_top_before:
320 | try:
321 | page.evaluate("window.scrollTo(0,0)")
322 | except Exception:
323 | pass
324 | page.screenshot(path=str(out_path), full_page=full_page)
325 | print(f"[Info] Saved screenshot: {out_path}")
326 | return str(out_path)
327 | except Exception:
328 | print("[Error] Failed to save screenshot.")
329 | return None
330 |
331 |
332 | def scroll_back_to_top(page, delay_ms: int = 200):
333 | """Scrolls the page back to the top to reduce visible motion after clicks."""
334 | try:
335 | if delay_ms > 0:
336 | page.wait_for_timeout(delay_ms)
337 | except Exception:
338 | pass
339 | try:
340 | page.evaluate("window.scrollTo(0,0)")
341 | except Exception:
342 | pass
343 |
344 | def handle_cloudflare_challenge(page, config: Dict[str, Any]) -> bool:
345 | """Tries to handle the Cloudflare challenge page if it is present.
346 | Returns True if no challenge is present or if it is successfully bypassed,
347 | and False if the challenge prevents proceeding.
348 | """
349 | cf_cfg = (config.get("cloudflare") or {})
350 | mode = str(cf_cfg.get("handle_challenge", "auto")).lower()
351 | timeout_ms = int(cf_cfg.get("timeout_ms", 20000))
352 | after_delay_ms = int(cf_cfg.get("after_check_delay_ms", 1500))
353 |
354 | if mode == "off":
355 | return True
356 |
357 | def is_challenge_present() -> bool:
358 | try:
359 | if page.locator("text=Verify you are human").first.is_visible():
360 | return True
361 | except Exception:
362 | pass
363 | try:
364 | if page.locator("text=Performance & security by Cloudflare").first.is_visible():
365 | return True
366 | except Exception:
367 | pass
368 | try:
369 | if page.locator("iframe[title*='security challenge']").count() > 0:
370 | return True
371 | except Exception:
372 | pass
373 | return False
374 |
375 | # إن لم يوجد تحدي فلا حاجة لشيء
376 | if not is_challenge_present():
377 | return True
378 |
379 | # محاولة تلقائية للنقر على مربع التحقق
380 | if mode in ("auto", "automatic"):
381 | try:
382 | page.get_by_label(re.compile("Verify you are human", re.I)).check()
383 | try:
384 | page.wait_for_load_state("networkidle")
385 | except Exception:
386 | page.wait_for_load_state("domcontentloaded")
387 | if after_delay_ms > 0:
388 | try:
389 | page.wait_for_timeout(after_delay_ms)
390 | except Exception:
391 | pass
392 | except Exception:
393 | # جرّب عبر الإطار مباشرة إن وُجد
394 | try:
395 | frame = page.frame_locator("iframe[title*='security challenge']").first
396 | frame.locator("input[type='checkbox']").click()
397 | if after_delay_ms > 0:
398 | try:
399 | page.wait_for_timeout(after_delay_ms)
400 | except Exception:
401 | pass
402 | except Exception:
403 | pass
404 |
405 | # في الوضع اليدوي، امنح بعض الوقت ليحل المستخدم التحدي
406 | if mode == "manual":
407 | print("[Warning] Cloudflare challenge detected. Please resolve it manually if required.")
408 | try:
409 | page.wait_for_timeout(timeout_ms)
410 | except Exception:
411 | pass
412 |
413 | return not is_challenge_present()
414 |
415 | def run_for_user(p, browser, headless: bool, url: str, origin: str, user: Dict[str, str], config: Dict[str, Any]) -> bool:
416 | geolocation = resolve_geolocation(config)
417 | browser_cfg = (config.get("browser") or {})
418 | user_agent = browser_cfg.get("user_agent") or (
419 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36"
420 | )
421 | context = browser.new_context(geolocation=geolocation or None, user_agent=user_agent, locale="en-US")
422 | # Stealth: reduce automation fingerprints, especially in headless
423 | try:
424 | context.add_init_script(
425 | """
426 | // Hide webdriver flag
427 | Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
428 | // Fake chrome object
429 | window.chrome = { runtime: {} };
430 | // Language and vendor
431 | Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
432 | Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' });
433 | Object.defineProperty(navigator, 'platform', { get: () => 'Win32' });
434 | // Patch permissions.query to avoid noisy results
435 | const originalQuery = (navigator.permissions && navigator.permissions.query) ? navigator.permissions.query.bind(navigator.permissions) : null;
436 | if (originalQuery) {
437 | navigator.permissions.query = (parameters) => {
438 | if (parameters && parameters.name === 'notifications') {
439 | return Promise.resolve({ state: Notification.permission });
440 | }
441 | return originalQuery(parameters);
442 | };
443 | }
444 | // WebGL fingerprint softening
445 | const getParameter = WebGLRenderingContext.prototype.getParameter;
446 | WebGLRenderingContext.prototype.getParameter = function(parameter) {
447 | if (parameter === 37445) return 'Intel(R) UHD Graphics 620';
448 | if (parameter === 37446) return 'Google Inc. (Intel)';
449 | return getParameter.call(this, parameter);
450 | };
451 | """
452 | )
453 | except Exception:
454 | pass
455 | # يحدّد الموقع الجغرافي تلقائيًا حسب المصدر (IP أو ثابت) من config/config.json
456 | grant_geo_permissions(context, origin, geolocation)
457 | page = context.new_page()
458 |
459 | print(f"[Info] Navigating to: {url}")
460 | page.goto(url, wait_until="domcontentloaded")
461 |
462 | # تحقّق من تحدي Cloudflare إن وُجد وحاول تجاوزه
463 | if not handle_cloudflare_challenge(page, config):
464 | sid = user.get("studentId", "")
465 | print("[Warning] Cloudflare challenge prevents proceeding for this user.")
466 | screenshot_for(page, sid, config, suffix="cloudflare-challenge")
467 | context.close()
468 | return False
469 |
470 | # في وضع المتصفح: اطلب إحداثيات الجهاز الحالي ثم تابع
471 | probe_browser_geolocation(page, config)
472 |
473 | ui_cfg = config.get("ui", {})
474 | scroll_back_after_click = bool(ui_cfg.get("scroll_back_after_click", True))
475 | scroll_back_delay_ms = int(ui_cfg.get("scroll_back_delay_ms", 200))
476 |
477 | login_cfg = config.get("login", {})
478 | student_label = login_cfg.get("student_id_label", "Student ID")
479 | password_label = login_cfg.get("password_label", "Password")
480 | submit_names = login_cfg.get("submit_button_names", ["Sign In", "Login"])
481 | overrides = login_cfg.get("overrides", {})
482 |
483 | sid = user.get("studentId", "")
484 | pwd = user.get("password", "")
485 | shots_cfg = config.get("screenshots", {})
486 |
487 | try:
488 | if overrides.get("student_id_selector"):
489 | page.fill(overrides["student_id_selector"], sid)
490 | # إذا حددت student_id_selector داخل login.overrides في config.json سيتم استخدامه مباشرة
491 | else:
492 | page.get_by_label(student_label).fill(sid)
493 | # إذا لم تحدد محددًا، سيبحث عن الحقل بواسطة نص الملصق "Student ID"
494 | if overrides.get("password_selector"):
495 | page.fill(overrides["password_selector"], pwd)
496 | # إذا حددت password_selector داخل login.overrides سيتم استخدامه مباشرة
497 | else:
498 | page.get_by_label(password_label).fill(pwd)
499 | # إذا لم تحدد محددًا، سيبحث عن الحقل بواسطة نص الملصق "Password"
500 | except Exception as e:
501 | print(f"[Error] Failed to fill login fields: {e}")
502 | # التقط لقطة شاشة لحالة الخطأ
503 | screenshot_for(page, sid, config, suffix="login-error")
504 | context.close()
505 | return False
506 |
507 | # لقطة شاشة بعد تحضير المُعرّف (تعبئة الحقول) قبل محاولة الدخول (قابلة للتعطيل)
508 | if bool(shots_cfg.get("capture_prepared", False)):
509 | screenshot_for(page, sid, config, suffix="prepared")
510 |
511 | # انقر زر الدخول
512 | clicked = False
513 | if overrides.get("submit_selector"):
514 | try:
515 | page.click(overrides["submit_selector"])
516 | clicked = True
517 | # زر الإرسال محدد مباشرة عبر submit_selector داخل login.overrides
518 | except Exception:
519 | # لقطة شاشة عند فشل النقر على زر الإرسال المحدد
520 | screenshot_for(page, sid, config, suffix="submit-click-error")
521 | clicked = False
522 | if not clicked:
523 | clicked = click_first_matching_button(page, submit_names)
524 | # إن لم تُحدِّد submit_selector، سيُجرّب أسماء الأزرار في القائمة مثل "Sign In" أو "Login"
525 | if not clicked:
526 | print("[Error] Failed to find the login button.")
527 | # لقطة شاشة عند عدم العثور على زر الدخول
528 | screenshot_for(page, sid, config, suffix="submit-not-found")
529 | context.close()
530 | return False
531 | # بعد النقر على زر الدخول، أعد التمرير للأعلى لتقليل الحركة المرئية
532 | if scroll_back_after_click:
533 | scroll_back_to_top(page, scroll_back_delay_ms)
534 |
535 | # انتظر تواجد واجهة الحضور أو زر الحضور
536 | check_cfg = config.get("checkin", {})
537 | check_names = check_cfg.get("button_names", ["Check-In"]) # أزرار محتملة
538 | timeout_ms = int(check_cfg.get("timeout_ms", 15000))
539 | success_selector = check_cfg.get("success_selector")
540 | # success_selector: عنصر يظهر بعد نجاح الحضور (مثل .alert-success) إن رغبت بالتحقق منه
541 |
542 | # بعد الدخول، انتظر استقرار الصفحة ثم حاول الضغط على زر الحضور
543 | try:
544 | page.wait_for_load_state("networkidle")
545 | except Exception:
546 | page.wait_for_load_state("domcontentloaded")
547 |
548 | # إن توفر محدد مباشر لزر الحضور استخدمه أولاً
549 | check_selector = check_cfg.get("selector")
550 | pressed = False
551 | did_capture_after_checkin = False
552 | if check_selector:
553 | try:
554 | page.click(check_selector)
555 | pressed = True
556 | except Exception:
557 | pressed = False
558 | if not pressed:
559 | # حاول الضغط على زر الحضور بانتظار ظهوره
560 | pressed = wait_and_click_first_matching(page, check_names, timeout_ms)
561 | if not pressed:
562 | # أحيانًا يكون زر الحضور باسم "Sign In" داخل صفحة الحضور نفسها
563 | pressed = wait_and_click_first_matching(page, ["Sign In"], timeout_ms) or pressed
564 | if not pressed:
565 | print("[Error] Failed to click the Check-In button.")
566 | screenshot_for(page, sid, config, suffix="checkin-not-found")
567 | context.close()
568 | return False
569 | # بعد النقر على زر الحضور، أعد التمرير للأعلى أيضًا
570 | if scroll_back_after_click:
571 | scroll_back_to_top(page, scroll_back_delay_ms)
572 |
573 | # التقط صورة بعد CHECK-IN فقط إن كان الخيار مفعّلًا
574 | if bool(shots_cfg.get("capture_after_checkin", True)):
575 | suffix = str(shots_cfg.get("suffix_after_checkin", "checked-in"))
576 | # انتظر الاستقرار أو تحقق النجاح قبل الالتقاط بعد النقر
577 | try:
578 | # إن توفر success_selector، انتظر ظهوره أولًا
579 | if success_selector:
580 | selectors: List[str] = []
581 | if isinstance(success_selector, list):
582 | selectors = [str(s).strip() for s in success_selector if str(s).strip()]
583 | else:
584 | selectors = [s.strip() for s in str(success_selector).split(",") if s.strip()]
585 | seen = False
586 | for sel in selectors:
587 | try:
588 | page.locator(sel).first.wait_for(timeout=timeout_ms, state="visible")
589 | seen = True
590 | break
591 | except Exception:
592 | continue
593 | # على أي حال، أخفِ مؤشرات التحميل وانتظر السكون
594 | _wait_idle_and_hide_spinners(page, shots_cfg, int(shots_cfg.get("prepared_wait_timeout_ms", 15000)))
595 | except Exception:
596 | pass
597 | shot_path = screenshot_for(page, sid, config, suffix=suffix)
598 | # بعد الالتقاط، إن كان لدى المستخدم معرف تلغرام، أرسل صورة التوثيق له
599 | try:
600 | if shot_path:
601 | _notify_user_with_photo(user, config, shot_path)
602 | except Exception:
603 | pass
604 | did_capture_after_checkin = True
605 |
606 | # انتظر نجاح إن توفر محدد
607 | if success_selector:
608 | # يدعم قائمة محددات أو سلسلة مفصولة بفواصل
609 | selectors: List[str] = []
610 | if isinstance(success_selector, list):
611 | selectors = [str(s).strip() for s in success_selector if str(s).strip()]
612 | else:
613 | selectors = [s.strip() for s in str(success_selector).split(",") if s.strip()]
614 |
615 | seen = False
616 | for sel in selectors:
617 | try:
618 | # استخدم Locator الذي يدعم أنماط Playwright مثل text=...
619 | page.locator(sel).first.wait_for(timeout=timeout_ms, state="visible")
620 | seen = True
621 | break
622 | except PlaywrightTimeout:
623 | continue
624 | except Exception:
625 | # تجاهل أخطاء التحليل لمحددات غير متوافقة وجرب الذي يليه
626 | continue
627 | if not seen:
628 | print("[Warning] Failed to verify success within timeout or invalid selectors.")
629 |
630 | # لقطة شاشة نهائية باسم المستخدم إذا لم نلتقط بعد CHECK-IN
631 | if not did_capture_after_checkin:
632 | screenshot_for(page, sid, config, suffix=None)
633 |
634 | context.close()
635 | return True
636 |
637 |
638 | def _notify_user_with_photo(user: Dict[str, Any], config: Dict[str, Any], photo_path: str) -> None:
639 | """Sends a verification photo to the user via Telegram using the Bot API.
640 | Depends on the presence of the environment variable TELEGRAM_TOKEN and the user's chat ID.
641 | """
642 | try:
643 | token = os.getenv("TELEGRAM_TOKEN")
644 | chat_id = user.get("telegram_chat_id")
645 | if not token or not chat_id:
646 | return
647 | # تحضير عنوان وتسمية
648 | sid = user.get("studentId", "")
649 | subject = str(config.get("selected_subject", "")).strip()
650 | caption = (f"Your attendance has been documented" + (f" For subject: {subject}" if subject else "") + f"\nStudent ID: {sid}")
651 |
652 | # إرسال عبر HTTP متعدد الأجزاء
653 | import urllib.request
654 | import urllib.parse
655 | import mimetypes
656 | import uuid
657 |
658 | url = f"https://api.telegram.org/bot{token}/sendPhoto"
659 | boundary = f"----WebKitFormBoundary{uuid.uuid4().hex}"
660 | parts: list[bytes] = []
661 |
662 | def add_field(name: str, value: str):
663 | parts.append(
664 | (f"--{boundary}\r\n"
665 | f"Content-Disposition: form-data; name=\"{name}\"\r\n\r\n"
666 | f"{value}\r\n").encode("utf-8")
667 | )
668 |
669 | def add_file(name: str, filename: str, content: bytes, content_type: str):
670 | header = (f"--{boundary}\r\n"
671 | f"Content-Disposition: form-data; name=\"{name}\"; filename=\"{filename}\"\r\n"
672 | f"Content-Type: {content_type}\r\n\r\n").encode("utf-8")
673 | parts.append(header)
674 | parts.append(content)
675 | parts.append(b"\r\n")
676 |
677 | add_field("chat_id", str(int(chat_id)))
678 | add_field("caption", caption)
679 | # حمّل الملف
680 | try:
681 | with open(photo_path, "rb") as f:
682 | data = f.read()
683 | except Exception:
684 | return
685 | ctype = mimetypes.guess_type(photo_path)[0] or "image/png"
686 | add_file("photo", os.path.basename(photo_path), data, ctype)
687 | parts.append((f"--{boundary}--\r\n").encode("utf-8"))
688 | body = b"".join(parts)
689 | req = urllib.request.Request(url, data=body, method="POST")
690 | req.add_header("Content-Type", f"multipart/form-data; boundary={boundary}")
691 | req.add_header("Content-Length", str(len(body)))
692 | try:
693 | with urllib.request.urlopen(req, timeout=20) as resp:
694 | _ = resp.read()
695 | except Exception:
696 | # تجاهل أي أخطاء في الإرسال حتى لا تعطل التدفق الرئيسي
697 | pass
698 | except Exception:
699 | # تجنّب إسقاط التنفيذ بسبب أي خطأ غير متوقع هنا
700 | pass
701 |
702 |
703 | def run_bot(config: Dict[str, Any]) -> None:
704 | # الرابط يؤخذ أولًا من وسيط التشغيل --url إن توفر، وإلا من config.json
705 | url = get_arg("--url", config.get("url"))
706 | if not url:
707 | print("[Error] URL not specified. Pass --url or specify it in config.json")
708 | sys.exit(1)
709 |
710 | parsed = urlparse(url)
711 | origin = f"{parsed.scheme}://{parsed.netloc}"
712 |
713 | # المستخدمون يُقرأون من config.json ضمن المفتاح "users"
714 | # مثال عنصر: { "studentId": "2023xxxxx", "password": "secret" }
715 | users: List[Dict[str, str]] = config.get("users", [])
716 | # إن كانت هناك مادة محددة (من واجهة التحضير)، صفِّ المستخدمين لتلك المادة فقط
717 | subject_filter = str(config.get("selected_subject", "")).strip()
718 | if subject_filter:
719 | try:
720 | users = [u for u in users if subject_filter in (u.get("subjects", []) or [])]
721 | except Exception:
722 | pass
723 | if not users:
724 | print("[Warning] No user list found in config. Trying once without credentials.")
725 | users = [{"studentId": "", "password": ""}]
726 |
727 | headless_env = os.getenv("HEADLESS", "0").strip()
728 | headless = headless_env in ("1", "true", "True")
729 |
730 | # عدد الجلسات المتوازية
731 | parallel = int(config.get("parallel_browsers", 0) or 0)
732 | if parallel <= 0:
733 | parallel = len(users)
734 | print(f"[Info] Launching browsers in parallel: {parallel}, Headless: {headless}")
735 |
736 | # Progress notification: percentages removed; only final message will be sent
737 |
738 | def worker(u: Dict[str, str]):
739 | # كل عامل يُنشئ Playwright ومتصفحًا خاصين به لعدم مشاركة الحالة بين الجلسات
740 | with sync_playwright() as p:
741 | browser_cfg = (config.get("browser") or {})
742 | launch_args = list(browser_cfg.get("launch_args", [])) or [
743 | "--disable-blink-features=AutomationControlled",
744 | "--no-sandbox",
745 | "--disable-dev-shm-usage",
746 | "--disable-features=IsolateOrigins,site-per-process",
747 | "--no-first-run",
748 | "--no-default-browser-check",
749 | ]
750 | browser = p.chromium.launch(headless=headless, args=launch_args)
751 | try:
752 | ok = run_for_user(p, browser, headless, url, origin, u, config)
753 | return (u.get("studentId"), ok)
754 | finally:
755 | try:
756 | browser.close()
757 | except Exception:
758 | pass
759 |
760 | results: List[tuple[str | None, bool]] = []
761 | with ThreadPoolExecutor(max_workers=parallel) as ex:
762 | future_map = {ex.submit(worker, u): u for u in users}
763 | for fut in as_completed(future_map):
764 | sid, ok = fut.result()
765 | results.append((sid, ok))
766 |
767 | # ملخص
768 | print("\n[Summary]")
769 | for sid, ok in results:
770 | print(f"- {sid}: {'Success' if ok else 'Failure'}")
771 | print("[Info] Automation process completed.")
772 | # Delete the started message (if stored), then send final completion message with checkmark
773 | try:
774 | _delete_started_message_if_any(config)
775 | subj = str(config.get("selected_subject", "")).strip() or "All"
776 | _notify_initiator(config, f"Preparation finished for '{subj}' ✅")
777 | except Exception:
778 | pass
779 |
780 | # فتح مجلد الصور بعد الانتهاء إذا كان الخيار مفعّلًا
781 | try:
782 | if bool(config.get("open_output_dir_after_run", False)):
783 | tmpl = config.get("screenshot_template", "output/{studentId}.png")
784 | try:
785 | sample = Path(tmpl.format(studentId="example"))
786 | except Exception:
787 | sample = Path("output/example.png")
788 | out_dir = sample.parent
789 | out_dir.mkdir(parents=True, exist_ok=True)
790 | print(f"[Info] Opening output directory: {out_dir}")
791 | if sys.platform.startswith("win"):
792 | os.startfile(str(out_dir)) # نوعية ويندوز
793 | elif sys.platform == "darwin":
794 | os.system(f"open \"{out_dir}\"")
795 | else:
796 | os.system(f"xdg-open \"{out_dir}\"")
797 | except Exception:
798 | pass
799 |
800 |
801 | if __name__ == "__main__":
802 | load_dotenv()
803 | cfg_path = get_arg("--config", "config/config.json")
804 | config = load_config(cfg_path)
805 | run_bot(config)
--------------------------------------------------------------------------------
/src/telegram_bot.py:
--------------------------------------------------------------------------------
1 | import os
2 | import logging
3 | import threading
4 | from typing import Any, Dict, List
5 |
6 | from pathlib import Path
7 |
8 | from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
9 | from telegram._webappinfo import WebAppInfo
10 | from telegram.ext import Application, CommandHandler, ContextTypes, CallbackQueryHandler, MessageHandler, filters
11 |
12 | # Load environment variables from .env if present
13 | try:
14 | from dotenv import load_dotenv
15 | load_dotenv()
16 | except Exception:
17 | pass
18 |
19 |
20 | # -----------------------------
21 | # Output images auto-cleaner
22 | # -----------------------------
23 | def _clean_output_once(max_age_hours: int = 6) -> int:
24 | """Delete images in ./output older than max_age_hours. Returns count deleted."""
25 | try:
26 | import time
27 | root = Path("output")
28 | if not root.exists():
29 | return 0
30 | cutoff = time.time() - max_age_hours * 3600
31 | patterns = ("*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif")
32 | deleted = 0
33 | for pat in patterns:
34 | for p in root.glob(pat):
35 | try:
36 | if p.is_file() and p.stat().st_mtime < cutoff:
37 | p.unlink(missing_ok=True)
38 | deleted += 1
39 | except Exception:
40 | pass
41 | for sub in root.glob("*/"):
42 | try:
43 | if sub.is_dir() and not any(sub.iterdir()):
44 | sub.rmdir()
45 | except Exception:
46 | pass
47 | return deleted
48 | except Exception:
49 | return 0
50 |
51 |
52 | def start_output_cleanup_daemon():
53 | """Start a background cleaner that runs every N hours.
54 |
55 | Env vars:
56 | - OUTPUT_MAX_AGE_HOURS (default: 6) — delete files older than this age
57 | - OUTPUT_CLEAN_INTERVAL_HOURS (default: same as max age) — run frequency
58 | - OUTPUT_AUTO_CLEAN (default: 1) — set to 0 to disable
59 | """
60 | if os.getenv("OUTPUT_AUTO_CLEAN", "1") not in ("1", "true", "True"):
61 | return
62 | try:
63 | max_age = int(os.getenv("OUTPUT_MAX_AGE_HOURS", "6") or 6)
64 | except Exception:
65 | max_age = 6
66 | try:
67 | interval = int(os.getenv("OUTPUT_CLEAN_INTERVAL_HOURS", str(max_age)) or max_age)
68 | except Exception:
69 | interval = max_age
70 |
71 | def _loop():
72 | import time
73 | while True:
74 | try:
75 | deleted = _clean_output_once(max_age)
76 | if deleted:
77 | print(f"[output-clean] Deleted {deleted} old image(s) from ./output")
78 | except Exception:
79 | pass
80 | time.sleep(max(1, interval) * 3600)
81 |
82 | try:
83 | t = threading.Thread(target=_loop, daemon=True)
84 | t.start()
85 | except Exception:
86 | pass
87 |
88 |
89 | status_lock = threading.Lock()
90 | run_status: Dict[str, Any] = {"state": "idle", "error": None}
91 |
92 | def _subjects_library() -> List[str]:
93 | """Read subjects library from config; seed defaults once if empty."""
94 | cfg = read_cfg()
95 | # Normalize subjects: strip whitespace and drop empties
96 | raw = list((cfg.get("subjects") or []) or [])
97 | lib = [str(s).strip() for s in raw if str(s).strip()]
98 | if not lib:
99 | # Seed initial defaults into the shared library (one-time initialization)
100 | lib = [
101 | "Engineering Statics",
102 | "Mathematical Methods for Engineering II ",
103 | "Digital Electronics",
104 | "Effective Writing",
105 | "Technical Communication",
106 | ]
107 | cfg["subjects"] = lib
108 | try:
109 | write_cfg(cfg)
110 | except Exception:
111 | pass
112 | return lib
113 |
114 | # إحداثيات تقريبية داخل الحرم؛ يمكن تعديلها لاحقًا
115 | PRESETS = {
116 | "building_g": {"latitude": 3.0783228, "longitude": 101.7328630, "accuracy": 25},
117 | "building_c": {"latitude": 3.078617, "longitude": 101.7334, "accuracy": 25},
118 | }
119 |
120 |
121 | def set_status(state: str, error: str | None = None):
122 | with status_lock:
123 | run_status["state"] = state
124 | run_status["error"] = error
125 |
126 |
127 | def read_cfg() -> Dict[str, Any]:
128 | # اقرأ الإعدادات مباشرة من JSON بدون الاعتماد على bot.py
129 | try:
130 | import json as _json
131 | cfg_path = Path("config/config.json")
132 | if not cfg_path.exists():
133 | return {}
134 | with cfg_path.open("r", encoding="utf-8") as f:
135 | return _json.load(f)
136 | except Exception:
137 | return {}
138 |
139 |
140 | def write_cfg(data: Dict[str, Any]) -> None:
141 | cfg_path = Path("config/config.json")
142 | cfg_path.parent.mkdir(parents=True, exist_ok=True)
143 | import json as _json
144 | with cfg_path.open("w", encoding="utf-8") as f:
145 | _json.dump(data, f, ensure_ascii=False, indent=2)
146 |
147 |
148 | def is_allowed(user_id: int) -> bool:
149 | raw = os.getenv("TELEGRAM_ALLOWED_IDS", "").strip()
150 | if not raw:
151 | return True
152 | try:
153 | allowed = {int(x.strip()) for x in raw.split(",") if x.strip()}
154 | except Exception:
155 | return False
156 | return user_id in allowed
157 |
158 |
159 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
160 | if not is_allowed(update.effective_user.id):
161 | await update.message.reply_text("Access denied.")
162 | return
163 | # قائمة رئيسية بدون زر فتح الواجهة
164 | kb = InlineKeyboardMarkup([
165 | [InlineKeyboardButton("New Preparation", callback_data="prep_new")],
166 | [InlineKeyboardButton("Add New User", callback_data="user_add")],
167 | [InlineKeyboardButton("Manage Users", callback_data="user_manage")],
168 | [InlineKeyboardButton("Show Last 10 Preparations", callback_data="history")],
169 | ])
170 | await update.message.reply_text(
171 | "UCSI Attendance Bot\nChoose an action:", reply_markup=kb
172 | )
173 |
174 |
175 | async def status(update: Update, context: ContextTypes.DEFAULT_TYPE):
176 | if not is_allowed(update.effective_user.id):
177 | await update.message.reply_text("Access denied.")
178 | return
179 | state = run_status.get("state")
180 | err = run_status.get("error")
181 | if err:
182 | await update.message.reply_text(f"Status: {state}\nError: {err}")
183 | else:
184 | await update.message.reply_text(f"Status: {state}")
185 |
186 |
187 | async def whoami(update: Update, context: ContextTypes.DEFAULT_TYPE):
188 | # Helper to get IDs for TELEGRAM_ALLOWED_IDS
189 | uid = update.effective_user.id if update.effective_user else None
190 | cid = update.effective_chat.id if update.effective_chat else None
191 | msg = ["Your IDs:"]
192 | if uid is not None:
193 | msg.append(f"User ID: {uid}")
194 | if cid is not None:
195 | msg.append(f"Chat ID: {cid}")
196 | await update.message.reply_text("\n".join(msg))
197 |
198 |
199 | def _run_worker(cfg: Dict[str, Any]):
200 | try:
201 | set_status("running")
202 | # استيراد كسول لتفادي فشل الإقلاع عند غياب Playwright
203 | try:
204 | from bot import run_bot # type: ignore
205 | except Exception as _imp_err:
206 | set_status("error", "Automation not available on this host (Playwright not installed).")
207 | return
208 | run_bot(cfg)
209 | set_status("done")
210 | except Exception as e: # noqa
211 | set_status("error", str(e))
212 |
213 |
214 | async def run(update: Update, context: ContextTypes.DEFAULT_TYPE):
215 | if not is_allowed(update.effective_user.id):
216 | await update.message.reply_text("Access denied.")
217 | return
218 | if run_status.get("state") == "running":
219 | await update.message.reply_text("Already running.")
220 | return
221 | # تحقّق مبكر إن كانت الأتمتة متاحة على هذا المضيف
222 | try:
223 | from bot import run_bot # noqa: F401
224 | except Exception:
225 | await update.message.reply_text("الأتمتة غير متاحة على هذا المضيف (Playwright غير مثبت). شغّلها محليًا أو أنشئ Worker منفصل للأتمتة.")
226 | return
227 | cfg = read_cfg()
228 | # Respect HEADLESS env
229 | os.environ["HEADLESS"] = os.getenv("HEADLESS", "1")
230 | t = threading.Thread(target=_run_worker, args=(cfg,), daemon=True)
231 | t.start()
232 | await update.message.reply_text("Started. Use /status to check progress.")
233 |
234 |
235 | async def on_button(update: Update, context: ContextTypes.DEFAULT_TYPE):
236 | q = update.callback_query
237 | if not q:
238 | return
239 | uid = q.from_user.id if q.from_user else None
240 | if uid is None or not is_allowed(uid):
241 | await q.answer("Access denied.", show_alert=True)
242 | return
243 | data = q.data or ""
244 | if data == "run":
245 | if run_status.get("state") == "running":
246 | await q.answer("Already running.", show_alert=False)
247 | return
248 | try:
249 | from bot import run_bot # noqa: F401
250 | except Exception:
251 | await q.answer("Automation not available on this host.", show_alert=True)
252 | await q.message.reply_text("الأتمتة غير متاحة على هذا المضيف (Playwright غير مثبت). شغّلها محليًا أو ضمن Worker منفصل.")
253 | return
254 | cfg = read_cfg()
255 | os.environ["HEADLESS"] = os.getenv("HEADLESS", "1")
256 | t = threading.Thread(target=_run_worker, args=(cfg,), daemon=True)
257 | t.start()
258 | await q.answer("Started.")
259 | await q.message.reply_text("Started. Use /status to check progress.")
260 | elif data == "status":
261 | state = run_status.get("state")
262 | err = run_status.get("error")
263 | await q.answer("Done.")
264 | if err:
265 | await q.message.reply_text(f"Status: {state}\nError: {err}")
266 | else:
267 | await q.message.reply_text(f"Status: {state}")
268 | elif data == "prep_new":
269 | # ابدأ معالج التحضير الجديد
270 | prep = context.user_data.get("prep") or {"url": None, "subject": None, "location": "custom"}
271 | context.user_data["prep"] = prep
272 | await _show_prep_menu(q, context)
273 | elif data == "prep_subject_menu":
274 | # عرض قائمة المواد في رسالة منفصلة
275 | await _send_subject_menu(q, context)
276 | elif data.startswith("prep_subject:"):
277 | subj = data.split(":", 1)[1]
278 | context.user_data.setdefault("prep", {})["subject"] = subj
279 | await _show_prep_menu(q, context)
280 | elif data == "prep_back":
281 | # Back to preparation menu
282 | await _show_prep_menu(q, context)
283 | elif data.startswith("prep_loc:"):
284 | loc = data.split(":", 1)[1]
285 | if loc == "custom":
286 | context.user_data.setdefault("prep", {})["location"] = "custom"
287 | context.user_data["awaiting"] = "custom_loc"
288 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="prep_back")]])
289 | await _replace_message(q, context, "Enter coordinates as: lat, lon, acc\nExample: 3.0796, 101.7332, 25", kb)
290 | elif loc in ("building_g", "building_c"):
291 | context.user_data.setdefault("prep", {})["location"] = loc
292 | await _show_prep_menu(q, context)
293 | else:
294 | await _show_prep_menu(q, context)
295 | elif data == "prep_url":
296 | context.user_data["awaiting"] = "prep_url"
297 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="prep_back")]])
298 | await _replace_message(q, context, "Send the new preparation URL now:", kb)
299 | elif data == "prep_start":
300 | await _start_preparation(q, context)
301 | elif data == "prep_cancel":
302 | # Reset to defaults when cancelling/back from preparation menu
303 | context.user_data["prep"] = {"url": None, "subject": None, "location": "custom"}
304 | context.user_data["awaiting"] = None
305 | await _show_main_menu(q, context)
306 | elif data == "user_add":
307 | context.user_data["awaiting"] = "add_user_username"
308 | # ابدأ تجميع بيانات المستخدم الجديد واحفظ معرف التلغرام الخاص به
309 | nu = {"telegram_chat_id": int(update.effective_chat.id)}
310 | context.user_data["new_user"] = nu
311 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
312 | # أرسل الرسالة التمهيدية التي تحتوي على الحقول المطلوبة ثم ابدأ بجمع البيانات
313 | await _update_summary_message(update, context)
314 | m = await q.message.reply_text("Send the username:", reply_markup=kb)
315 | context.user_data["last_prompt_msg_id"] = m.message_id
316 | elif data == "user_manage":
317 | context.user_data["awaiting"] = "admin_user"
318 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
319 | await q.edit_message_text("Send the admin username:", reply_markup=kb)
320 | elif data == "history":
321 | await _show_history(q)
322 | elif data in ("m_add", "m_update", "m_delete", "m_list"):
323 | await _on_manage_button(update, context, data)
324 | elif data == "back_main":
325 | await _show_main_menu(q, context)
326 |
327 |
328 | async def seturl(update: Update, context: ContextTypes.DEFAULT_TYPE):
329 | if not is_allowed(update.effective_user.id):
330 | await update.message.reply_text("Access denied.")
331 | return
332 | args = context.args
333 | if not args:
334 | await update.message.reply_text("Usage: /seturl ")
335 | return
336 | url = args[0]
337 | cfg = read_cfg()
338 | cfg["url"] = url
339 | write_cfg(cfg)
340 | await update.message.reply_text("URL updated.")
341 |
342 |
343 | async def setheadless(update: Update, context: ContextTypes.DEFAULT_TYPE):
344 | if not is_allowed(update.effective_user.id):
345 | await update.message.reply_text("Access denied.")
346 | return
347 | args = context.args
348 | if not args or args[0] not in ("0", "1"):
349 | await update.message.reply_text("Usage: /setheadless <0|1>")
350 | return
351 | os.environ["HEADLESS"] = args[0]
352 | await update.message.reply_text(f"HEADLESS set to {args[0]}.")
353 |
354 |
355 | def main():
356 | logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s")
357 | token = os.getenv("TELEGRAM_TOKEN")
358 | if not token:
359 | raise RuntimeError("TELEGRAM_TOKEN not set in environment.")
360 | # Launch output cleaner in the background
361 | start_output_cleanup_daemon()
362 | app = Application.builder().token(token).build()
363 | logging.info("Starting Telegram bot polling...")
364 | app.add_handler(CommandHandler("start", start))
365 | app.add_handler(CommandHandler("run", run))
366 | app.add_handler(CommandHandler("status", status))
367 | app.add_handler(CommandHandler("seturl", seturl))
368 | app.add_handler(CommandHandler("setheadless", setheadless))
369 | app.add_handler(CommandHandler("whoami", whoami))
370 | # Handle subject selection callbacks first
371 | app.add_handler(CallbackQueryHandler(on_subject_toggle, pattern=r"^sub_"))
372 | app.add_handler(CallbackQueryHandler(on_button))
373 | app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text))
374 | app.run_polling(allowed_updates=Update.ALL_TYPES)
375 |
376 | # -------------------------
377 | # -------------------------
378 | # Submenu helpers
379 | # -------------------------
380 |
381 | def _checkmark(selected: bool, label: str) -> str:
382 | return ("✅ " + label) if selected else ("☑️ " + label)
383 |
384 |
385 | async def _show_main_menu(q, context):
386 | kb = InlineKeyboardMarkup([
387 | [InlineKeyboardButton("New Preparation", callback_data="prep_new")],
388 | [InlineKeyboardButton("Add New User", callback_data="user_add")],
389 | [InlineKeyboardButton("Manage Users", callback_data="user_manage")],
390 | [InlineKeyboardButton("Show Last 10 Preparations", callback_data="history")],
391 | ])
392 | await _replace_message(q, context, "UCSI Attendance Bot\nChoose an action:", kb)
393 |
394 |
395 | async def _send_main_menu_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
396 | kb = InlineKeyboardMarkup([
397 | [InlineKeyboardButton("New Preparation", callback_data="prep_new")],
398 | [InlineKeyboardButton("Add New User", callback_data="user_add")],
399 | [InlineKeyboardButton("Manage Users", callback_data="user_manage")],
400 | [InlineKeyboardButton("Show Last 10 Preparations", callback_data="history")],
401 | ])
402 | await _send_new_menu_after_text(update, context, "UCSI Attendance Bot\nChoose an action:", kb)
403 |
404 |
405 | async def _show_prep_menu(q, context):
406 | prep = context.user_data.get("prep") or {}
407 | url = prep.get("url")
408 | subject = prep.get("subject")
409 | loc = prep.get("location", "custom")
410 |
411 | url_label = (url or "— Not set —")
412 | subj_label = (subject or "— Not selected —")
413 |
414 | rows = [
415 | [InlineKeyboardButton(f"Add Preparation URL\n{url_label}", callback_data="prep_url")],
416 | [InlineKeyboardButton(f"Choose Subject\n{subj_label}", callback_data="prep_subject_menu")],
417 | [
418 | InlineKeyboardButton(_checkmark(loc == "custom", "Custom Location"), callback_data="prep_loc:custom"),
419 | InlineKeyboardButton(_checkmark(loc == "building_g", "Block G"), callback_data="prep_loc:building_g"),
420 | InlineKeyboardButton(_checkmark(loc == "building_c", "Block C"), callback_data="prep_loc:building_c"),
421 | ],
422 | [InlineKeyboardButton("Start Preparation / Run All", callback_data="prep_start")],
423 | [InlineKeyboardButton("Cancel / Back", callback_data="prep_cancel")],
424 | ]
425 | kb = InlineKeyboardMarkup(rows)
426 | await _replace_message(q, context, "New Preparation Setup:", kb)
427 |
428 |
429 | async def _send_prep_menu_message(update: Update, context):
430 | # نسخة لإرسال قائمة جديدة بدل تحرير السابقة
431 | prep = context.user_data.get("prep") or {}
432 | url = prep.get("url")
433 | subject = prep.get("subject")
434 | loc = prep.get("location", "custom")
435 | url_label = (url or "— Not set —")
436 | subj_label = (subject or "— Not selected —")
437 | rows = [
438 | [InlineKeyboardButton(f"Add Preparation URL\n{url_label}", callback_data="prep_url")],
439 | [InlineKeyboardButton(f"Choose Subject\n{subj_label}", callback_data="prep_subject_menu")],
440 | [
441 | InlineKeyboardButton(_checkmark(loc == "custom", "Custom Location"), callback_data="prep_loc:custom"),
442 | InlineKeyboardButton(_checkmark(loc == "building_g", "Block G"), callback_data="prep_loc:building_g"),
443 | InlineKeyboardButton(_checkmark(loc == "building_c", "Block C"), callback_data="prep_loc:building_c"),
444 | ],
445 | [InlineKeyboardButton("Start Preparation / Run All", callback_data="prep_start")],
446 | [InlineKeyboardButton("Cancel / Back", callback_data="prep_cancel")],
447 | ]
448 | kb = InlineKeyboardMarkup(rows)
449 | await _send_new_menu_after_text(update, context, "New Preparation Setup:", kb)
450 |
451 |
452 | async def _send_subject_menu(q, context):
453 | # قائمة لاختيار المادة من مكتبة مشتركة
454 | subs = _subjects_library()
455 | rows = [[InlineKeyboardButton("📘 " + s, callback_data=f"prep_subject:{s}")] for s in subs]
456 | rows.append([InlineKeyboardButton("Back", callback_data="prep_back")])
457 | kb = InlineKeyboardMarkup(rows)
458 | await _replace_message(q, context, "Select Subject:", kb)
459 |
460 | async def _replace_message(q, context, text: str, reply_markup=None):
461 | try:
462 | await context.bot.delete_message(chat_id=q.message.chat_id, message_id=q.message.message_id)
463 | except Exception:
464 | pass
465 | new_msg = await context.bot.send_message(chat_id=q.message.chat_id, text=text, reply_markup=reply_markup)
466 | context.user_data["last_bot_msg_id"] = new_msg.message_id
467 |
468 | async def _send_new_menu_after_text(update: Update, context, text: str, reply_markup=None):
469 | chat_id = update.effective_chat.id
470 | last_id = context.user_data.get("last_bot_msg_id")
471 | if last_id:
472 | try:
473 | await context.bot.delete_message(chat_id=chat_id, message_id=last_id)
474 | except Exception:
475 | pass
476 | new_msg = await context.bot.send_message(chat_id=chat_id, text=text, reply_markup=reply_markup)
477 | context.user_data["last_bot_msg_id"] = new_msg.message_id
478 |
479 | async def _update_summary_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
480 | """Create or update a registration summary message with entered fields."""
481 | nu = context.user_data.get("new_user", {})
482 | subs = list(context.user_data.get("new_user_subjects", set()))
483 | lines = [
484 | "Please provide the following fields (all in English):",
485 | "1) Username",
486 | "2) Phone",
487 | "3) Student ID",
488 | "4) Password",
489 | "5) Courses",
490 | "",
491 | "Registration Info:",
492 | f"- Username: {nu.get('username','—')}",
493 | f"- Phone: {nu.get('phone','—')}",
494 | f"- Student ID: {nu.get('studentId','—')}",
495 | f"- Password: {nu.get('password','—')}",
496 | f"- Courses: {(', '.join(subs) if subs else '—')}",
497 | ]
498 | msg_id = context.user_data.get("summary_msg_id")
499 | chat_id = update.effective_chat.id
500 | text = "\n".join(lines)
501 | try:
502 | if msg_id:
503 | await context.bot.edit_message_text(chat_id=chat_id, message_id=msg_id, text=text)
504 | else:
505 | m = await context.bot.send_message(chat_id=chat_id, text=text)
506 | context.user_data["summary_msg_id"] = m.message_id
507 | except Exception:
508 | m = await context.bot.send_message(chat_id=chat_id, text=text)
509 | context.user_data["summary_msg_id"] = m.message_id
510 |
511 | async def _send_subjects_multiselect(update: Update, context: ContextTypes.DEFAULT_TYPE):
512 | subs = _subjects_library()
513 | selected = set(context.user_data.get("new_user_subjects", set()))
514 | rows: List[List[InlineKeyboardButton]] = []
515 | for s in subs:
516 | label = ("✅ " + s) if s in selected else ("☑️ " + s)
517 | rows.append([InlineKeyboardButton(label, callback_data=f"sub_toggle:{s}")])
518 | rows.append([InlineKeyboardButton("Confirm ✅📚", callback_data="sub_confirm")])
519 | await update.message.reply_text("Select courses (toggle) then confirm:", reply_markup=InlineKeyboardMarkup(rows))
520 |
521 |
522 | async def on_subject_toggle(update: Update, context: ContextTypes.DEFAULT_TYPE):
523 | q = update.callback_query
524 | if not q:
525 | return
526 | uid = q.from_user.id if q.from_user else None
527 | if uid is None or not is_allowed(uid):
528 | await q.answer("Access denied.", show_alert=True)
529 | return
530 | data = q.data or ""
531 | if data.startswith("sub_toggle:"):
532 | s = data.split(":", 1)[1]
533 | cur = set(context.user_data.get("new_user_subjects", set()))
534 | if s in cur:
535 | cur.remove(s)
536 | else:
537 | cur.add(s)
538 | context.user_data["new_user_subjects"] = cur
539 | await _update_summary_message(update, context)
540 | # redraw keyboard
541 | subs = _subjects_library()
542 | rows: List[List[InlineKeyboardButton]] = []
543 | for ss in subs:
544 | label = ("✅ " + ss) if ss in cur else ("☑️ " + ss)
545 | rows.append([InlineKeyboardButton(label, callback_data=f"sub_toggle:{ss}")])
546 | rows.append([InlineKeyboardButton("Confirm ✅📚", callback_data="sub_confirm")])
547 | try:
548 | await q.edit_message_reply_markup(reply_markup=InlineKeyboardMarkup(rows))
549 | except Exception:
550 | await q.message.reply_text("Updated selection.", reply_markup=InlineKeyboardMarkup(rows))
551 | await q.answer()
552 | elif data == "sub_confirm":
553 | nu = dict(context.user_data.get("new_user", {}))
554 | subjects = list(context.user_data.get("new_user_subjects", set()))
555 | cfg = read_cfg()
556 | users: List[Dict[str, Any]] = list((cfg.get("users", []) or []))
557 | pendings: List[Dict[str, Any]] = list((cfg.get("pending_users", []) or []))
558 | sid_val = nu.get("studentId")
559 | if any(u.get("studentId") == sid_val for u in users) or any(p.get("studentId") == sid_val for p in pendings):
560 | await _replace_message(q, context, "Student ID already exists (in users or pending).", None)
561 | else:
562 | nu.setdefault("subjects", subjects)
563 | pendings.append(nu)
564 | cfg["pending_users"] = pendings
565 | write_cfg(cfg)
566 | await _replace_message(q, context, "User request submitted. Awaiting admin approval.", None)
567 | context.user_data["awaiting"] = None
568 | await _show_main_menu(q, context)
569 | await q.answer()
570 |
571 |
572 | async def _start_preparation(q, context):
573 | prep = context.user_data.get("prep") or {}
574 | url = (prep.get("url") or "").strip()
575 | subject = (prep.get("subject") or "").strip()
576 | location = prep.get("location", "custom")
577 | if not url or not subject:
578 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="prep_back")]])
579 | await _replace_message(q, context, "Please set URL and subject first.", kb)
580 | return
581 | cfg = read_cfg()
582 | # Save initiator chat id to send progress updates only to the creator
583 | try:
584 | chat_id = int(q.message.chat.id)
585 | except Exception:
586 | chat_id = None
587 | notify_cfg = dict((cfg.get("notify") or {}))
588 | if chat_id:
589 | notify_cfg["initiator_chat_id"] = chat_id
590 | # Remove milestones: only final completion notification is used
591 | notify_cfg["milestones"] = []
592 | cfg["notify"] = notify_cfg
593 | # حدّث الرابط والموقع
594 | cfg["url"] = url
595 | # خزّن المادة المحددة ليتم تصفية المستخدمين بناءً عليها أثناء التحضير
596 | cfg["selected_subject"] = subject
597 | if location == "custom":
598 | custom_geo = (prep.get("custom_geo") or {})
599 | try:
600 | lat = float(custom_geo.get("latitude"))
601 | lon = float(custom_geo.get("longitude"))
602 | acc = int(float(custom_geo.get("accuracy", 25)))
603 | except Exception:
604 | lat = PRESETS.get("building_g", {}).get("latitude")
605 | lon = PRESETS.get("building_g", {}).get("longitude")
606 | acc = int(PRESETS.get("building_g", {}).get("accuracy", 25))
607 | cfg["geolocation"] = {
608 | "source": "fixed",
609 | "latitude": lat,
610 | "longitude": lon,
611 | "accuracy": acc,
612 | }
613 | elif location in ("building_g", "building_c"):
614 | preset = PRESETS.get(location) or {"latitude": 3.079548, "longitude": 101.733216, "accuracy": 50}
615 | cfg["geolocation"] = {
616 | "source": "fixed",
617 | "latitude": preset.get("latitude"),
618 | "longitude": preset.get("longitude"),
619 | "accuracy": int(preset.get("accuracy", 50)),
620 | }
621 | elif location == "ip":
622 | cfg["geolocation"] = {"source": "ip", "accuracy": 50}
623 | write_cfg(cfg)
624 | # Run in background thread same as /run
625 | if run_status.get("state") == "running":
626 | await _replace_message(q, context, "Already running.", None)
627 | return
628 | # تأكد أن الأتمتة متاحة قبل البدء
629 | try:
630 | from bot import run_bot # noqa: F401
631 | except Exception:
632 | await _replace_message(q, context, "الأتمتة غير متاحة على هذا المضيف (Playwright غير مثبت).", None)
633 | return
634 | os.environ["HEADLESS"] = os.getenv("HEADLESS", "1")
635 | t = threading.Thread(target=_run_worker, args=(cfg,), daemon=True)
636 | t.start()
637 | # سجّل في التاريخ
638 | try:
639 | from datetime import datetime
640 | cfg2 = read_cfg()
641 | hist = list(cfg2.get("history", []) or [])
642 | hist.append({"subject": subject, "timestamp": datetime.now().isoformat(timespec="seconds"), "url": url})
643 | cfg2["history"] = hist[-50:]
644 | write_cfg(cfg2)
645 | except Exception:
646 | pass
647 | await _replace_message(q, context, "Started. Just wait a few seconds..", None)
648 | # Store the started message id and chat id so the runner can delete it on completion
649 | try:
650 | started_id = context.user_data.get("last_bot_msg_id")
651 | started_chat = q.message.chat.id
652 | if started_id:
653 | cfg3 = read_cfg()
654 | notify3 = dict((cfg3.get("notify") or {}))
655 | notify3["started_message_id"] = int(started_id)
656 | notify3["started_message_chat_id"] = int(started_chat)
657 | cfg3["notify"] = notify3
658 | write_cfg(cfg3)
659 | except Exception:
660 | pass
661 |
662 |
663 | async def _show_history(q):
664 | cfg = read_cfg()
665 | hist = list(cfg.get("history", []) or [])
666 | last10 = hist[-10:]
667 | if not last10:
668 | await q.edit_message_text("No attendance records yet.")
669 | return
670 | lines = [f"- {h.get('subject','?')} — {h.get('timestamp','?')}" for h in last10]
671 | await q.edit_message_text("Last 10 attendances:\n" + "\n".join(lines))
672 |
673 |
674 | async def _on_manage_button(update: Update, context: ContextTypes.DEFAULT_TYPE, code: str):
675 | # يفعّل حقول الإدخال المطلوبة لكل عملية
676 | if code == "m_add":
677 | context.user_data["awaiting"] = "m_add_sid"
678 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
679 | await update.effective_message.reply_text("[Add] Send Student ID:", reply_markup=kb)
680 | elif code == "m_update":
681 | context.user_data["awaiting"] = "m_update_sid"
682 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
683 | await update.effective_message.reply_text("[Update] Send Student ID:", reply_markup=kb)
684 | elif code == "m_delete":
685 | context.user_data["awaiting"] = "m_delete_sid"
686 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
687 | await update.effective_message.reply_text("[Delete] Send Student ID:", reply_markup=kb)
688 | elif code == "m_list":
689 | users = list((read_cfg().get("users", []) or []))
690 | if not users:
691 | await update.effective_message.reply_text("No users found.")
692 | else:
693 | msg = "Users list:\n" + "\n".join([f"{u.get('studentId','')} | {u.get('password','')} | {u.get('username','')} | {u.get('phone','')}" for u in users])
694 | await update.effective_message.reply_text(msg)
695 | await _send_main_menu_message(update, context)
696 |
697 |
698 | async def on_text(update: Update, context: ContextTypes.DEFAULT_TYPE):
699 | # يُستخدم لالتقاط المدخلات النصية أثناء التدفق
700 | if not is_allowed(update.effective_user.id):
701 | return
702 | txt = (update.message.text or "").strip()
703 | awaiting = context.user_data.get("awaiting")
704 | if awaiting == "prep_url":
705 | context.user_data.setdefault("prep", {})["url"] = txt
706 | context.user_data["awaiting"] = None
707 | await update.message.reply_text("URL saved.")
708 | await _send_prep_menu_message(update, context)
709 | return
710 | if awaiting == "custom_loc":
711 | # توقع إدخالًا بصيغة: lat, lon, acc أو lat lon acc
712 | parts = [p.strip() for p in txt.replace(",", " ").split() if p.strip()]
713 | if len(parts) < 2:
714 | await update.message.reply_text("Invalid format. Send in the format: lat, lon, acc")
715 | return
716 | try:
717 | lat = float(parts[0])
718 | lon = float(parts[1])
719 | acc = int(float(parts[2])) if len(parts) >= 3 else 25
720 | except Exception:
721 | await update.message.reply_text("Invalid format. Send in the format: lat, lon, acc")
722 | return
723 | prep = context.user_data.setdefault("prep", {})
724 | prep["custom_geo"] = {"latitude": lat, "longitude": lon, "accuracy": acc}
725 | prep["location"] = "custom"
726 | context.user_data["awaiting"] = None
727 | await update.message.reply_text("Coordinates saved.")
728 | await _send_prep_menu_message(update, context)
729 | return
730 | # إضافة مستخدم جديد
731 | if awaiting == "add_user_username":
732 | nu = context.user_data.get("new_user", {})
733 | nu["username"] = txt
734 | context.user_data["new_user"] = nu
735 | # احذف رسالة المطالبة السابقة
736 | try:
737 | msg_id = context.user_data.get("last_prompt_msg_id")
738 | if msg_id:
739 | await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=msg_id)
740 | except Exception:
741 | pass
742 | await _update_summary_message(update, context)
743 | context.user_data["awaiting"] = "add_user_phone"
744 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
745 | m = await update.message.reply_text("Send the phone number:", reply_markup=kb)
746 | context.user_data["last_prompt_msg_id"] = m.message_id
747 | return
748 | if awaiting == "add_user_phone":
749 | nu = context.user_data.get("new_user", {})
750 | nu["phone"] = txt
751 | context.user_data["new_user"] = nu
752 | # احذف رسالة المطالبة السابقة
753 | try:
754 | msg_id = context.user_data.get("last_prompt_msg_id")
755 | if msg_id:
756 | await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=msg_id)
757 | except Exception:
758 | pass
759 | await _update_summary_message(update, context)
760 | context.user_data["awaiting"] = "add_user_sid"
761 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
762 | m = await update.message.reply_text("Send the student ID:", reply_markup=kb)
763 | context.user_data["last_prompt_msg_id"] = m.message_id
764 | return
765 | if awaiting == "add_user_sid":
766 | nu = context.user_data.get("new_user", {})
767 | nu["studentId"] = txt
768 | context.user_data["new_user"] = nu
769 | # احذف رسالة المطالبة السابقة
770 | try:
771 | msg_id = context.user_data.get("last_prompt_msg_id")
772 | if msg_id:
773 | await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=msg_id)
774 | except Exception:
775 | pass
776 | await _update_summary_message(update, context)
777 | context.user_data["awaiting"] = "add_user_pwd"
778 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
779 | m = await update.message.reply_text("Send the password:", reply_markup=kb)
780 | context.user_data["last_prompt_msg_id"] = m.message_id
781 | return
782 | if awaiting == "add_user_pwd":
783 | nu = context.user_data.get("new_user", {})
784 | nu["password"] = txt
785 | context.user_data["new_user"] = nu
786 | # Show subjects multi-select instead of immediate submit
787 | context.user_data["awaiting"] = "add_user_subjects"
788 | context.user_data["new_user_subjects"] = set()
789 | # احذف رسالة المطالبة السابقة
790 | try:
791 | msg_id = context.user_data.get("last_prompt_msg_id")
792 | if msg_id:
793 | await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=msg_id)
794 | except Exception:
795 | pass
796 | await _update_summary_message(update, context)
797 | await _send_subjects_multiselect(update, context)
798 | return
799 | # إدارة المستخدمين
800 | if awaiting == "admin_user":
801 | context.user_data["_admin_user"] = txt
802 | context.user_data["awaiting"] = "admin_pwd"
803 | kb = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data="back_main")]])
804 | await update.message.reply_text("Send the admin password:", reply_markup=kb)
805 | return
806 | if awaiting == "admin_pwd":
807 | admin_user = str(context.user_data.get("_admin_user", ""))
808 | admin_pwd = txt
809 | if admin_user == "1002476196" and admin_pwd == "Ahmad@2006":
810 | context.user_data["awaiting"] = None
811 | import os as _os
812 | web_url = _os.getenv("WEB_APP_URL", "http://127.0.0.1:5000/manage")
813 | await update.message.reply_text("Admin privileges granted. Choose an action:")
814 | # Telegram requires HTTPS for WebApp. Fallback to opening external link when not HTTPS.
815 | if str(web_url).lower().startswith("https://"):
816 | btn = InlineKeyboardButton("Open Admin Panel", web_app=WebAppInfo(web_url))
817 | else:
818 | btn = InlineKeyboardButton("Open Admin Panel (opens browser)", url=web_url)
819 | kb = InlineKeyboardMarkup([
820 | [btn],
821 | [InlineKeyboardButton("Back", callback_data="back_main")],
822 | ])
823 | await update.message.reply_text("Admin Panel:", reply_markup=kb)
824 | else:
825 | context.user_data["awaiting"] = None
826 | await update.message.reply_text("Invalid admin credentials.")
827 | await _send_main_menu_message(update, context)
828 | return
829 | if awaiting == "m_add_sid":
830 | context.user_data["m_sid"] = txt
831 | context.user_data["awaiting"] = "m_add_pwd"
832 | await update.message.reply_text("Send the password:")
833 | return
834 | if awaiting == "m_add_pwd":
835 | sid = str(context.user_data.get("m_sid", ""))
836 | pwd = txt
837 | cfg = read_cfg()
838 | users = list(cfg.get("users", []) or [])
839 | if any(u.get("studentId") == sid for u in users):
840 | await update.message.reply_text("Student ID already exists.")
841 | else:
842 | users.append({"studentId": sid, "password": pwd})
843 | cfg["users"] = users
844 | write_cfg(cfg)
845 | await update.message.reply_text("Added.")
846 | context.user_data["awaiting"] = None
847 | await _send_main_menu_message(update, context)
848 | return
849 | if awaiting == "m_update_sid":
850 | context.user_data["m_sid"] = txt
851 | context.user_data["awaiting"] = "m_update_pwd"
852 | await update.message.reply_text("Send the new password:")
853 | return
854 | if awaiting == "m_update_pwd":
855 | sid = str(context.user_data.get("m_sid", ""))
856 | pwd = txt
857 | cfg = read_cfg()
858 | users = list(cfg.get("users", []) or [])
859 | updated = False
860 | for u in users:
861 | if u.get("studentId") == sid:
862 | u["password"] = pwd
863 | updated = True
864 | break
865 | if updated:
866 | cfg["users"] = users
867 | write_cfg(cfg)
868 | await update.message.reply_text("Updated.")
869 | else:
870 | await update.message.reply_text("Student ID not found.")
871 | context.user_data["awaiting"] = None
872 | await _send_main_menu_message(update, context)
873 | return
874 | if awaiting == "m_delete_sid":
875 | sid = txt
876 | cfg = read_cfg()
877 | users = [u for u in (cfg.get("users", []) or []) if u.get("studentId") != sid]
878 | cfg["users"] = users
879 | write_cfg(cfg)
880 | await update.message.reply_text("Deleted.")
881 | context.user_data["awaiting"] = None
882 | await _send_main_menu_message(update, context)
883 | return
884 |
885 | if __name__ == "__main__":
886 | main()
--------------------------------------------------------------------------------