diff --git a/.gitea/EDU_HANDOFF.md b/.gitea/EDU_HANDOFF.md new file mode 100644 index 0000000..1571ea1 --- /dev/null +++ b/.gitea/EDU_HANDOFF.md @@ -0,0 +1,55 @@ +# EDU ownership handoff + +## Review findings + +The current `main` branch can verify and roll back changed workloads across the cluster. This is unsafe when an external repository owns an application. Open PR #99 already changes this behavior to use selected workload references and immutable releases. This change is based on PR #99 branch `codex/ci-visible-checks` and keeps its protected check names: + +- `ci / Compose*` +- `ci / Workflows*` +- `ci / Shell*` +- `ci / Formatting*` +- `ci / Python and tests*` +- `ci / YAML*` +- `ci / Dockerfiles*` +- `ci / Kubernetes*` + +Open PR #100 adds service metrics. It is independent and is not required for this handoff. + +Live Gitea 28.0.0 supports dynamic job outputs, matrices, and `max-parallel`. The runner has 1 CPU, 1.7 GiB RAM, and 6 GiB free disk. Its capacity details could not be verified because the diagnostic required a sudo password. Runner concurrency capacity remains unverified. + +The workstation checkout `/srv/edu-master` exists, is clean at `7ed537f`, and uses the SSH remote `gitssh.forust.xyz:2221`. In Kubernetes context `Default`, namespace `edu-master`, the `session-keeper` and `webinar-checker` workloads are Ready. Their health and live endpoints return 200. Their running image digests match the digests in the old homelab configuration. The Redis PVC `redis-data-pvc` is bound to PV `pvc-a4f2a79a-363a-4c12-ae91-92cdfc2a0d2e`; its reclaim policy is `Delete`. Never delete, recreate, or apply this PVC. + +The EDU repository still needs its live `playwright-service` manifest and reconciled auto-reloader annotations. Those changes, plus backup and monitoring verification improvements, are in the separate EDU branch `fix/handoff-runtime`. + +## Implementation decisions + +The homelab change removes the complete `edu_master` subtree, EDU build and deploy selection, image pinning, rollback and verification cases, route probes, related tests, and Renovate references. It removes the external EDU image bypass from generic image scans. Application source and release ownership: [forust/edu-master](https://git.forust.xyz/forust/edu-master). + +The image plan compares fingerprints for all three homelab images with the last successful CI release. Each matrix job builds its image or reuses the matching immutable digest. The matrix runs one job at a time and continues after a job failure so each image has a visible result. The final build job waits for all image jobs, checks the commit, inputs, and digests, applies the full-SHA tags, and writes the existing release artifact format. This preserves the deploy gate. A manifest-only change still runs three reuse jobs and the final tag stage. Pull requests do not publish images. + +Retain the protected check names from PR #99. Keep CI and deploy separate. `AUTODEPLOY=false` is configured explicitly. The EDU main-push deploy policy is independent of this setting. + +## Dependencies and rollout order + +1. Merge PR #99 first, because this change uses its selected-workload and immutable-release behavior. PR #100 is not a dependency. +2. Complete the EDU runtime reconciliation on the EDU feature branch. Do not merge EDU into `main` yet. Configure and verify the EDU deployment secrets and trusted SSH host key outside Git. +3. Confirm the EDU release can pass its CI and immutable-SHA deploy gate. Keep the existing namespace, Secret, Redis data, Fernet key, and runtime credentials. +4. Stop or drain pending homelab runs that can deploy EDU. Remove the EDU active marker from the authoritative homelab source before any later homelab deploy. Confirm that the removal path does not prune resources. +5. Merge the homelab removal. Then merge EDU PR #1 into `main` to start a successful main-push release and deployment. +6. Verify that homelab no longer selects EDU and that EDU is the sole owner. Check rollout health, `/health`, `/live`, Redis AUTH, session TTL, delivery backlog, and monitoring. Record the release SHA, image digests, downtime, and any remaining limits in the relevant PR. + +## Rollback + +For an EDU release failure, use the EDU release snapshot and reapply the last recorded immutable digests. Inspect workload and application health after recovery. Do not restore old Redis data unless recovery requires it. + +To reverse the ownership handoff, stop EDU deployment triggers and runs first. Restore the reviewed homelab configuration and active marker only after EDU is inactive. Reapply the recorded image digests and verify workload health. Never enable both deployment paths at the same time. The PVC and its PV must remain intact. + +## Verification status + +Verified: Gitea version and matrix support; current runner CPU, memory, and free disk readings; clean EDU checkout and SSH remote; Kubernetes context and namespace; workload readiness and health endpoints; matching live image digests; and Redis PVC binding and reclaim policy. + +Not verified: runner capacity limits, merged PR state, post-merge image release, EDU deployment, or final handoff acceptance. The merge and live deployment steps remain pending. Do not report the handoff as complete until the live checks above pass. + +Live pre-handoff checks also passed: Redis AUTH (`NOAUTH` without credentials and `PONG` with them), positive session TTL, zero delivery backlog, and nine EDU vmalert rules with matching expressions and healthy evaluation. The private snapshot is `/home/forust/.local/state/edu-master-deploy/handoff-20261007T080838Z` on workstation. It includes the Redis RDB, Secret, manifests, source files, and checkout commits. Workloads have not been redeployed. + +The Redis RDB checksum passed with twelve keys. The unauthorized Redis pod test passed and the pod was removed. VictoriaMetrics reported the EDU scrape target UP. EDU Actions has the dedicated path and port secrets and `EDU_KUBE_CONTEXT=Default`. Existing deployment and registry credentials were retained. diff --git a/.gitea/runner/README.md b/.gitea/runner/README.md index bd203a2..e229986 100644 --- a/.gitea/runner/README.md +++ b/.gitea/runner/README.md @@ -67,8 +67,7 @@ The deploy user's existing Docker registry authentication remains necessary. CI publishes `release-` as a Gitea artifact with all three owned image digests and build input fingerprints. Unchanged images are reused only from a -successful main CI artifact, never from `:prod`. EDU images remain pinned to the -digests released by their application repository. Expired artifacts cause CI to +successful main CI artifact, never from `:prod`. Expired artifacts cause CI to rebuild images; they block deployment until CI is rerun. Run deploy from main with `deploy_ref=main` or a checked SHA: diff --git a/.gitignore b/.gitignore index d49fe11..9df6ca4 100644 --- a/.gitignore +++ b/.gitignore @@ -94,7 +94,6 @@ replacements.txt .idea # Temp files -edu_master/temp/ temp/* # Local-only tooling scratch space (pinned CI tools, verification scripts) tmp/ diff --git a/edu_master/.env.example b/edu_master/.env.example deleted file mode 100644 index ec9c514..0000000 --- a/edu_master/.env.example +++ /dev/null @@ -1,14 +0,0 @@ -EDU_LOGIN=your_edu_login_here -EDU_PASSWORD=your_edu_password_here -EDU_URL_LOGIN=https://edu.edu.vn.ua/user/login -EDU_URL_VERIFY=https://edu.edu.vn.ua/course/userlist -PHPSESSID_INTERVAL=10 -USER_AGENT="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36" -WEBINAR_URL=https://edu.edu.vn.ua/webinar/useractive -WEBINAR_CHECK_INTERVAL=60 -REDIS_HOST=redis -REDIS_PORT=6379 -PLAYWRIGHT_WS=ws://playwright-service:3000/ws -TZ=Europe/Kyiv -WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here -WEBINAR_ADMIN_ID=123456789 diff --git a/edu_master/PLAYWRIGHT_VERSION b/edu_master/PLAYWRIGHT_VERSION deleted file mode 100644 index 3ebf789..0000000 --- a/edu_master/PLAYWRIGHT_VERSION +++ /dev/null @@ -1 +0,0 @@ -1.56.0 diff --git a/edu_master/README.md b/edu_master/README.md deleted file mode 100644 index 093d47c..0000000 --- a/edu_master/README.md +++ /dev/null @@ -1,14 +0,0 @@ -# EDU deployment ownership - -Application source and release builds: `forust/edu-master`. -The homelab pipeline deploys `edu_master/k8s` and preserves explicit image digests. -The application copies in this directory are legacy and are not build inputs. -Do not publish EDU `prod` images from homelab or resolve releases from moving tags. - -For an EDU release, validate both images, select their digests in the keeper and -checker manifests, and run the existing homelab validation/apply/verification -helpers against this service. Keep the existing Secret and Redis PVC. -Coordinate Redis authentication changes with both clients and all init/probes; -keep a pre-rollout Redis backup and both previous compatible image references. -The current HTTP checker does not depend on Playwright; check other consumers -before removing the separate browser service. diff --git a/edu_master/compose.yaml b/edu_master/compose.yaml deleted file mode 100644 index a87d9ff..0000000 --- a/edu_master/compose.yaml +++ /dev/null @@ -1,49 +0,0 @@ -services: - redis: - image: redis:8.10.2-alpine - restart: unless-stopped - volumes: - - redis-data:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 5s - timeout: 3s - retries: 5 - - playwright-service: - image: mcr.microsoft.com/playwright:v1.56.0-jammy - restart: unless-stopped - command: npx -y playwright@1.56.0 run-server --port 3000 --path /ws - - session-keeper: - build: ./phpsessid-bot - image: gcr.forust.xyz/forust/session-keeper:prod - pull_policy: build - env_file: .env - restart: unless-stopped - depends_on: - redis: - condition: service_healthy - healthcheck: - test: ["CMD-SHELL", "redis-cli -h redis EXISTS EDU_PHPSESSID | grep -q 1"] - interval: 30s - timeout: 5s - retries: 10 - start_period: 60s - - webinar-checker: - build: ./webinar-checker - image: gcr.forust.xyz/forust/webinar-checker:prod - pull_policy: build - env_file: .env - restart: unless-stopped - depends_on: - redis: - condition: service_healthy - session-keeper: - condition: service_healthy - playwright-service: - condition: service_started - -volumes: - redis-data: diff --git a/edu_master/k8s/active b/edu_master/k8s/active deleted file mode 100644 index e69de29..0000000 diff --git a/edu_master/k8s/alerts.yaml b/edu_master/k8s/alerts.yaml deleted file mode 100644 index 82c62dc..0000000 --- a/edu_master/k8s/alerts.yaml +++ /dev/null @@ -1,115 +0,0 @@ -apiVersion: monitoring.coreos.com/v1 -kind: PrometheusRule -metadata: - name: edu-master-webinar - namespace: edu-master - labels: - release: prometheus-stack -spec: - groups: - - name: edu_master.webinar - rules: - # No successful webinar check for 5m (~2-3 missed 2-min checks). - # Catches: playwright hangs/timeouts, version skew, site changes, hung job. - # The last_success > 0 guard is mandatory: checker.py initialises - # last_success to 0, so without it `time() - 0` equals the current epoch - # and humanizeDuration renders ~20722d on every pod restart. Keep the - # duration expression on the left so $value stays the real gap. - - alert: WebinarCheckerNoSuccessfulCheck - expr: | - ((time() - webinar_check_last_success_timestamp_seconds) > 300) - and (webinar_check_last_success_timestamp_seconds > 0) - and (webinar_check_last_run_timestamp_seconds > 0) - for: 2m - labels: - severity: critical - annotations: - summary: "Webinar checker has no successful check for 5m" - description: "edu-master/webinar-checker: last successful webinar check was {{ $value | humanizeDuration }} ago. Checks are failing or hanging (see consecutive failures alert). Notifications about new webinars are NOT being sent." - - # Checks are running but none has ever succeeded since pod start. - # Split out from the rule above so a zeroed gauge never feeds - # humanizeDuration. - - alert: WebinarCheckerNeverSucceeded - expr: | - (webinar_check_last_success_timestamp_seconds == 0) - and (webinar_check_last_run_timestamp_seconds > 0) - for: 10m - labels: - severity: critical - annotations: - summary: "Webinar checker has never completed a successful check" - description: 'edu-master/webinar-checker: checks have been running for 10m but not one has ever succeeded since the pod started, so every check is failing. Check pod logs (Loki: {namespace="edu-master", container="webinar-checker"}).' - - # Fast path: 3 consecutive failures (~6+ min at 2-min interval). - - alert: WebinarCheckerConsecutiveFailures - expr: | - webinar_check_consecutive_failures >= 3 - for: 5m - labels: - severity: critical - annotations: - summary: "Webinar checker failing consecutively" - description: 'edu-master/webinar-checker: {{ $value }} consecutive webinar check failures (timeout / http error / page error). Check pod logs (Loki: {namespace="edu-master", container="webinar-checker"}).' - - - alert: WebinarCheckerNeverStarted - expr: | - (time() - edu_process_start > 120) - and (webinar_check_last_run_timestamp_seconds == 0) - for: 2m - labels: - severity: critical - annotations: - summary: "Webinar checker job has not started" - description: "The process exposes metrics but its webinar job has never started." - - - alert: WebinarDeliveryPending - expr: edu_delivery_pending > 0 - for: 5m - labels: - severity: warning - annotations: - summary: "Webinar notifications await delivery" - description: "Telegram delivery has pending recipients. Check delivery failures and retry status." - - - alert: EduRedisUnavailable - expr: edu_redis_connected == 0 - for: 2m - labels: - severity: critical - annotations: - summary: "EDU checker cannot reach Redis" - description: "Redis health checks are failing; checker commands and delivery may be unavailable." - - # Metrics endpoint not scraped for 10m: pod down, metrics server dead, or ServiceMonitor broken. - - alert: WebinarCheckerScrapeDown - expr: | - absent(webinar_check_last_run_timestamp_seconds) == 1 - for: 10m - labels: - severity: critical - annotations: - summary: "Webinar checker metrics missing" - description: "edu-master/webinar-checker: no metrics series for 10m. Pod may be down, metrics server dead, or ServiceMonitor/Service broken. Webinar checks are unobserved." - - # EDU session lost: session-keeper down or credentials expired. Without PHPSESSID every check is skipped. - - alert: EduPhpsessidMissing - expr: | - edu_phpsessid_present == 0 - for: 10m - labels: - severity: critical - annotations: - summary: "EDU_PHPSESSID missing" - description: "edu-master: EDU_PHPSESSID absent from redis for 10m. Webinar/diari/schedule checks are all skipped. Check session-keeper logs and EDU credentials." - - # Hard deps: checker deployment unavailable. - - alert: WebinarCheckerDeploymentDown - expr: | - kube_deployment_status_replicas_unavailable{deployment="webinar-checker", namespace="edu-master"} > 0 - for: 10m - labels: - severity: critical - annotations: - summary: "Webinar checker deployment unavailable" - description: "edu-master/webinar-checker deployment has {{ $value }} unavailable replica(s) for 10m." diff --git a/edu_master/k8s/namespace.yaml b/edu_master/k8s/namespace.yaml deleted file mode 100644 index e241a25..0000000 --- a/edu_master/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: edu-master diff --git a/edu_master/k8s/playwright.yaml b/edu_master/k8s/playwright.yaml deleted file mode 100644 index c071a05..0000000 --- a/edu_master/k8s/playwright.yaml +++ /dev/null @@ -1,69 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: playwright-service - namespace: edu-master - labels: - app: edu-master-playwright -spec: - replicas: 1 - selector: - matchLabels: - app: edu-master-playwright - strategy: - type: Recreate - template: - metadata: - labels: - app: edu-master-playwright - spec: - containers: - - name: playwright - # renovate: datasource=docker depName=mcr.microsoft.com/playwright versioning=docker - image: mcr.microsoft.com/playwright:v1.56.0-jammy - imagePullPolicy: IfNotPresent - # p95 412M, max 478M over 7 days, no limit before. Request is set at p95 - # so the pod is not an eviction candidate; the limit stays above 2x the - # request because browser page lifetimes are unpredictable. - resources: - requests: - cpu: "200m" - memory: "416Mi" - limits: - memory: "1Gi" - command: - - npx - - -y - - playwright@1.56.0 - - run-server - - --port - - "3000" - - --path - - /ws - ports: - - containerPort: 3000 - readinessProbe: - tcpSocket: - port: 3000 - initialDelaySeconds: 5 - periodSeconds: 10 - timeoutSeconds: 3 - livenessProbe: - tcpSocket: - port: 3000 - initialDelaySeconds: 15 - periodSeconds: 20 - timeoutSeconds: 3 ---- -apiVersion: v1 -kind: Service -metadata: - name: playwright-service - namespace: edu-master -spec: - selector: - app: edu-master-playwright - ports: - - name: ws - port: 3000 - targetPort: 3000 diff --git a/edu_master/k8s/redis-networkpolicy.yaml b/edu_master/k8s/redis-networkpolicy.yaml deleted file mode 100644 index e7e4ec1..0000000 --- a/edu_master/k8s/redis-networkpolicy.yaml +++ /dev/null @@ -1,22 +0,0 @@ -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: redis-clients-only - namespace: edu-master -spec: - podSelector: - matchLabels: - app: edu-master-redis - policyTypes: - - Ingress - ingress: - - from: - - podSelector: - matchLabels: - app: edu-master-session-keeper - - podSelector: - matchLabels: - app: edu-master-webinar-checker - ports: - - protocol: TCP - port: 6379 diff --git a/edu_master/k8s/redis.yaml b/edu_master/k8s/redis.yaml deleted file mode 100644 index e4f139c..0000000 --- a/edu_master/k8s/redis.yaml +++ /dev/null @@ -1,96 +0,0 @@ -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: redis - namespace: edu-master - labels: - app: edu-master-redis -spec: - serviceName: redis - replicas: 1 - selector: - matchLabels: - app: edu-master-redis - template: - metadata: - labels: - app: edu-master-redis - spec: - containers: - - name: redis - image: redis:8.10.2-alpine - imagePullPolicy: IfNotPresent - env: - - name: REDIS_PASSWORD - valueFrom: - secretKeyRef: - name: edu-master-secrets - key: REDIS_PASSWORD - - name: REDISCLI_AUTH - valueFrom: - secretKeyRef: - name: edu-master-secrets - key: REDIS_PASSWORD - command: - - /bin/sh - - -ec - - | - case "$REDIS_PASSWORD" in *[!0-9a-fA-F]*|'') echo 'REDIS_PASSWORD must be 64 hex characters' >&2; exit 1;; esac - [ "${#REDIS_PASSWORD}" -eq 64 ] || { echo 'REDIS_PASSWORD must be 64 hex characters' >&2; exit 1; } - umask 077 - printf 'requirepass "%s"\n' "$REDIS_PASSWORD" > /tmp/redis-auth.conf - chown redis:redis /tmp/redis-auth.conf - exec docker-entrypoint.sh redis-server /tmp/redis-auth.conf - ports: - - containerPort: 6379 - volumeMounts: - - name: redis-data - mountPath: /data - resources: - requests: - cpu: 25m - memory: 32Mi - limits: - cpu: 250m - memory: 128Mi - readinessProbe: - exec: - command: ["redis-cli", "ping"] - initialDelaySeconds: 5 - periodSeconds: 5 - timeoutSeconds: 3 - livenessProbe: - exec: - command: ["redis-cli", "ping"] - initialDelaySeconds: 10 - periodSeconds: 10 - timeoutSeconds: 3 - volumes: - - name: redis-data - persistentVolumeClaim: - claimName: redis-data-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: redis-data-pvc - namespace: edu-master -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi ---- -apiVersion: v1 -kind: Service -metadata: - name: redis - namespace: edu-master -spec: - selector: - app: edu-master-redis - ports: - - name: redis - port: 6379 - targetPort: 6379 diff --git a/edu_master/k8s/restore-seed-job.yaml.example b/edu_master/k8s/restore-seed-job.yaml.example deleted file mode 100644 index c85b078..0000000 --- a/edu_master/k8s/restore-seed-job.yaml.example +++ /dev/null @@ -1,50 +0,0 @@ -# One-time Job to migrate redis state from docker compose to k8s (maintenance window). -# The .example file is not applied by the deploy pipeline (mask *.example.yaml). -# -# Runbook: -# 1. docker compose -f /edu_master/compose.yaml stop # SIGTERM -> redis will flush dump.rdb -# 2. docker run --rm -v edu_master_redis-data:/data \ -# -v /tmp/edu-master-backup:/backup \ -# redis:alpine sh -c "cp /data/dump.rdb /backup/ && ls -la /backup" -# 3. kubectl apply -f edu_master/k8s/namespace.yaml -# 4. kubectl apply -f # seed must come BEFORE redis pod starts -# 5. kubectl apply -f edu_master/k8s/restore-seed-job.yaml.example -# kubectl wait --for=condition=complete job/redis-restore-seed -n edu-master --timeout=120s -# 6. kubectl delete job redis-restore-seed -n edu-master -# 7. kubectl apply -f edu_master/k8s/ -R # apply remaining manifests -apiVersion: batch/v1 -kind: Job -metadata: - name: redis-restore-seed - namespace: edu-master -spec: - backoffLimit: 2 - ttlSecondsAfterFinished: 3600 - template: - spec: - restartPolicy: Never - containers: - - name: seed - image: redis:alpine - command: - - /bin/sh - - -ec - - | - ls -la /backup - cp /backup/dump.rdb /data/dump.rdb - chmod 644 /data/dump.rdb - ls -la /data - volumeMounts: - - name: redis-data - mountPath: /data - - name: backup - mountPath: /backup - readOnly: true - volumes: - - name: redis-data - persistentVolumeClaim: - claimName: redis-data-pvc - - name: backup - hostPath: - path: /tmp/edu-master-backup - type: DirectoryOrCreate diff --git a/edu_master/k8s/secrets.yaml.example b/edu_master/k8s/secrets.yaml.example deleted file mode 100644 index 7f1cf9b..0000000 --- a/edu_master/k8s/secrets.yaml.example +++ /dev/null @@ -1,29 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: edu-master-secrets - namespace: edu-master -type: Opaque -stringData: - # Session keeper credentials - KEEPER_LOGIN: "" - KEEPER_PASSWORD: "" - KEEPER_INTERVAL: "10" - # EDU links - EDU_URL_BASE: "https://edu.edu.vn.ua" - EDU_URL_LOGIN: "/user/login" - EDU_URL_COURSES: "/course/userlist" - EDU_URL_WEBINAR: "/webinar/useractive" - # Playwright - USER_AGENT: "" - PLAYWRIGHT_WS: "ws://playwright-service:3000/ws" - # Webinar-checker - WEBINAR_TELEGRAM_TOKEN: "" - WEBINAR_ADMIN_ID: "" - WEBINAR_CHECK_INTERVAL: "60" - # Prometheus metrics endpoint (scraped via ServiceMonitor, alerts in k8s/alerts.yaml) - METRICS_PORT: "8000" - # Database - REDIS_HOST: "redis" - REDIS_PORT: "6379" - TZ: "Europe/Kyiv" diff --git a/edu_master/k8s/service.yaml b/edu_master/k8s/service.yaml deleted file mode 100644 index a20026e..0000000 --- a/edu_master/k8s/service.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: webinar-checker - namespace: edu-master - labels: - app: edu-master-webinar-checker -spec: - selector: - app: edu-master-webinar-checker - ports: - - name: metrics - port: 8000 - targetPort: metrics - protocol: TCP diff --git a/edu_master/k8s/servicemonitor.yaml b/edu_master/k8s/servicemonitor.yaml deleted file mode 100644 index f57abd3..0000000 --- a/edu_master/k8s/servicemonitor.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: monitoring.coreos.com/v1 -kind: ServiceMonitor -metadata: - name: webinar-checker - namespace: edu-master - labels: - release: prometheus-stack -spec: - selector: - matchLabels: - app: edu-master-webinar-checker - endpoints: - - port: metrics - path: /metrics - interval: 30s - scrapeTimeout: 10s diff --git a/edu_master/k8s/session-keeper.yaml b/edu_master/k8s/session-keeper.yaml deleted file mode 100644 index 65bfd97..0000000 --- a/edu_master/k8s/session-keeper.yaml +++ /dev/null @@ -1,69 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - reloader.stakater.com/auto: "true" - name: session-keeper - namespace: edu-master - labels: - app: edu-master-session-keeper -spec: - replicas: 1 - selector: - matchLabels: - app: edu-master-session-keeper - strategy: - type: Recreate - template: - metadata: - annotations: - edu.forust.xyz/source-commit: "90829d6c8080b9928f9da23587678e640939e10a" - labels: - app: edu-master-session-keeper - spec: - initContainers: - - name: wait-redis - image: redis:8.10.2-alpine - env: - - name: REDISCLI_AUTH - valueFrom: - secretKeyRef: - name: edu-master-secrets - key: REDIS_PASSWORD - command: - - /bin/sh - - -ec - - | - i=0 - until redis-cli -h redis ping | grep -q PONG; do - i=$((i+1)) - [ "$i" -ge 300 ] && echo "TIMEOUT: redis not ready" && exit 1 - sleep 2 - done - echo "redis is ready" - containers: - - name: session-keeper - image: gcr.forust.xyz/forust/session-keeper@sha256:49285e87cc5bc4cf4ffe190813d87927916c2df8a206daac0aeb7d227c636450 - envFrom: - - secretRef: - name: edu-master-secrets - env: - - name: REDISCLI_AUTH - valueFrom: - secretKeyRef: - name: edu-master-secrets - key: REDIS_PASSWORD - resources: - requests: - cpu: 25m - memory: 32Mi - limits: - cpu: 250m - memory: 128Mi - readinessProbe: - exec: - command: ["/bin/sh", "-ec", "redis-cli -h redis EXISTS EDU_PHPSESSID | grep -q 1"] - initialDelaySeconds: 15 - periodSeconds: 30 - timeoutSeconds: 5 - failureThreshold: 10 diff --git a/edu_master/k8s/webinar-checker.yaml b/edu_master/k8s/webinar-checker.yaml deleted file mode 100644 index 6280de1..0000000 --- a/edu_master/k8s/webinar-checker.yaml +++ /dev/null @@ -1,87 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - annotations: - reloader.stakater.com/auto: "true" - name: webinar-checker - namespace: edu-master - labels: - app: edu-master-webinar-checker -spec: - replicas: 1 - selector: - matchLabels: - app: edu-master-webinar-checker - strategy: - type: Recreate - template: - metadata: - annotations: - edu.forust.xyz/source-commit: "90829d6c8080b9928f9da23587678e640939e10a" - labels: - app: edu-master-webinar-checker - spec: - # Enforces dependency order like compose depends_on: - # redis healthy -> session-keeper healthy (EXISTS EDU_PHPSESSID) - initContainers: - - name: wait-deps - image: redis:8.10.2-alpine - env: - - name: REDISCLI_AUTH - valueFrom: - secretKeyRef: - name: edu-master-secrets - key: REDIS_PASSWORD - command: - - /bin/sh - - -ec - - | - i=0 - until redis-cli -h redis ping | grep -q PONG; do - i=$((i+1)) - [ "$i" -ge 300 ] && echo "TIMEOUT: redis not ready" && exit 1 - sleep 2 - done - echo "redis ok" - until [ "$(redis-cli -h redis EXISTS EDU_PHPSESSID)" = "1" ]; do - i=$((i+1)) - [ "$i" -ge 300 ] && echo "TIMEOUT: no PHPSESSID (session-keeper down?)" && exit 1 - sleep 2 - done - echo "PHPSESSID ok" - containers: - - name: webinar-checker - image: gcr.forust.xyz/forust/webinar-checker@sha256:66c146f7b43cb9f0dc31ba9aa36d217e01df42ddafba5971b79c12ec215b2c01 - ports: - - name: metrics - containerPort: 8000 - protocol: TCP - readinessProbe: - httpGet: - path: /health - port: metrics - periodSeconds: 10 - timeoutSeconds: 3 - failureThreshold: 12 - initialDelaySeconds: 10 - livenessProbe: - httpGet: - path: /live - port: metrics - initialDelaySeconds: 60 - periodSeconds: 15 - timeoutSeconds: 3 - failureThreshold: 4 - envFrom: - - secretRef: - name: edu-master-secrets - env: - - name: TZ - value: "Europe/Kyiv" - resources: - requests: - cpu: "50m" - memory: "192Mi" - limits: - cpu: "600m" - memory: "384Mi" diff --git a/edu_master/phpsessid-bot/Dockerfile b/edu_master/phpsessid-bot/Dockerfile deleted file mode 100644 index 2c9f2c1..0000000 --- a/edu_master/phpsessid-bot/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.14-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y --no-install-recommends redis-tools && rm -rf /var/lib/apt/lists/* - -# Install dependencies -RUN pip install --no-cache-dir requests==2.32.3 redis==5.2.1 - -# Copy application code -COPY . . - -# Run the bot -CMD ["python", "bot.py"] diff --git a/edu_master/phpsessid-bot/bot.py b/edu_master/phpsessid-bot/bot.py deleted file mode 100644 index 963a143..0000000 --- a/edu_master/phpsessid-bot/bot.py +++ /dev/null @@ -1,132 +0,0 @@ -import logging -import os -import time -from datetime import datetime - -import redis -import requests - -# Configure logging -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - - -# Load configuration (adapted to .env keys) -def _env(key, default=None): - v = os.getenv(key, default) - if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")): - return v[1:-1] - return v - - -LOGIN = _env('KEEPER_LOGIN') -PASSWORD = _env('KEEPER_PASSWORD') - -EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua') -EDU_LOGIN_PATH = _env('EDU_URL_LOGIN', '/user/login') -EDU_COURSES_PATH = _env('EDU_URL_COURSES', '/course/userlist') -URL_LOGIN = f'{EDU_BASE.rstrip("/")}/{EDU_LOGIN_PATH.lstrip("/")}' -URL_VERIFY = f'{EDU_BASE.rstrip("/")}/{EDU_COURSES_PATH.lstrip("/")}' - -INTERVAL = int(_env('KEEPER_INTERVAL', 10)) -USER_AGENT = _env( - 'USER_AGENT', - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', -) -REDIS_HOST = _env('REDIS_HOST', 'redis') -REDIS_PORT = int(_env('REDIS_PORT', 6379)) - -SUCCESS_FILE = '/tmp/last_success' # noqa: S108 - - -def touch_success_file(): - """Updates the timestamp of the success file for healthchecks.""" - try: - with open(SUCCESS_FILE, 'w') as f: - f.write(str(datetime.now().timestamp())) - except Exception as e: - logger.error(f'Failed to touch success file: {e}') - - -def main(): - logger.info('Starting Session Keeper Bot') - - # Connect to Redis - try: - redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) - redis_client.ping() - logger.info(f'Connected to Redis at {REDIS_HOST}:{REDIS_PORT}') - except Exception as e: - logger.error(f'Failed to connect to Redis: {e}') - return - - session = requests.Session() - - # Set headers - headers = { - 'User-Agent': USER_AGENT, - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', - 'Accept-Language': 'en-US,en;q=0.9', - 'Cache-Control': 'max-age=0', - 'Upgrade-Insecure-Requests': '1', - 'Sec-Fetch-Site': 'same-origin', - 'Sec-Fetch-Mode': 'navigate', - 'Sec-Fetch-User': '?1', - 'Sec-Fetch-Dest': 'document', - 'Sec-Ch-Ua': '"Not_A Brand";v="99", "Chromium";v="142"', - 'Sec-Ch-Ua-Mobile': '?0', - 'Sec-Ch-Ua-Platform': '"Linux"', - 'Accept-Encoding': 'gzip, deflate, br', - 'Priority': 'u=0, i', - } - session.headers.update(headers) - - while True: - try: - logger.info('Attempting login...') - - # Login payload - payload = {'login': LOGIN, 'password': PASSWORD} - - # Perform Login - # Note: The user request shows a POST to /user/login with form data - # We need to make sure we handle the PHPSESSID correctly. - # If we already have a PHPSESSID, requests will send it. - - login_response = session.post(URL_LOGIN, data=payload, allow_redirects=True) - - logger.info(f'Login Response Status: {login_response.status_code}') - logger.info(f'Cookies after login: {session.cookies.get_dict()}') - - # Verify Session - logger.info('Verifying session...') - verify_response = session.get(URL_VERIFY, allow_redirects=False) - - logger.info(f'Verify Response Status: {verify_response.status_code}') - - if verify_response.status_code == 200: - logger.info('Session verification SUCCESS (200 OK).') - touch_success_file() - - # Save PHPSESSID to Redis - phpsessid = session.cookies.get('PHPSESSID') - if phpsessid: - try: - redis_client.set('EDU_PHPSESSID', phpsessid) - logger.info(f'Saved PHPSESSID to Redis: {phpsessid}') - except Exception as e: - logger.error(f'Failed to save PHPSESSID to Redis: {e}') - elif verify_response.status_code == 302: - logger.warning('Session verification FAILED (302 Redirect). Session might be invalid.') - else: - logger.warning(f'Session verification returned unexpected status: {verify_response.status_code}') - - except Exception as e: - logger.error(f'An error occurred: {e}') - - logger.info(f'Sleeping for {INTERVAL} minutes...') - time.sleep(INTERVAL * 60) - - -if __name__ == '__main__': - main() diff --git a/edu_master/webinar-checker/Dockerfile b/edu_master/webinar-checker/Dockerfile deleted file mode 100644 index 9edab9f..0000000 --- a/edu_master/webinar-checker/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.14-slim - -WORKDIR /app - -# renovate: datasource=pypi depName=playwright versioning=pep440 -ARG PLAYWRIGHT_VERSION=1.56.0 - -# Install dependencies - PLAYWRIGHT_VERSION is single-source, renovate updates ARG above and all other places via regexManagers -RUN pip install --no-cache-dir pip==25.0.1 && pip install --no-cache-dir playwright==${PLAYWRIGHT_VERSION} redis==5.2.1 requests==2.32.3 "python-telegram-bot[job-queue]==21.10" - -COPY checker.py . - -CMD ["python", "checker.py"] diff --git a/edu_master/webinar-checker/checker.py b/edu_master/webinar-checker/checker.py deleted file mode 100644 index ae3de7a..0000000 --- a/edu_master/webinar-checker/checker.py +++ /dev/null @@ -1,1821 +0,0 @@ -import asyncio -import contextlib -import json -import logging -import os -import re -import tempfile -import threading -import time -from datetime import datetime, timedelta -from html import escape -from http.server import BaseHTTPRequestHandler, HTTPServer - -import redis -from playwright.async_api import async_playwright -from telegram import ChatMember, InlineKeyboardButton, InlineKeyboardMarkup, Update -from telegram.constants import ChatType -from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes - -# Logger -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') -logger = logging.getLogger(__name__) - -# Suppress HTTP request logs -logging.getLogger('urllib3').setLevel(logging.WARNING) -logging.getLogger('httpx').setLevel(logging.WARNING) -logging.getLogger('telegram.ext._application').setLevel(logging.WARNING) - - -# Load environment variables -def _env(key, default=None): - v = os.getenv(key, default) - if isinstance(v, str) and len(v) >= 2 and ((v[0] == '"' and v[-1] == '"') or (v[0] == "'" and v[-1] == "'")): - return v[1:-1] - return v - - -EDU_BASE = _env('EDU_URL_BASE', 'https://edu.edu.vn.ua') -EDU_WEBINAR_PATH = _env('EDU_URL_WEBINAR', '/webinar/useractive') -WEBINAR_URL = f'{EDU_BASE.rstrip("/")}/{EDU_WEBINAR_PATH.lstrip("/")}' -DIARY_URL = f'{EDU_BASE.rstrip("/")}/user/diary' -SCHEDULE_URL = f'{EDU_BASE.rstrip("/")}/lessons/table' - -WEBINAR_CHECK_INTERVAL = int(_env('WEBINAR_CHECK_INTERVAL', 60)) -REDIS_HOST = _env('REDIS_HOST', 'redis') -REDIS_PORT = int(_env('REDIS_PORT', 6379)) -PLAYWRIGHT_WS = _env('PLAYWRIGHT_WS', 'ws://playwright-service:3000/ws') -USER_AGENT = _env( - 'USER_AGENT', - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36', -) -WEBINAR_TELEGRAM_TOKEN = _env('WEBINAR_TELEGRAM_TOKEN') -ADMIN_ID = int(_env('WEBINAR_ADMIN_ID', '0')) -METRICS_PORT = int(_env('METRICS_PORT', '8000')) - -# --- Prometheus metrics (stdlib only, no extra deps) --- -# Scraped by prometheus-stack via ServiceMonitor (edu_master/k8s/servicemonitor.yaml). -# Critical alerts in edu_master/k8s/alerts.yaml fire to Telegram via Alertmanager. -_METRICS_LOCK = threading.Lock() -_METRICS = { - 'last_run': 0.0, # Unix ts of last check start - 'last_success': 0.0, # Unix ts of last successful check - 'last_duration': 0.0, # Duration of last check in seconds - 'success_total': 0, - 'failure_total': 0, - 'consecutive_failures': 0, - 'phpsessid_present': 1, # 1 if EDU_PHPSESSID found in redis, else 0 -} - - -def _metric_check_start(): - with _METRICS_LOCK: - _METRICS['last_run'] = time.time() - - -def _metric_check_ok(duration: float): - now = time.time() - with _METRICS_LOCK: - _METRICS['last_success'] = now - _METRICS['last_duration'] = duration - _METRICS['success_total'] += 1 - _METRICS['consecutive_failures'] = 0 - _METRICS['phpsessid_present'] = 1 - - -def _metric_check_fail(duration: float, phpsessid_missing: bool = False): - with _METRICS_LOCK: - _METRICS['last_duration'] = duration - _METRICS['failure_total'] += 1 - _METRICS['consecutive_failures'] += 1 - _METRICS['phpsessid_present'] = 0 if phpsessid_missing else 1 - - -def _metrics_render() -> bytes: - with _METRICS_LOCK: - m = dict(_METRICS) - lines = [ - '# HELP webinar_check_last_run_timestamp_seconds Unix timestamp of last webinar check start.', - '# TYPE webinar_check_last_run_timestamp_seconds gauge', - f'webinar_check_last_run_timestamp_seconds {m["last_run"]}', - '# HELP webinar_check_last_success_timestamp_seconds Unix timestamp of last successful webinar check.', - '# TYPE webinar_check_last_success_timestamp_seconds gauge', - f'webinar_check_last_success_timestamp_seconds {m["last_success"]}', - '# HELP webinar_check_last_duration_seconds Duration of last webinar check in seconds.', - '# TYPE webinar_check_last_duration_seconds gauge', - f'webinar_check_last_duration_seconds {m["last_duration"]}', - '# HELP webinar_check_success_total Total successful webinar checks.', - '# TYPE webinar_check_success_total counter', - f'webinar_check_success_total {m["success_total"]}', - '# HELP webinar_check_failure_total Total failed webinar checks (timeout, playwright error, page error).', - '# TYPE webinar_check_failure_total counter', - f'webinar_check_failure_total {m["failure_total"]}', - '# HELP webinar_check_consecutive_failures Consecutive failed webinar checks (reset on success).', - '# TYPE webinar_check_consecutive_failures gauge', - f'webinar_check_consecutive_failures {m["consecutive_failures"]}', - '# HELP edu_phpsessid_present 1 if EDU_PHPSESSID exists in redis, 0 otherwise.', - '# TYPE edu_phpsessid_present gauge', - f'edu_phpsessid_present {m["phpsessid_present"]}', - ] - return ('\n'.join(lines) + '\n').encode() - - -class _MetricsHandler(BaseHTTPRequestHandler): - def do_GET(self): - if self.path == '/metrics': - body = _metrics_render() - self.send_response(200) - self.send_header('Content-Type', 'text/plain; version=0.0.4') - self.send_header('Content-Length', str(len(body))) - self.end_headers() - self.wfile.write(body) - elif self.path in ('/healthz', '/health'): - body = b'ok\n' - self.send_response(200) - self.send_header('Content-Type', 'text/plain') - self.send_header('Content-Length', str(len(body))) - self.end_headers() - self.wfile.write(body) - else: - self.send_response(404) - self.end_headers() - - def log_message(self, *args): - pass # keep bot logs clean - - -def start_metrics_server(port: int = METRICS_PORT): - server = HTTPServer(('0.0.0.0', port), _MetricsHandler) # noqa: S104 - k8s ServiceMonitor scrapes pod IP - thread = threading.Thread(target=server.serve_forever, name='metrics-server', daemon=True) - thread.start() - logger.info(f'Metrics server listening on :{port}/metrics') - return server - - -# Redis Keys -KEY_WHITELIST = 'bot:whitelist' -KEY_WHITELIST_ENABLED = 'bot:whitelist_enabled' -KEY_SUBSCRIBERS = 'bot:subscribers' -KEY_PHPSESSID = 'EDU_PHPSESSID' -KEY_WEBINAR_HISTORY = 'bot:webinar_history' # Stores last 3 webinars - -# Marker shown by the site when there are no active online lessons -NO_WEBINAR_MARKER = 'Жодного онлайн уроку зараз' - -# Initialize Redis -try: - redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True) - redis_client.ping() - logger.info(f'Connected to Redis at {REDIS_HOST}:{REDIS_PORT}') -except Exception as e: - logger.error(f'Failed to connect to Redis: {e}') - exit(1) - -# --- Translations --- - -TRANSLATIONS = { - 'ru': { - 'welcome': '👋 Привет, {name}!\n\nЯ бот-уведомитель о вебинарах. Я буду сообщать вам, когда появится новый вебинар.\nВы подписаны на уведомления.', - 'welcome_admin': '\n\n👑 Режим администратора активен', - 'access_denied': '⛔ Доступ запрещен. Вас нет в белом списке.', - 'help_title': '🤖 Помощь по боту\n\n', - 'help_commands': '/start - Подписаться на уведомления\n/stop - Отписаться от уведомлений\n/help - Показать это сообщение\n/language - Сменить язык', - 'help_admin': '\nКоманды администратора:\n/adduser [user_id] - Добавить пользователя в белый список\n/removeuser [user_id] - Удалить пользователя из белого списка\nИли используйте панель ниже для управления настройками.', - 'admin_only': '⛔ Только для администратора!', - 'user_added': '✅ Пользователь {user_id} добавлен в белый список', - 'user_removed': '✅ Пользователь {user_id} удален из белого списка', - 'user_not_in_whitelist': '⚠️ Пользователь {user_id} не был в белом списке', - 'cannot_remove_admin': '❌ Невозможно удалить администратора из белого списка', - 'invalid_user_id': '❌ Неверный ID пользователя. Должно быть число.', - 'usage_adduser': 'Использование: /adduser [user_id]', - 'usage_removeuser': 'Использование: /removeuser [user_id]', - 'whitelist_enabled': '✅ Белый список включен', - 'whitelist_disabled': '🚫 Белый список отключен', - 'whitelist_title': '📋 Белый список:\n', - 'subscribers_title': '👥 Подписчики:\n', - 'empty': 'Пусто', - 'force_check_running': '🔄 Запускаю проверку...', - 'check_failed': '❌ Проверка не удалась. Смотрите логи.', - 'check_completed_none': '✅ Проверка завершена. Вебинаров не найдено.', - 'check_completed': '✅ Проверка завершена. Найдено {count} вебинар(ов)!', - 'toggle_whitelist_disable': '🔒 Отключить белый список', - 'toggle_whitelist_enable': '🔓 Включить белый список', - 'view_whitelist': '📋 Посмотреть белый список', - 'view_subscribers': '👥 Посмотреть подписчиков', - 'force_check': '🔄 Принудительная проверка', - 'webinar_found': '🎓 Новый вебинар!\n\n', - 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', - 'select_language': '🌐 Выберите язык / Оберіть мову / Select language:', - 'language_changed': '✅ Язык изменен на {lang}', - 'flag_ru': '🇷🇺 Русский', - 'flag_uk': '🇺🇦 Українська', - 'flag_en': '🇬🇧 English', - 'history_cleared': '✅ История вебинаров очищена', - 'history_clear_failed': '❌ Ошибка при очистке истории', - 'today': '📌 Сегодня', - 'tomorrow': '📌 Завтра', - 'week': '📅 Эта неделя', - 'month': '📅 Весь месяц', - 'no_events': 'Нет событий', - 'no_lessons': 'Нет уроков', - 'time_unknown': 'неизвестно', - 'free_period': 'Свободно', - 'unsubscribed': '🔕 Вы отписались от уведомлений.', - 'diary_title': '📅 Дневник — выберите период:', - 'diary_week_title': '📅 Неделя {start} – {end}', - 'schedule_title': '📅 {weekday} — {class_num} класс', - 'loading_diary': '🔄 Загружаю дневник...', - 'diary_load_failed': '❌ Не удалось загрузить дневник.', - 'diary_no_session': '❌ Не удалось загрузить дневник. Нет сессии или ошибка.', - 'diary_next_month': '❌ Данные за следующий месяц недоступны. Перейдите на сайт.', - 'schedule_load_failed': '❌ Не удалось загрузить расписание.', - 'class_not_set': '❌ Класс не настроен. Используйте /setclass.', - 'class_not_found': '❌ Классы не найдены в расписании.', - 'select_class': '🎒 Выберите класс — сохранится и больше не спросится:', - 'admin_class_not_set': '⚠️ Администратор еще не настроил класс для этой группы. Используйте /setclass 11', - 'loading_schedule': '🔄 Загружаю расписание...', - 'invalid_class_num': '❌ Неверный номер класса.', - 'class_saved': '✅ Класс {class_num} сохранен.', - 'admin_only_msg': '⚠️ Только администратор может настроить класс для группы.', - 'truncation': '\n\n✂️ ...(обрезано)', - 'day_mon': 'Пн', - 'day_tue': 'Вт', - 'day_wed': 'Ср', - 'day_thu': 'Чт', - 'day_fri': 'Пт', - 'day_sat': 'Сб', - 'day_sun': 'Вс', - }, - 'uk': { - 'welcome': "👋 Привіт, {name}!\n\nЯ бот-сповіщувач про вебінари. Я повідомлятиму вас, коли з'явиться новий вебінар.\nВи підписані на сповіщення.", - 'welcome_admin': '\n\n👑 Режим адміністратора активний', - 'access_denied': '⛔ Доступ заборонено. Вас немає в білому списку.', - 'help_title': '🤖 Довідка по боту\n\n', - 'help_commands': '/start - Підписатися на сповіщення\n/stop - Відписатися від сповіщень\n/help - Показати це повідомлення\n/language - Змінити мову', - 'help_admin': '\nКоманди адміністратора:\n/adduser [user_id] - Додати користувача до білого списку\n/removeuser [user_id] - Видалити користувача з білого списку\nАбо використовуйте панель нижче для керування налаштуваннями.', - 'admin_only': '⛔ Тільки для адміністратора!', - 'user_added': '✅ Користувач {user_id} доданий до білого списку', - 'user_removed': '✅ Користувач {user_id} видалений з білого списку', - 'user_not_in_whitelist': '⚠️ Користувач {user_id} не був у білому списку', - 'cannot_remove_admin': '❌ Неможливо видалити адміністратора з білого списку', - 'invalid_user_id': '❌ Невірний ID користувача. Має бути число.', - 'usage_adduser': 'Використання: /adduser [user_id]', - 'usage_removeuser': 'Використання: /removeuser [user_id]', - 'whitelist_enabled': '✅ Білий список увімкнено', - 'whitelist_disabled': '🚫 Білий список вимкнено', - 'whitelist_title': '📋 Білий список:\n', - 'subscribers_title': '👥 Підписники:\n', - 'empty': 'Порожньо', - 'force_check_running': '🔄 Запускаю перевірку...', - 'check_failed': '❌ Перевірка не вдалася. Дивіться логи.', - 'check_completed_none': '✅ Перевірка завершена. Вебінарів не знайдено.', - 'check_completed': '✅ Перевірка завершена. Знайдено {count} вебінар(ів)!', - 'toggle_whitelist_disable': '🔒 Вимкнути білий список', - 'toggle_whitelist_enable': '🔓 Увімкнути білий список', - 'view_whitelist': '📋 Переглянути білий список', - 'view_subscribers': '👥 Переглянути підписників', - 'force_check': '🔄 Примусова перевірка', - 'webinar_found': '🎓 Новий вебінар!\n\n', - 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', - 'select_language': '🌐 Виберіть мову / Выберите язык / Select language:', - 'language_changed': '✅ Мову змінено на {lang}', - 'flag_ru': '🇷🇺 Русский', - 'flag_uk': '🇺🇦 Українська', - 'flag_en': '🇬🇧 English', - 'history_cleared': '✅ Історія вебінарів очищена', - 'history_clear_failed': '❌ Помилка при очищенні історії', - 'today': '📌 Сьогодні', - 'tomorrow': '📌 Завтра', - 'week': '📅 Цей тиждень', - 'month': '📅 Весь місяць', - 'no_events': 'Немає подій', - 'no_lessons': 'Немає уроків', - 'time_unknown': 'невідомо', - 'free_period': 'Вільно', - 'unsubscribed': '🔕 Ви відписалися від сповіщень.', - 'diary_title': '📅 Щоденник — виберіть період:', - 'diary_week_title': '📅 Тиждень {start} – {end}', - 'schedule_title': '📅 {weekday} — {class_num} клас', - 'loading_diary': '🔄 Завантажую щоденник...', - 'diary_load_failed': '❌ Не вдалося завантажити щоденник.', - 'diary_no_session': '❌ Не вдалося завантажити щоденник. Немає сесії або помилка.', - 'diary_next_month': '❌ Дані за наступний місяць недоступні. Перейдіть на сайт.', - 'schedule_load_failed': '❌ Не вдалося завантажити розклад.', - 'class_not_set': '❌ Клас не налаштовано. Використайте /setclass.', - 'class_not_found': '❌ Класи не знайдені в розкладі.', - 'select_class': '🎒 Оберіть клас — збережеться і більше не питатиметься:', - 'admin_class_not_set': '⚠️ Адміністратор ще не налаштував клас для цієї групи. Використайте /setclass 11', - 'loading_schedule': '🔄 Завантажую розклад...', - 'invalid_class_num': '❌ Невірний номер класу.', - 'class_saved': '✅ Клас {class_num} збережено.', - 'admin_only_msg': '⚠️ Тільки адміністратор може налаштувати клас для групи.', - 'truncation': '\n\n✂️ ...(обрізано)', - 'day_mon': 'Пн', - 'day_tue': 'Вт', - 'day_wed': 'Ср', - 'day_thu': 'Чт', - 'day_fri': 'Пт', - 'day_sat': 'Сб', - 'day_sun': 'Нд', - }, - 'en': { - 'welcome': '👋 Hello, {name}!\n\nI am the Webinar Checker Bot. I will notify you when a new webinar appears.\nYou have been subscribed to notifications.', - 'welcome_admin': '\n\n👑 Admin Mode Active', - 'access_denied': '⛔ Access denied. You are not on the whitelist.', - 'help_title': '🤖 Bot Help\n\n', - 'help_commands': '/start - Subscribe to notifications\n/stop - Unsubscribe from notifications\n/help - Show this message\n/language - Change language', - 'help_admin': '\nAdmin Commands:\n/adduser [user_id] - Add user to whitelist\n/removeuser [user_id] - Remove user from whitelist\nOr use the panel below to manage settings.', - 'admin_only': '⛔ Admin only!', - 'user_added': '✅ User {user_id} added to whitelist', - 'user_removed': '✅ User {user_id} removed from whitelist', - 'user_not_in_whitelist': '⚠️ User {user_id} was not in whitelist', - 'cannot_remove_admin': '❌ Cannot remove admin from whitelist', - 'invalid_user_id': '❌ Invalid user ID. Must be a number.', - 'usage_adduser': 'Usage: /adduser [user_id]', - 'usage_removeuser': 'Usage: /removeuser [user_id]', - 'whitelist_enabled': '✅ Whitelist Enabled', - 'whitelist_disabled': '🚫 Whitelist Disabled', - 'whitelist_title': '📋 Whitelist:\n', - 'subscribers_title': '👥 Subscribers:\n', - 'empty': 'Empty', - 'force_check_running': '🔄 Running immediate check...', - 'check_failed': '❌ Check failed. See logs for details.', - 'check_completed_none': '✅ Check completed. No webinars found.', - 'check_completed': '✅ Check completed. Found {count} webinar(s)!', - 'toggle_whitelist_disable': '🔒 Disable Whitelist', - 'toggle_whitelist_enable': '🔓 Enable Whitelist', - 'view_whitelist': '📋 View Whitelist', - 'view_subscribers': '👥 View Subscribers', - 'force_check': '🔄 Force Check', - 'webinar_found': '🎓 New webinar found!\n\n', - 'webinar_item': '📌 {name}\n🔗 https://edu.edu.vn.ua{url}', - 'select_language': '🌐 Select language / Виберіть мову / Выберите язык:', - 'language_changed': '✅ Language changed to {lang}', - 'flag_ru': '🇷🇺 Русский', - 'flag_uk': '🇺🇦 Українська', - 'flag_en': '🇬🇧 English', - 'history_cleared': '✅ Webinar history cleared', - 'history_clear_failed': '❌ Error clearing history', - 'today': '📌 Today', - 'tomorrow': '📌 Tomorrow', - 'week': '📅 This week', - 'month': '📅 Whole month', - 'no_events': 'No events', - 'no_lessons': 'No lessons', - 'time_unknown': 'unknown', - 'free_period': 'Free', - 'unsubscribed': '🔕 You have unsubscribed from notifications.', - 'diary_title': '📅 Diary — choose a period:', - 'diary_week_title': '📅 Week {start} – {end}', - 'schedule_title': '📅 {weekday} — {class_num} class', - 'loading_diary': '🔄 Loading diary...', - 'diary_load_failed': '❌ Failed to load diary.', - 'diary_no_session': '❌ Failed to load diary. No session or error.', - 'diary_next_month': '❌ Next month data is not available. Please visit the website.', - 'schedule_load_failed': '❌ Failed to load schedule.', - 'class_not_set': '❌ Class is not set. Use /setclass.', - 'class_not_found': '❌ No classes found in the schedule.', - 'select_class': '🎒 Choose a class — it will be saved and not asked again:', - 'admin_class_not_set': '⚠️ Admin has not set a class for this group yet. Use /setclass 11', - 'loading_schedule': '🔄 Loading schedule...', - 'invalid_class_num': '❌ Invalid class number.', - 'class_saved': '✅ Class {class_num} saved.', - 'admin_only_msg': '⚠️ Only an admin can set the class for this group.', - 'truncation': '\n\n✂️ ...(truncated)', - 'day_mon': 'Mon', - 'day_tue': 'Tue', - 'day_wed': 'Wed', - 'day_thu': 'Thu', - 'day_fri': 'Fri', - 'day_sat': 'Sat', - 'day_sun': 'Sun', - }, -} - -# --- Language Helper Functions --- - - -LANG_NAME_MAP = {'ru': 'Русский', 'uk': 'Українська', 'en': 'English'} - - -def get_user_language(user_id: int) -> str: - """Get user's preferred language from Redis. Default: English.""" - lang = redis_client.get(f'user:{user_id}:language') - return lang if lang in ['ru', 'uk', 'en'] else 'en' - - -def set_user_language(user_id: int, lang: str): - """Save user's language preference to Redis.""" - if lang in ['ru', 'uk', 'en']: - redis_client.set(f'user:{user_id}:language', lang) - logger.info(f'User {user_id} language set to {lang}') - - -def t(user_id: int, key: str, **kwargs) -> str: - """Translate message for user with optional formatting. - - Fallback chain: user lang → en → uk → ru, otherwise return the key. - """ - lang = get_user_language(user_id) - message = None - for fallback in (lang, 'en', 'uk', 'ru'): - message = TRANSLATIONS.get(fallback, {}).get(key) - if message is not None: - break - if message is None: - return key - if kwargs: - return message.format(**kwargs) - return message - - -def _tr(lang: str, key: str, **kwargs) -> str: - """Translate by explicit lang code (for format_* helpers). - - Fallback chain: lang → en → uk → ru, otherwise return the key. - """ - message = None - for fallback in (lang, 'en', 'uk', 'ru'): - message = TRANSLATIONS.get(fallback, {}).get(key) - if message is not None: - break - if message is None: - return key - if kwargs: - return message.format(**kwargs) - return message - - -def get_chat_language(chat_id: int) -> str: - """Get group chat language from Redis. Default: Ukrainian.""" - lang = redis_client.get(f'chat:{chat_id}:language') - return lang if lang in ['ru', 'uk', 'en'] else 'uk' - - -def set_chat_language(chat_id: int, lang: str): - """Save group chat language preference to Redis.""" - if lang in ['ru', 'uk', 'en']: - redis_client.set(f'chat:{chat_id}:language', lang) - logger.info(f'Chat {chat_id} language set to {lang}') - - -def resolve_lang(chat, user_id=None) -> str: - """Resolve effective language: private → user lang, groups → chat lang (default 'uk').""" - chat_id = getattr(chat, 'id', chat) - chat_type = getattr(chat, 'type', None) - if chat_type is None: - try: - is_private = int(chat_id) > 0 - except (TypeError, ValueError): - is_private = True - if is_private: - uid = user_id if user_id is not None else chat_id - return get_user_language(int(uid)) - return get_chat_language(int(chat_id)) - if chat_type in (ChatType.PRIVATE, 'private'): - uid = user_id if user_id is not None else chat_id - return get_user_language(int(uid)) - return get_chat_language(chat_id) - - -def t_chat(chat, user_id, key: str, **kwargs) -> str: - """Translate using chat-resolved language (private → user, groups → chat 'uk' default).""" - return _tr(resolve_lang(chat, user_id), key, **kwargs) - - -def get_language_keyboard(user_id: int | None = None): - """Generate language selection keyboard.""" - lang = get_user_language(user_id) if user_id is not None else 'en' - keyboard = [ - [ - InlineKeyboardButton(_tr(lang, 'flag_ru'), callback_data='lang_ru'), - InlineKeyboardButton(_tr(lang, 'flag_uk'), callback_data='lang_uk'), - ], - [ - InlineKeyboardButton(_tr(lang, 'flag_en'), callback_data='lang_en'), - ], - ] - return InlineKeyboardMarkup(keyboard) - - -# --- Helper Functions --- - - -def is_whitelisted(user_id: int) -> bool: - """Check if user is allowed to use the bot.""" - if user_id == ADMIN_ID: - return True - - enabled = redis_client.get(KEY_WHITELIST_ENABLED) - if enabled == '0': # Whitelist disabled - return True - - return redis_client.sismember(KEY_WHITELIST, str(user_id)) - - -async def is_group_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: - """Check if the user is an administrator in the group.""" - user = update.effective_user - chat = update.effective_chat - - if chat.type in [ChatType.PRIVATE, 'private']: - return True - - try: - member = await context.bot.get_chat_member(chat.id, user.id) - return member.status in [ChatMember.OWNER, ChatMember.ADMINISTRATOR] - except Exception as e: - logger.error(f'Failed to check admin status: {e}') - return False - - -def get_admin_keyboard(user_id: int): - """Generate admin panel keyboard.""" - whitelist_enabled = redis_client.get(KEY_WHITELIST_ENABLED) != '0' - toggle_text = t(user_id, 'toggle_whitelist_disable') if whitelist_enabled else t(user_id, 'toggle_whitelist_enable') - - keyboard = [ - [InlineKeyboardButton(toggle_text, callback_data='toggle_whitelist')], - [InlineKeyboardButton(t(user_id, 'view_whitelist'), callback_data='view_whitelist')], - [InlineKeyboardButton(t(user_id, 'view_subscribers'), callback_data='view_subscribers')], - [InlineKeyboardButton(t(user_id, 'force_check'), callback_data='force_check')], - ] - return InlineKeyboardMarkup(keyboard) - - -# --- Diary Functions --- - -SCHEDULE_WEEKDAYS_FULL = { - 'uk': ['Понеділок', 'Вівторок', 'Середа', 'Четвер', "П'ятниця", 'Субота', 'Неділя'], - 'ru': ['Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота', 'Воскресенье'], - 'en': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], -} -# Canonical weekday names used in callback_data and schedule lookup (site language). -SCHEDULE_WEEKDAYS_CANONICAL = SCHEDULE_WEEKDAYS_FULL['uk'] -SCHEDULE_CACHE_TTL = 18000 # 5 hours - - -def _norm_day(s): - """Normalize weekday name for tolerant matching (case, spaces, apostrophes).""" - if not isinstance(s, str): - return '' - s = s.strip().casefold() - for _q in ('\u2019', '\u2018', '\u02bc', '`'): - s = s.replace(_q, "'") - s = re.sub(r'\s+', ' ', s) - return s.strip() - - -_SCHEDULE_WEEKDAYS_CANONICAL_NORM = [_norm_day(d) for d in SCHEDULE_WEEKDAYS_CANONICAL] - -DIARY_WEEKDAYS_SHORT = { - 'uk': ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд'], - 'ru': ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'], - 'en': ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'], -} - - -def get_diary_keyboard(user_id: int | None = None): - today = datetime.now() - lang = get_user_language(user_id) if user_id is not None else 'en' - today_label = _tr(lang, 'today') - tomorrow_label = _tr(lang, 'tomorrow') - week_label = _tr(lang, 'week') - month_label = _tr(lang, 'month') - keyboard = [ - [ - InlineKeyboardButton(f'{today_label} ({today.day}.{today.month:02d})', callback_data='diary_today'), - InlineKeyboardButton(tomorrow_label, callback_data='diary_tomorrow'), - ], - [ - InlineKeyboardButton(week_label, callback_data='diary_week'), - InlineKeyboardButton(month_label, callback_data='diary_month'), - ], - ] - return InlineKeyboardMarkup(keyboard) - - -def _parse_calendar_html(table_html: str) -> tuple: - """Parse calendar HTML table into (month_text, {day_num: {weekday, events}}). - - Each event is a dict: {'title': str, 'id': site event id | None, 'time': 'HH:MM' | None}. - """ - days = {} - weekdays = [] - rows = re.findall(r']*>(.*?)', table_html, re.DOTALL) - - month_text = '' - for r_idx, row in enumerate(rows): - cells = re.findall(r']*>(.*?)', row, re.DOTALL) - - if r_idx == 0: - # Month navigation row: extract "Травень 2026" from nav text - raw = re.sub(r'<[^>]+>', ' ', row).strip() - raw = re.sub(r'\s+', ' ', raw) - m = re.search(r'([А-Яа-яіїєґ\']+\s*:?\s*\d{4})', raw) - month_text = m.group(1).replace(' : ', ' ').strip() if m else raw - elif r_idx == 1: - # Day names row - for cell in cells: - name = re.sub(r'<[^>]+>', '', cell).strip() - if name: - weekdays.append(name) - else: - # Data rows: each cell = a day - for col_idx, cell in enumerate(cells): - # Extract day number — first number in the cell text - text = re.sub(r'<[^>]+>', ' ', cell).strip() - text = re.sub(r'\s+', ' ', text) - dm = re.match(r'(\d+)', text) - if not dm: - continue - day_num = dm.group(1) - - # Extract events: title attribute (full name) of ALL tags inside the cell. - # Each event keeps its site data-event-id and a 'time' slot (filled later - # from the AJAX popup by fetch_diary_data). 'time' is 'HH:MM' or None. - events = [] - for a_match in re.finditer(r']*>(.*?)', cell, re.DOTALL): - a_tag = a_match.group(0) - # Prefer the title attribute (contains full name, not truncated) - title_m = re.search(r'title\s*=\s*"([^"]*)"', a_tag) - et = title_m.group(1).strip() if title_m else re.sub(r'<[^>]+>', '', a_match.group(1)).strip() - if not et: - continue - id_m = re.search(r'data-event-id\s*=\s*"?(\d+)"?', a_tag) - events.append( - { - 'title': et, - 'id': id_m.group(1) if id_m else None, - 'time': None, - } - ) - - weekday = weekdays[col_idx] if col_idx < len(weekdays) else '' - days[day_num] = {'weekday': weekday, 'weekday_idx': col_idx, 'events': events} - - return month_text, days - - -async def _collect_event_times(page) -> dict: - """Read event times straight from the rendered calendar DOM. - - The diary page embeds `div.event-full-info[data-event-full-info-id]` - containing `span.data` (e.g. "2026-09-02 16:30:00") for every event, so no - AJAX popup clicks are needed. Returns {event_id: 'HH:MM'} for events that - have a date; events without one are simply skipped. - """ - times_by_id: dict[str, str] = {} - try: - raw_times = await page.evaluate( - """() => { - const out = {}; - for (const div of document.querySelectorAll( - 'div.event-full-info[data-event-full-info-id]' - )) { - const id = div.getAttribute('data-event-full-info-id'); - const date_span = div.querySelector('p.date span.data'); - if (id && date_span) { - out[id] = date_span.textContent.trim(); - } - } - return out; - }""" - ) - except Exception as e: - logger.warning(f'Failed to read diary event times from DOM: {e}') - return times_by_id - - for event_id, time_text in (raw_times or {}).items(): - if not time_text: - continue - m = re.search(r'(\d{1,2}:\d{2})', time_text) - if m: - times_by_id[event_id] = m.group(1) - - logger.info(f'Diary event times collected for {len(times_by_id)} events') - return times_by_id - - -async def fetch_diary_data(phpsessid: str) -> dict | None: - logger.info('Fetching diary data via Playwright...') - try: - async with asyncio.timeout(60): - async with async_playwright() as p: - browser = await asyncio.wait_for(p.chromium.connect(PLAYWRIGHT_WS), timeout=15) - try: - context_browser = await browser.new_context(user_agent=USER_AGENT) - await context_browser.add_cookies( - [{'name': 'PHPSESSID', 'value': phpsessid, 'domain': 'edu.edu.vn.ua', 'path': '/'}] - ) - page = await context_browser.new_page() - - try: - await asyncio.wait_for(page.goto(DIARY_URL, wait_until='domcontentloaded'), timeout=30) - await page.wait_for_selector('table.calendar', timeout=10000) - await page.wait_for_timeout(1500) - - table_html = await page.evaluate(""" - () => { - const t = document.querySelector('table.calendar'); - return t ? t.outerHTML : null; - } - """) - if not table_html: - logger.error('table.calendar not found in DOM') - return None - - # Debug: save HTML for troubleshooting - with contextlib.suppress(Exception), open('/tmp/diary_debug.html', 'w', encoding='utf-8') as f: # noqa: S108 - f.write(table_html) - - month_text, days = _parse_calendar_html(table_html) - - # Read event times by opening each event's AJAX popup. - times_by_id = await _collect_event_times(page) - if times_by_id: - for day_data in days.values(): - for ev in day_data.get('events', []): - eid = ev.get('id') - if eid and eid in times_by_id: - ev['time'] = times_by_id[eid] - - logger.info( - f'Diary parsed: month={month_text!r}, days_with_events={sum(1 for d in days.values() if d["events"])}/{len(days)}' - ) - - return {'monthFullText': month_text, 'days': days} - - except Exception as e: - logger.error(f'Error parsing diary: {e}') - return None - finally: - with contextlib.suppress(Exception): - await asyncio.wait_for(page.close(), timeout=5) - with contextlib.suppress(Exception): - await asyncio.wait_for(context_browser.close(), timeout=5) - finally: - with contextlib.suppress(Exception): - await asyncio.wait_for(browser.close(), timeout=5) - except TimeoutError: - logger.error('Diary fetch timed out (60s)') - return None - except Exception as e: - logger.error(f'Playwright error in diary fetch: {e}') - return None - - -def _parse_diary_month(text: str) -> str: - match = re.search(r'([А-Яа-яіїєґ\']+\s*:\s*\d{4})', text) - if match: - return match.group(1).replace(' : ', ' ').strip() - return text.strip() - - -def _format_event_line(event, lang: str) -> str: - """Render one diary event line: 📌 title, with time in parens when known. - - '08:00' is the site's placeholder for "no time specified" — show a localized - 'time_unknown' mark instead. Unknown/absent time renders as before, no parens. - """ - if isinstance(event, dict): - title = escape(str(event.get('title') or '')) - tme = event.get('time') - else: - title = escape(str(event)) - tme = None - if tme == '08:00': - return f'📌 {title} ({_tr(lang, "time_unknown")})' - if tme: - return f'📌 {title} ({escape(str(tme))})' - return f'📌 {title}' - - -def format_diary_day(data: dict, day_num: int, lang: str = 'en') -> str: - days = data.get('days', {}) - month_str = _parse_diary_month(data.get('monthFullText', '')) - day_data = days.get(str(day_num)) - lines = [f'📅 {day_num} {month_str}', '─' * 18] - if not day_data or not day_data.get('events'): - lines.append(_tr(lang, 'no_events')) - else: - for e in day_data['events']: - lines.append(_format_event_line(e, lang)) - lines.append(f'\n🔗 {DIARY_URL}') - return '\n'.join(lines) - - -def format_diary_week(data: dict, today: datetime, lang: str = 'en') -> str: - days = data.get('days', {}) - _parse_diary_month(data.get('monthFullText', '')) - monday = today - timedelta(days=today.weekday()) - friday = monday + timedelta(days=4) - start = f'{monday.day}.{monday.month}' - end = f'{friday.day}.{friday.month}' - short = DIARY_WEEKDAYS_SHORT.get(lang, DIARY_WEEKDAYS_SHORT['en']) - lines = [_tr(lang, 'diary_week_title', start=start, end=end) + '\n'] - for i in range(5): - d = monday + timedelta(days=i) - day_data = days.get(str(d.day)) - lines.append(f'─ {short[i]} {d.day}.{d.month} ─') - if not day_data or not day_data.get('events'): - lines.append(_tr(lang, 'no_events') + '\n') - else: - for e in day_data['events']: - lines.append(_format_event_line(e, lang)) - lines.append('') - lines.append(f'🔗 {DIARY_URL}') - return '\n'.join(lines) - - -def format_diary_month(data: dict, lang: str = 'en') -> str: - days = data.get('days', {}) - month_str = _parse_diary_month(data.get('monthFullText', '')) - lines = [f'📅 {month_str}\n'] - for day_num in sorted(days.keys(), key=int): - day_data = days[day_num] - if day_data.get('weekday_idx', 0) >= 5: - continue - if day_data.get('weekday', '').strip().lower() in ( - 'субота', - 'суббота', - 'saturday', - 'неділя', - 'воскресенье', - 'sunday', - ): - continue - events = day_data.get('events', []) - weekday = day_data.get('weekday', '') - lines.append(f'─ {weekday} {day_num} ─') - if not events: - lines.append(_tr(lang, 'no_events') + '\n') - else: - for e in events: - lines.append(_format_event_line(e, lang)) - lines.append('') - lines.append(f'🔗 {DIARY_URL}') - return '\n'.join(lines) - - -async def _get_diary_data(context: ContextTypes.DEFAULT_TYPE) -> dict | None: - cached = context.user_data.get('diary_cache') - now_ts = time.time() - if cached and (now_ts - cached.get('timestamp', 0)) < 300: - return cached['data'] - phpsessid = redis_client.get(KEY_PHPSESSID) - if not phpsessid: - return None - data = await fetch_diary_data(phpsessid) - if data: - context.user_data['diary_cache'] = {'data': data, 'timestamp': now_ts} - return data - - -# --- Schedule Functions --- - - -def get_schedule_day_keyboard(user_id: int | None = None): - lang = get_user_language(user_id) if user_id is not None else 'en' - full = SCHEDULE_WEEKDAYS_FULL.get(lang, SCHEDULE_WEEKDAYS_FULL['en']) - today = datetime.now() - tomorrow = today + timedelta(days=1) - today_label = _tr(lang, 'today') - tomorrow_label = _tr(lang, 'tomorrow') - short_keys = ['day_mon', 'day_tue', 'day_wed', 'day_thu', 'day_fri'] - keyboard = [ - [ - InlineKeyboardButton( - f'{today_label} ({full[today.weekday()]})', - callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[today.weekday()]}', - ), - InlineKeyboardButton( - f'{tomorrow_label} ({full[tomorrow.weekday()]})', - callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[tomorrow.weekday()]}', - ), - ], - [ - InlineKeyboardButton( - _tr(lang, short_keys[0]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[0]}' - ), - InlineKeyboardButton( - _tr(lang, short_keys[1]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[1]}' - ), - InlineKeyboardButton( - _tr(lang, short_keys[2]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[2]}' - ), - ], - [ - InlineKeyboardButton( - _tr(lang, short_keys[3]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[3]}' - ), - InlineKeyboardButton( - _tr(lang, short_keys[4]), callback_data=f'schedule_day_{SCHEDULE_WEEKDAYS_CANONICAL[4]}' - ), - ], - ] - return InlineKeyboardMarkup(keyboard) - - -def get_schedule_class_keyboard(classes: list): - rows = [] - for i in range(0, len(classes), 4): - row = [InlineKeyboardButton(str(c), callback_data=f'schedule_class_{c}') for c in classes[i : i + 4]] - rows.append(row) - return InlineKeyboardMarkup(rows) - - -def _get_chat_class(chat, user_id) -> str | None: - """Get stored schedule class for a private user or a group chat.""" - if chat.type == 'private': - return redis_client.get(f'user:{user_id}:schedule_class') - return redis_client.get(f'chat:{chat.id}:schedule_class') - - -def _set_chat_class(chat, user_id, class_num) -> None: - """Save schedule class for a private user or a group chat.""" - if chat.type == 'private': - redis_client.set(f'user:{user_id}:schedule_class', str(class_num)) - else: - redis_client.set(f'chat:{chat.id}:schedule_class', str(class_num)) - - -def _parse_schedule_cell(cell_html: str) -> list: - """Parse a single schedule cell, returning list of {subject, note, teacher}.""" - lessons = [] - parts = re.split(r'', cell_html, flags=re.IGNORECASE) - for part in parts: - part = re.sub(r'', '', part, flags=re.DOTALL) - part = re.sub(r'