├── 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 |
25 |
26 |
27 |
28 | 29 | 30 |
31 |
32 | 33 | 34 |
35 |
36 |
37 |
38 | 39 | 40 |
41 |
42 | 43 | 44 |
45 |
46 |
47 | 48 | Cancel 49 |
50 |
51 |
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 |
27 |
Link & Settings
28 |
29 |
30 |
31 | 32 | 33 |
34 |
35 | 36 | 41 |
42 |
43 | 44 |
45 | 46 |
47 | 48 | 49 | 50 | 51 |
52 |
53 | 54 |
55 |
56 | 57 | 58 |
59 |
60 | 61 | 62 |
63 |
64 | 65 | 66 |
67 |
68 | 69 |
70 |
71 | 72 |
73 | 74 |
75 |
76 |
77 | 78 | 79 |
80 |
81 | 82 | 83 |
84 |
85 | 86 |
87 | 88 | Reset 89 |
90 |
91 |
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 |
28 |
Admin Login
29 |
30 |
31 |
32 | 33 | 34 |
35 |
36 | 37 | 38 |
39 |
40 |
41 | 42 |
43 |
44 |
45 |

Manage student data (locked)

46 | {% else %} 47 | 52 | 53 | {% if view == 'req' %} 54 |
55 |
Account Requests (Pending)
56 | {% if not pendings %} 57 |

No pending requests.

58 | {% else %} 59 | 60 | 61 | 62 | 63 | 64 | {% for p in pendings %} 65 | 66 | 67 | 68 | 69 | 70 | 80 | 81 | {% endfor %} 82 | 83 |
Student IDPasswordNamePhoneActions
{{ p.get('studentId','') }}{{ p.get('password','') }}{{ p.get('username','') }}{{ p.get('phone','') }} 71 |
72 | 73 | 74 |
75 |
76 | 77 | 78 |
79 |
84 | {% endif %} 85 |
86 | {% else %} 87 |
88 |
Registered Users
89 | 90 | 91 | 92 | 93 | 94 | {% for u in users %} 95 | {% set subs = u.get('subjects', []) or [] %} 96 | 97 | 98 | 99 | 100 | 101 | 102 | 138 | 139 | {% endfor %} 140 | 141 |
Student IDPasswordNamePhoneSubjectsActions
{{ u.get('studentId','') }}{{ u.get('password','') }}{{ u.get('username','') }}{{ u.get('phone','') }}{{ subs | join(', ') if subs else '—' }} 103 | 104 |
105 | 106 | 107 | 108 | 109 | 110 |
111 | 112 |
113 | 114 | 115 |
116 | 117 |
118 | 119 | 128 | 129 |
130 | {% for s in subs %} 131 |
132 | 133 | 134 | 135 |
136 | {% endfor %} 137 |
142 |
143 | {% endif %} 144 | {% endif %} 145 |
146 | 147 | {% if authed and view == 'users' %} 148 |
149 |
150 |
Subjects Library
151 |
152 | 153 | 154 |
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() --------------------------------------------------------------------------------