diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 0000000..60128ea --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,10 @@ +version = 1 + +[[analyzers]] +name = "shell" + +[[analyzers]] +name = "python" + + [analyzers.meta] + runtime_version = "3.x.x" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a2b070b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.unused +Downloads +.venv +volumes +.env +.env.* +my_account.session +.gitignore +README.md +.git +uv.lock +.deepsource.toml \ No newline at end of file diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 1d95791..0000000 --- a/.editorconfig +++ /dev/null @@ -1,24 +0,0 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.{yml,yaml}] -indent_size = 2 - -[*.{json,jsonc}] -indent_size = 2 - -[*.md] -trim_trailing_whitespace = false - -[*.py] -indent_size = 4 - -[{Makefile,makefile}] -indent_style = tab diff --git a/.env.account.example b/.env.account.example new file mode 100644 index 0000000..583a44e --- /dev/null +++ b/.env.account.example @@ -0,0 +1,3 @@ +API_ID="" +API_HASH="" +STRINGSESSION="" \ No newline at end of file diff --git a/.env.example b/.env.example index 95493ef..b1ba859 100644 --- a/.env.example +++ b/.env.example @@ -1,70 +1,14 @@ - - -#=============================== -# Basic auth credentials -#=============================== -# -# -# -#=============================== - -#=============================================== -#BEGIN TRAEFIK ENVIRONMENT VARIABLES =========== -#=============================================== - -#=============================================== -# General Traefik Environment Variables -#=============================================== -HOST=hostname -EMAIL=your@email.here -CF_DNS_API_TOKEN=API_TOKEN_HERE -CF_EMAIL=your_cloudflare@email.here -TZ=Europe/Berlin - -#=============================================== -# Dockmon Traefik Configuration File -#=============================================== -DOCKMON_APPNAME=dockmon -DOCKMON_SUBDOMEN=dockmon -#=============================================== -# Dashboard Traefik Environment Variables -#=============================================== -DASHBOARD_APPNAME=traefik -DASHBOARD_SUBDOMEN=traefik -#=============================================== -# Watercrawl Traefik Environment Variables -#=============================================== -WATERCRAWL_APPNAME=watercrawl -WATERCRAWL_SUBDOMEN=watercrawl -#=============================================== -# n8n Traefik Environment Variables -#=============================================== -N8N_APPNAME=n8n -N8N_SUBDOMEN=n8n -#=============================================== -# Glance Traefik Environment Variables -#=============================================== -GLANCE_APPNAME=glance -GLANCE_SUBDOMEN=glance -#=============================================== -# AdGuard Traefik Environment Variables -#=============================================== -ADGUARD_APPNAME=adguard -ADGUARD_SUBDOMEN=adguard -#=============================================== -# Portainer Traefik Environment Variables -#=============================================== -PORTAINER_APPNAME=portainer -PORTAINER_SUBDOMEN=portainer -#=============================================== -# Nextcloud Traefik Environment Variables -#=============================================== -NEXTCLOUD_APPNAME=nextcloud -NEXTCLOUD_SUBDOMEN=nextcloud -#=============================================== -# Aio Traefik Environment Variables -#=============================================== -NEXTCLOUD_AIO_APPNAME=nextcloud-aio -NEXTCLOUD_AIO_SUBDOMEN=nextcloud-aio -# END OF TRAEFIK ENVIRONMENT VARIABLES -#=============================================== \ No newline at end of file +# apiflash api key only for webshot plugin +APIFLASH_KEY="" +# gemini api key only for gemini plugin +GEMINI_KEY="" +# VT api key only for VirusTotal plugin +VT_KEY="" +# rmbg api key only for removebg plugin +RMBG_KEY="" +# cohere api key only for cohere plugin +COHERE_KEY="" +# sqlite/sqlite3 or mongo/mongodb +DATABASE_TYPE="" +# file name for sqlite3, database name for mongodb +DATABASE_NAME="" diff --git a/.gitea/workflows/prod_deploy.yaml b/.gitea/workflows/prod_deploy.yaml deleted file mode 100644 index 1ff8bf6..0000000 --- a/.gitea/workflows/prod_deploy.yaml +++ /dev/null @@ -1,39 +0,0 @@ -name: Deploy to Server -run-name: Deploying to ${{ runner.os}} server on ${{ gitea.ref }} -on: - push: - branches: - - main - - ci/gitea-actions -jobs: - deploy: - runs-on: prod - steps: - - name: Fetch and Diff Analysis - id: diff - run: | - cd ${{ secrets.PROD_DIR }} - git fetch origin main - CHANGES=$(git diff --name-only HEAD origin/main | cut -d/ -f1 | sort -u | tr '\n' ' ') - echo "dirs=$CHANGES" >> $GITHUB_OUTPUT - echo "Changed dirs: $CHANGES" - - - name: Sync Server Files - run: | - cd ${{ secrets.PROD_DIR }} - git reset --hard origin/main - echo "Server files synced with origin/main" - - - name: Deploy Services - run: | - cd ${{ secrets.PROD_DIR }} - for dir in ${{ steps.diff.outputs.dirs }}; do - if [ -d "$dir" ] && ([ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yaml" ]); then - echo ">>> Deploying $dir" - cd "$dir" - DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=plain docker compose up -d --build --no-color - cd .. - else - echo ">>> Skipping $dir: no compose file found" - fi - done diff --git a/.github/workflows/prod_deploy.yaml b/.github/workflows/prod_deploy.yaml deleted file mode 100644 index 0047066..0000000 --- a/.github/workflows/prod_deploy.yaml +++ /dev/null @@ -1,41 +0,0 @@ -## BINARY MODE, USE WITH THE GITHUB RUNNER BINARY INSTALLED ON THE SERVER - -name: Deploy to Server -run-name: Deploying onto server on ${{ github.ref }} -on: - push: - branches: - - main - - ci/actions -jobs: - deploy: - runs-on: [prod, self-hosted] - steps: - - name: Fetch and Diff Analysis - id: diff - run: | - cd ${{ secrets.PROD_DIR }} - git fetch origin main - CHANGES=$(git diff --name-only HEAD origin/main | cut -d/ -f1 | sort -u | tr '\n' ' ') - echo "dirs=$CHANGES" >> $GITHUB_OUTPUT - echo "Changed dirs: $CHANGES" - - - name: Sync Server Files - run: | - cd ${{ secrets.PROD_DIR }} - git reset --hard origin/main - echo "Server files synced with origin/main" - - - name: Deploy Services - run: | - cd ${{ secrets.PROD_DIR }} - for dir in ${{ steps.diff.outputs.dirs }}; do - if [ -d "$dir" ] && ([ -f "$dir/compose.yaml" ] || [ -f "$dir/docker-compose.yaml" ]); then - echo ">>> Deploying $dir" - cd "$dir" - DOCKER_BUILDKIT=1 BUILDKIT_PROGRESS=plain docker compose up -d --build --no-color - cd .. - else - echo ">>> Skipping $dir: no compose file found" - fi - done diff --git a/.gitignore b/.gitignore index 2c5f973..b166610 100644 --- a/.gitignore +++ b/.gitignore @@ -1,69 +1,7 @@ -# FreeFileSync -sync.ffs_lock -.sync.ffs_db +.DS_Store +.gitattributes -# Copyparty -*.hist/ - -# Volumes, configs and data directories -gitea/gitea-db/ -gitea/gitea-data/* -n8n/n8n-data/* -n8n/n8n-node-data/* -adguardhome/conf/* -dockmon/data/* -portainer/portainer_data/* -metube/MeTube_downloads -uptime-kuma/data/ -termix/termix-data/* -cfddns/config.json -checkmk/checkmk/* -downtify/Downtify_downloads -headscale/config/* -headscale/data/* -searxng/core-config/* - -# Steaming services files -streaming/jellyfin/* -streaming/jellyseerr/* -streaming/sonarr/* -streaming/radarr/* -streaming/data/* -streaming/qbittorrent/* -streaming/prowlarr/* - -# Homepage -homepages/forust_files/.well-known/* - -# Traefik files -traefik/letsencrypt/acme.json -traefik/dynamic/fileservers.yml -traefik/dynamic/*.local.y*ml.* -traefik/dynamic/*.external.y*ml - - -traefik/logs/* - -# SSL Certificates -adguardhome/certs/* -traefik/certs/* -certs/ - -# Monitoring -monitoring/prometheus.yml - -# Python -.python-version -venv/ -pyc -unknown_errors.txt -moonlogs.txt -thumb.jpg -antipm_pic.jpg -musicbot/ -.trunk/ -previous_profiles/ -.python-version +.vscode /modules/__pycache__/ __pycache__/ *.session @@ -73,37 +11,9 @@ __pycache__/ *-journal /venv/ .venv/ +/downloads/ +/Downloads/ +config.ini -# DataSecurity -replacements.txt - -# Vscode -.vscode - -# Git -.gitattributes -# Gitea/github Runners -.runner - -# Misc -.DS_Store -.idea - -# Temp files -edu_master/temp/ -temp/* - -# Environment -.env -.env.anna -.env.forust -.env.* -!*example - -# kubernetes -*/k8s/*secret* -!*/k8s/*secret*.example -traefik/k8s/local-tls.yaml -converters/k8s/config.yaml -convertx/k8s/config.yaml -traefik/k8s/crowdsec-middleware.yaml +k8s/*/*secret*.yaml +!k8s/account-secrets.yaml.example \ No newline at end of file diff --git a/.hadolint.yaml b/.hadolint.yaml deleted file mode 100644 index de4bacb..0000000 --- a/.hadolint.yaml +++ /dev/null @@ -1,10 +0,0 @@ -ignored: - - DL3008 - - DL3042 - - DL3018 - - DL3059 -trustedRegistries: - - docker.io - - ghcr.io - - quay.io - - gcr.forust.xyz diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index 9dfeb53..0000000 --- a/.markdownlint.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "default": true, - "MD013": false, - "MD024": false, - "MD033": false, - "MD041": false, - "MD046": false -} diff --git a/.prettierrc.yaml b/.prettierrc.yaml deleted file mode 100644 index baf4658..0000000 --- a/.prettierrc.yaml +++ /dev/null @@ -1,7 +0,0 @@ -bracketSameLine: true -htmlWhitespaceSensitivity: css -printWidth: 120 -tabWidth: 2 -trailingComma: all -proseWrap: preserve -endOfLine: lf diff --git a/.unused/Aptfile b/.unused/Aptfile new file mode 100644 index 0000000..4b48999 --- /dev/null +++ b/.unused/Aptfile @@ -0,0 +1,5 @@ +git +wget +ffmpeg +mediainfo +yt-dlp diff --git a/.unused/Cloudfile b/.unused/Cloudfile new file mode 100644 index 0000000..21218db --- /dev/null +++ b/.unused/Cloudfile @@ -0,0 +1,10 @@ +FROM python:3.11 +WORKDIR /app +COPY . /app +RUN apt-get -qq update && apt-get -qq install -y git wget ffmpeg mediainfo \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +RUN python -m venv --copies /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +RUN pip install --no-cache-dir -r requirements.txt +CMD ["bash", "cloud.sh"] diff --git a/.unused/Procfile b/.unused/Procfile new file mode 100644 index 0000000..ad9aac5 --- /dev/null +++ b/.unused/Procfile @@ -0,0 +1 @@ +web: bash cloud.sh diff --git a/.unused/app.json b/.unused/app.json new file mode 100644 index 0000000..f0d3493 --- /dev/null +++ b/.unused/app.json @@ -0,0 +1,82 @@ +{ + "name": "Moon-userbot", + "description": "A Simple, Fast, Customizable, Ai powered Userbot for Telegram with most easiest installation.", + "logo": "https://camo.githubusercontent.com/1efdfa6416b3cd08471d865ca9ebf0fbdd38602ea95425f18a9bec6aeeefe49b/68747470733a2f2f74656c656772612e70682f66696c652f3063333763326662306631393463633163303334342e6a7067", + "keywords": ["telegram", "Moon-userbot", "bot", "python", "pyrogram"], + "env": { + "API_ID": { + "description": "Get it from my.telegram.org", + "required": true + }, + "API_HASH": { + "description": "Get it from my.telegram.org", + "required": true + }, + "PM_LIMIT": { + "description": "set your pm permit warn limit, default is 4", + "value": "4", + "required": true + }, + "SECOND_SESSION": { + "description": "Pyrorogram v2 session string for music bot, only fill this if you want to use music bot feature", + "required": false + }, + "DATABASE_URL": { + "description": "ONLY for MongoDB, get it from https://cloud.mongodb.com", + "required": false + }, + "DATABASE_NAME": { + "description": "set database name, if using sqlite then change it to `db.sqlite3`", + "value": "moonub", + "required": true + }, + "DATABASE_TYPE": { + "description": "set to sqlite3 if want to use sqlite3 db", + "value": "mongodb", + "required": true + }, + "STRINGSESSION": { + "description": "Pyrogram V2 Session String. Don't use bots or else you'll be responsible for your actions. Gen yourself https://github.com/The-MoonTg-project/Moon-Userbot?tab=readme-ov-file#-optional-vars.", + "required": true + }, + "APIFLASH_KEY": { + "description": "ONLY, If you want to use web screenshot plugin You can get it from https://apiflash.com/dashboard/access_keys", + "value": "123456779:ABCDE", + "required": true + }, + "RMBG_KEY": { + "description": "ONLY, If you want to use removbg plugin You can get it from https://www.remove.bg/dashboard#api-key", + "value": "123456779:ABCDE", + "required": true + }, + "VT_KEY": { + "description": "ONLY, If you want to use VirusTotal plugin You can get it from https://www.virustotal.com/gui/", + "value": "123456779:ABCDE", + "required": true + }, + "GEMINI_KEY": { + "description": "ONLY, If you want to use gemini ai plugin You can get it from https://makersuite.google.com/app/apikey", + "value": "123456779:ABCDE", + "required": true + }, + "COHERE_KEY": { + "description": "ONLY, If you want to use cohere ai plugin You can get it from https://dashboard.cohere.com/api-keys", + "value": "123456779:ABCDE", + "required": true + } + }, + "buildpacks": [ + { + "url": "heroku/python" + }, + { + "url": "https://github.com/heroku/heroku-buildpack-apt" + }, + { + "url": "https://github.com/heroku/heroku-buildpack-activestorage-preview" + }, + { + "url": "https://github.com/The-MoonTg-project/heroku-buildpack-yt-dlp" + } + ] +} diff --git a/.unused/app.py b/.unused/app.py new file mode 100644 index 0000000..65a84b2 --- /dev/null +++ b/.unused/app.py @@ -0,0 +1,12 @@ +from flask import Flask + +app = Flask(__name__) + + +@app.route('/') +def hello_world(): + return 'This is Moon' + + +if __name__ == '__main__': + app.run() diff --git a/.unused/cloud.sh b/.unused/cloud.sh new file mode 100644 index 0000000..0f32f6b --- /dev/null +++ b/.unused/cloud.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +cat <<'EOF' + _ ____ ____ _ +/ \__/|/ _ \/ _ \/ \ /| +| |\/||| / \|| / \|| |\ || +| | ||| \_/|| \_/|| | \|| +\_/ \|\____/\____/\_/ \| + +Copyright (C) 2020-2023 by MoonTg-project@Github, < https://github.com/The-MoonTg-project >. +This file is part of < https://github.com/The-MoonTg-project/Moon-Userbot > project, +and is released under the "GNU v3.0 License Agreement". +Please see < https://github.com/The-MoonTg-project/Moon-Userbot/blob/main/LICENSE > +All rights reserved. +EOF + +gunicorn app:app --daemon && python main.py diff --git a/.unused/render.yaml b/.unused/render.yaml new file mode 100644 index 0000000..1172ce1 --- /dev/null +++ b/.unused/render.yaml @@ -0,0 +1,17 @@ +services: + - type: worker + name: Moon-Userbot + runtime: docker + repo: https://github.com/The-MoonTg-project/Moon-Userbot + plan: starter + envVars: + - key: STABILITY_KEY + sync: false + - key: CLARIFAI_PAT + sync: false + - key: .env + sync: false + region: oregon + dockerContext: . + dockerfilePath: ./Dockerfile +version: "1" diff --git a/.unused/run_command.txt b/.unused/run_command.txt new file mode 100644 index 0000000..974c034 --- /dev/null +++ b/.unused/run_command.txt @@ -0,0 +1 @@ +gunicorn app:app & python3 main.py \ No newline at end of file diff --git a/.yamllint b/.yamllint deleted file mode 100644 index 087c860..0000000 --- a/.yamllint +++ /dev/null @@ -1,22 +0,0 @@ -extends: default - -rules: - comments: - min-spaces-from-content: 1 - comments-indentation: false - document-start: disable - line-length: disable - braces: - min-spaces-inside: 0 - max-spaces-inside: 1 - brackets: - min-spaces-inside: 0 - max-spaces-inside: 1 - indentation: - spaces: 2 - indent-sequences: consistent - truthy: - allowed-values: - - "true" - - "false" - - "on" diff --git a/.zed/settings.json b/.zed/settings.json deleted file mode 100644 index 042dac8..0000000 --- a/.zed/settings.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "tab_size": 2, - "soft_wrap": "prefer_line", - "preferred_line_length": 120, - "format_on_save": "on", - "languages": { - "YAML": { - "tab_size": 2, - "hard_tabs": false, - "format_on_save": "on", - "formatter": { - "language_server": { "name": "yaml-language-server" } - } - }, - "Python": { - "tab_size": 4, - "format_on_save": "on", - "formatter": { - "language_server": { "name": "ruff" } - } - } - }, - "lsp": { - "yaml-language-server": { - "settings": { - "yaml": { - "schemas": { - "kubernetes": ["**/k8s/*.yaml", "**/k8s/*.yml"] - }, - "validate": true, - "completion": true, - "format": { - "enable": true - } - } - } - } - } -} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6148d41 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim AS builder + +WORKDIR /src +COPY requirements.txt . +RUN pip install --no-cache-dir --user -r requirements.txt + +FROM python:3.11-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git \ + mediainfo \ + wget \ + xz-utils \ + && rm -rf /var/lib/apt/lists/* + +RUN wget -qO- https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz | \ + tar xJ && \ + cp ffmpeg-*-static/ffmpeg /usr/local/bin/ && \ + cp ffmpeg-*-static/ffprobe /usr/local/bin/ && \ + rm -rf ffmpeg-* + +COPY --from=builder /root/.local /usr/local +COPY . /app +WORKDIR /app + +ENV PYTHONUNBUFFERED=1 + +CMD ["python", "-u", "main.py"] diff --git a/glance/assets/user.css b/README.md similarity index 100% rename from glance/assets/user.css rename to README.md diff --git a/adguardhome/compose.yaml b/adguardhome/compose.yaml deleted file mode 100644 index eafd5d5..0000000 --- a/adguardhome/compose.yaml +++ /dev/null @@ -1,48 +0,0 @@ -services: - adguard: - image: adguard/adguardhome:latest - container_name: adguardhome - restart: unless-stopped - ports: - - "53:53/tcp" - - "53:53/udp" - - "853:853/tcp" # DNS over TLS - # - "67:67/udp" # DHCP - # - "68:68/tcp" # DHCP - # - "3000:3000/tcp" - volumes: - - data:/opt/adguardhome/work - - ./conf:/opt/adguardhome/conf - - ./certs:/certs:ro - labels: - - "traefik.enable=true" - - "traefik.http.services.adguard.loadbalancer.server.port=3000" - - # Prod Router - - "traefik.http.routers.adguard.rule=Host(`dns.forust.xyz`) || Host(`adguard.forust.xyz`)" - - "traefik.http.routers.adguard.entrypoints=websecure" - - "traefik.http.routers.adguard.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.adguard-local.rule=Host(`adguard.workstation.internal`) || Host(`dns.workstation.internal`)" - - "traefik.http.routers.adguard-local.entrypoints=websecure" - - "traefik.http.routers.adguard-local.tls=true" - # Dev Router - - "traefik.http.routers.adguard-dev.rule=Host(`adguard.gigaforust.internal`) || Host(`dns.gigaforust.internal`)" - - "traefik.http.routers.adguard-dev.entrypoints=websecure" - - "traefik.http.routers.adguard-dev.tls=true" - # DoH Router - - "traefik.http.routers.dns-over-https.rule=(Host(`dns.forust.xyz` || Host(`adguard.forust.xyz`)) && PathPrefix(`/dns-query`))" - - "traefik.http.routers.dns-over-https.entrypoints=websecure" - - "traefik.http.routers.dns-over-https.tls.certresolver=letsencrypt" - - # Glance Metadata - - glance.name=adguard - - glance.url=https://adguard.forust.xyz/ - - glance.description=AdGuard Home is a network-wide software for blocking ads. - networks: - - proxy -volumes: - data: -networks: - proxy: - external: true diff --git a/adguardhome/k8s/adguard.yaml b/adguardhome/k8s/adguard.yaml deleted file mode 100644 index 6fceeae..0000000 --- a/adguardhome/k8s/adguard.yaml +++ /dev/null @@ -1,116 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: adguard-lb-service - namespace: adguard - annotations: - metallb.io/loadBalancerIPs: "192.168.80.3" -spec: - type: LoadBalancer - externalTrafficPolicy: Local - selector: - app: adguard - ports: - - name: dns-udp - port: 53 - targetPort: 53 - protocol: UDP - - name: dns-tcp - port: 53 - targetPort: 53 - protocol: TCP - - name: dot - port: 853 - targetPort: 853 - protocol: TCP ---- -apiVersion: v1 -kind: Service -metadata: - name: adguard-service - namespace: adguard -spec: - selector: - app: adguard - ports: - - port: 3000 - name: webui - targetPort: 3000 - - port: 53 - name: dns - targetPort: 53 - protocol: UDP - - port: 53 - name: dns-tcp - targetPort: 53 - protocol: TCP - - port: 853 - name: dot - targetPort: 853 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: adguard-deployment - namespace: adguard -spec: - replicas: 1 - selector: - matchLabels: - app: adguard - template: - metadata: - labels: - app: adguard - spec: - containers: - - name: adguard - image: adguard/adguardhome:latest - resources: - limits: - memory: "1.5Gi" - cpu: "300m" - requests: - memory: "500Mi" - cpu: "50m" - ports: - - containerPort: 3000 - name: webui - - containerPort: 53 - name: dns - - containerPort: 853 - name: dot - volumeMounts: - - name: adguard-data - mountPath: /opt/adguardhome/work - subPath: work - - name: adguard-data - mountPath: /opt/adguardhome/conf - subPath: conf - - name: adguard-certs - mountPath: /certs - readOnly: true - volumes: - - name: adguard-data - persistentVolumeClaim: - claimName: adguard-pvc - - name: adguard-certs - secret: - secretName: adguard-certs - items: - - key: tls.crt - path: fullchain.pem - - key: tls.key - path: privkey.pem ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: adguard-pvc - namespace: adguard -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 2Gi diff --git a/adguardhome/k8s/ingress.yaml b/adguardhome/k8s/ingress.yaml deleted file mode 100644 index ab065a3..0000000 --- a/adguardhome/k8s/ingress.yaml +++ /dev/null @@ -1,52 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: adguard-prod - namespace: adguard -spec: - entryPoints: - - websecure - routes: - - match: Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: adguard-service - port: 3000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: adguard-local - namespace: adguard -spec: - entryPoints: - - websecure - routes: - - match: Host(`adguard.workstation.internal`) || Host(`dns.workstation.internal`) || Host(`adguard.gigaforust.internal`) || Host(`dns.gigaforust.internal`) - kind: Rule - services: - - name: adguard-service - port: 3000 ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: adguard-doh - namespace: adguard -spec: - entryPoints: - - websecure - routes: - - match: (Host(`adguard.forust.xyz`) || Host(`dns.forust.xyz`)) && PathPrefix(`/dns-query`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: adguard-service - port: 3000 - tls: - certResolver: letsencrypt diff --git a/adguardhome/k8s/namespace.yaml b/adguardhome/k8s/namespace.yaml deleted file mode 100644 index 659575d..0000000 --- a/adguardhome/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: adguard diff --git a/adguardhome/k8s/secrets.yaml.example b/adguardhome/k8s/secrets.yaml.example deleted file mode 100644 index fb2b3ef..0000000 --- a/adguardhome/k8s/secrets.yaml.example +++ /dev/null @@ -1,10 +0,0 @@ -kubectl apply -f k8s/namespace.yaml && \ -kubectl create secret tls adguard-certs -n adguard \ - --cert=certs/fullchain.pem \ - --key=certs/privkey.pem --dry-run=client -o yaml > \ - k8s/secrets.yaml - -# OR WITH NO FILE CREATION: -kubectl create secret tls adguard-certs -n adguard \ - --cert=certs/fullchain.pem --key=certs/privkey.pem \ - --save-config \ No newline at end of file diff --git a/authentik/.env.example b/authentik/.env.example deleted file mode 100644 index a1222c4..0000000 --- a/authentik/.env.example +++ /dev/null @@ -1,20 +0,0 @@ -# =================================== -# Authentification app (authentik) - -# PostgresQL conf -PG_PASS=change_this_cuz_its_ur_db_pass -PG_USER=authentik # it's okay - -# Image Settings -AUTHENTIK_IMAGE=ghcr.io/goauthentik/server -AUTHENTIK_TAG=2025.10.2 - -# Networking -PORT_HTTP=9000 -PORT_HTTPS=9443 # btw likely already used by portainer - -AUTHENTIK_SECRET_KEY=super_secret_super_scary_authenik_key - -AUTHENTIK_BOOTSTRAP_PASSWORD=pls_change_this - -AUTHENTIK_ERROR_REPORTING__ENABLED=true # Or false to turn off \ No newline at end of file diff --git a/authentik/compose.yaml b/authentik/compose.yaml deleted file mode 100644 index e2c81da..0000000 --- a/authentik/compose.yaml +++ /dev/null @@ -1,95 +0,0 @@ -services: - postgresql: - image: docker.io/library/postgres:15-alpine - restart: unless-stopped - env_file: - - .env - environment: - POSTGRES_DB: ${PG_DB:-authentik} - POSTGRES_PASSWORD: ${PG_PASS:?database password required} - POSTGRES_USER: ${PG_USER:-authentik} - healthcheck: - interval: 30s - retries: 5 - start_period: 20s - test: - - CMD-SHELL - - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER} - timeout: 5s - volumes: - - database:/var/lib/postgresql/data - networks: - - authentik - - server: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2} - command: server - container_name: authentik-server - restart: unless-stopped - # ports: - # - ${PORT_HTTP:-9000}:9000 - # - ${PORT_HTTPS:-9443}:9443 - env_file: - - .env - environment: - AUTHENTIK_POSTGRESQL__HOST: postgresql - AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} - AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} - AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} - volumes: - - ./media:/media - - ./custom-templates:/templates - labels: - - "traefik.enable=true" - - "traefik.http.services.authentik-server.loadbalancer.server.port=9000" - - # Prod Router - - "traefik.http.routers.authentik-server.rule=Host(`auth.forust.xyz`)" - - "traefik.http.routers.authentik-server.entrypoints=websecure" - - "traefik.http.routers.authentik-server.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.authentik-server-local.rule=Host(`auth.workstation.internal`)" - - "traefik.http.routers.authentik-server-local.entrypoints=websecure" - - "traefik.http.routers.authentik-server-local.tls=true" - # Dev Router - - "traefik.http.routers.authentik-server-dev.rule=Host(`auth.gigaforust.internal`)" - - "traefik.http.routers.authentik-server-dev.entrypoints=websecure" - - "traefik.http.routers.authentik-server-dev.middlewares=security-headers@file" - - "traefik.http.routers.authentik-server-dev.tls=true" - networks: - - proxy - - authentik - depends_on: - postgresql: - condition: service_healthy - worker: - image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2025.10.2} - restart: unless-stopped - user: root - command: worker - env_file: - - .env - environment: - AUTHENTIK_POSTGRESQL__HOST: postgresql - AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik} - AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS} - AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required} - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./media:/media - - ./certs:/certs - - ./custom-templates:/templates - networks: - - authentik - depends_on: - postgresql: - condition: service_healthy -volumes: - database: - driver: local -networks: - authentik: - proxy: - external: true diff --git a/authentik/k8s/authentik.yaml b/authentik/k8s/authentik.yaml deleted file mode 100644 index 7df8954..0000000 --- a/authentik/k8s/authentik.yaml +++ /dev/null @@ -1,93 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: authentik-server-service - namespace: authentik -spec: - type: ClusterIP - selector: - app: authentik-server - ports: - - port: 9000 - targetPort: 9000 ---- -apiVersion: v1 -kind: Service -metadata: - name: authentik-worker-service - namespace: authentik -spec: - type: ClusterIP - selector: - app: authentik-worker - ports: - - port: 9000 - targetPort: 9000 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: authentik-server-deployment - namespace: authentik -spec: - replicas: 1 - selector: - matchLabels: - app: authentik-server - template: - metadata: - labels: - app: authentik-server - spec: - containers: - - name: authentik-server - image: ghcr.io/goauthentik/server:2025.10.2 - args: ["server"] - envFrom: - - configMapRef: - name: authentik-config - - secretRef: - name: authentik-secrets - ports: - - containerPort: 9000 - resources: - requests: - memory: "700Mi" - cpu: "300m" - limits: - memory: "1.5Gi" - cpu: "1000m" ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: authentik-worker-deployment - namespace: authentik -spec: - replicas: 1 - selector: - matchLabels: - app: authentik-worker - template: - metadata: - labels: - app: authentik-worker - spec: - containers: - - name: authentik-worker - image: ghcr.io/goauthentik/server:2025.10.2 - args: ["worker"] - securityContext: - runAsUser: 0 - envFrom: - - configMapRef: - name: authentik-config - - secretRef: - name: authentik-secrets - resources: - requests: - memory: "512Mi" - cpu: "300m" - limits: - memory: "1Gi" - cpu: "700m" diff --git a/authentik/k8s/config.yaml b/authentik/k8s/config.yaml deleted file mode 100644 index f888be8..0000000 --- a/authentik/k8s/config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: authentik-config - namespace: authentik -data: - AUTHENTIK_IMAGE: ghcr.io/goauthentik/server - AUTHENTIK_TAG: "2025.10.2" - AUTHENTIK_POSTGRESQL__HOST: authentik-postgres-service - AUTHENTIK_POSTGRESQL__NAME: authentik - AUTHENTIK_ERROR_REPORTING__ENABLED: "true" diff --git a/authentik/k8s/ingress.yaml b/authentik/k8s/ingress.yaml deleted file mode 100644 index c25087b..0000000 --- a/authentik/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: authentik-prod - namespace: authentik -spec: - entryPoints: - - websecure - routes: - - match: Host(`auth.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: authentik-server-service - port: 9000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: authentik-local - namespace: authentik -spec: - entryPoints: - - websecure - routes: - - match: Host(`auth.workstation.internal`) || Host(`auth.gigaforust.internal`) - kind: Rule - services: - - name: authentik-server-service - port: 9000 diff --git a/authentik/k8s/namespace.yaml b/authentik/k8s/namespace.yaml deleted file mode 100644 index bb24d8d..0000000 --- a/authentik/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: authentik diff --git a/authentik/k8s/postgresql.yaml b/authentik/k8s/postgresql.yaml deleted file mode 100644 index 8cb75c8..0000000 --- a/authentik/k8s/postgresql.yaml +++ /dev/null @@ -1,66 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: authentik-postgres-service - namespace: authentik -spec: - clusterIP: None - selector: - app: authentik-postgres - ports: - - port: 5432 - targetPort: 5432 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: authentik-postgres-statefulset - namespace: authentik -spec: - selector: - matchLabels: - app: authentik-postgres - serviceName: authentik-postgres-service - replicas: 1 - template: - metadata: - labels: - app: authentik-postgres - spec: - containers: - - name: postgres - image: docker.io/library/postgres:15-alpine - env: - - name: POSTGRES_DB - value: authentik - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: authentik-secrets - key: AUTHENTIK_POSTGRESQL__USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: authentik-secrets - key: AUTHENTIK_POSTGRESQL__PASSWORD - ports: - - containerPort: 5432 - name: postgres - volumeMounts: - - name: postgres-data - mountPath: /var/lib/postgresql/data - resources: - requests: - memory: "256Mi" - cpu: "200m" - limits: - memory: "1Gi" - cpu: "500m" - volumeClaimTemplates: - - metadata: - name: postgres-data - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 5Gi diff --git a/authentik/k8s/secrets.yaml.example b/authentik/k8s/secrets.yaml.example deleted file mode 100644 index c3c7651..0000000 --- a/authentik/k8s/secrets.yaml.example +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: authentik-secrets - namespace: authentik -type: Opaque -stringData: - AUTHENTIK_SECRET_KEY: "" - AUTHENTIK_POSTGRESQL__PASSWORD: "" - AUTHENTIK_POSTGRESQL__USER: authentik - AUTHENTIK_BOOTSTRAP_PASSWORD: authentik diff --git a/cfddns/.env.example b/cfddns/.env.example deleted file mode 100644 index 644875e..0000000 --- a/cfddns/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -CLOUDFLARE_API_TOKEN=YOUR_CLOUDFLARE_API_TOKEN -DOMAINS=example.com,dns.example.com,mc.example.com,auth.example.com,ssh.example.com -IP4_DOMAINS= -IP6_DOMAINS= -IP4_PROVIDER=cloudflare.trace -IP6_PROVIDER=none # change if you want to update AAAA -UPDATE_CRON=@every 5m -UPDATE_ON_START=true -DELETE_ON_STOP=false -DELETE_ON_FAILURE=true -TTL=1 -PROXIED=!is(dns.example.com) && !is(mc.example.com) && !is(ssh.example.com) -EMOJI=true -UPTIMEKUMA=https://uptime-kuma.example.com/api/push/AsaSDFGFkfklaFALSKffkfFKfkfkfkFK?status=up&msg=OK&ping= -REJECT_CLOUDFLARE_IPS=true \ No newline at end of file diff --git a/cfddns/compose.yaml b/cfddns/compose.yaml deleted file mode 100644 index 9f17b33..0000000 --- a/cfddns/compose.yaml +++ /dev/null @@ -1,30 +0,0 @@ -services: - cloudflare-ddns: - image: timothyjmiller/cloudflare-ddns:latest - container_name: cloudflare-ddns - restart: unless-stopped - security_opt: - - no-new-privileges:true - network_mode: "host" - # https://github.com/timothymiller/cloudflare-ddns#-quick-start - environment: - - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:?Cloudflare API token is required} - - DOMAINS=${DOMAINS:-} - - IP4_DOMAINS=${IP4_DOMAINS:-} - - IP6_DOMAINS=${IP6_DOMAINS:-} - - IP4_PROVIDER=${IP4_PROVIDER:-cloudflare.trace} - - IP6_PROVIDER=${IP6_PROVIDER:-none} - - UPDATE_CRON=${UPDATE_CRON:-@every 5m} - - UPDATE_ON_START=${UPDATE_ON_START:-true} - - DELETE_ON_STOP=${DELETE_ON_STOP:-false} - - DELETE_ON_FAILURE=${DELETE_ON_FAILURE:-true} - - TTL=${TTL:-1} # 1=auto - # to proxy only "dns.example.com" and "wfs.example.com" use "!is(dns.domain.com) && !is (wfs.domain.com)" - - PROXIED=${PROXIED:-true} - - EMOJI=${EMOJI:-true} - - UPTIMEKUMA=${UPTIMEKUMA:-} - - HEALTHCHECKS=${HEALTHCHECKS:-} - - REJECT_CLOUDFLARE_IPS=${REJECT_CLOUDFLARE_IPS:-true} - # volumes: - # Prefer using environment variables for configuration, config.json legacy support - # - ./config.json:/config.json diff --git a/cfddns/config.json.example b/cfddns/config.json.example deleted file mode 100644 index d290046..0000000 --- a/cfddns/config.json.example +++ /dev/null @@ -1,22 +0,0 @@ -{ - "cloudflare": [ - { - "authentication": { - "api_token": "API_TOKEN" - }, - "api_key": { - "api_key": "api_key_here", - "account_email": "your_email_here" - } - "zone_id": "your_zone-id", - "subdomains": [ - { "name": "", "proxied": true }, - { "name": "www", "proxied": true } - ] - } - ], - "a": true, - "aaaa": false, - "purgeUnknownRecords": false, - "ttl": 300 -} diff --git a/cfddns/k8s/.gitignore b/cfddns/k8s/.gitignore deleted file mode 100644 index 4a424df..0000000 --- a/cfddns/k8s/.gitignore +++ /dev/null @@ -1 +0,0 @@ -secret.yaml diff --git a/cfddns/k8s/deployment.yaml b/cfddns/k8s/deployment.yaml deleted file mode 100644 index a14877f..0000000 --- a/cfddns/k8s/deployment.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: cfddns - labels: - app: cfddns -spec: - replicas: 1 - selector: - matchLabels: - app: cfddns - template: - metadata: - labels: - app: cfddns - spec: - hostNetwork: true - dnsPolicy: ClusterFirstWithHostNet - containers: - - name: cloudflare-ddns - image: timothyjmiller/cloudflare-ddns:latest - imagePullPolicy: Always - resources: - requests: - memory: "20Mi" - cpu: "30m" - limits: - memory: "64Mi" - cpu: "50m" - envFrom: - - secretRef: - name: cfddns-secrets diff --git a/cfddns/k8s/secret.yaml.example b/cfddns/k8s/secret.yaml.example deleted file mode 100644 index da84b0b..0000000 --- a/cfddns/k8s/secret.yaml.example +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: cfddns-secrets -type: Opaque -stringData: - CLOUDFLARE_API_TOKEN: your_token - DOMAINS: "example.com,www.example.com" - IP4_PROVIDER: cloudflare.trace - IP6_PROVIDER: none - UPDATE_CRON: "@every 5m" - UPDATE_ON_START: "true" - DELETE_ON_STOP: "false" - DELETE_ON_FAILURE: "true" - TTL: "1" - PROXIED: "true" - EMOJI: "true" - REJECT_CLOUDFLARE_IPS: "true" diff --git a/checkmk/.env.example b/checkmk/.env.example deleted file mode 100644 index a57b7d2..0000000 --- a/checkmk/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -CMK_PASSWORD=password -TZ=Europe/Berlin \ No newline at end of file diff --git a/checkmk/compose.yaml b/checkmk/compose.yaml deleted file mode 100644 index 9febca1..0000000 --- a/checkmk/compose.yaml +++ /dev/null @@ -1,39 +0,0 @@ -services: - checkmk: - image: "checkmk/check-mk-raw:2.4.0-latest" - container_name: "checkmk" - restart: unless-stopped - # ports: - # - 5000:5000 - # - 6776:8000 - volumes: - - sites:/omd/sites - tmpfs: - - /opt/omd/sites/cmk/tmp:uid=1000,gid=1000 - environment: - - CMK_PASSWORD=${CMK_PASSWORD:-password} - - CMK_SITE_ID=cmk - - TZ=${TZ:-Etc/UTC} - labels: - - "traefik.enable=true" - - "traefik.http.services.checkmk.loadbalancer.server.port=5000" - - # Prod Router - - "traefik.http.routers.checkmk.rule=Host(`cmk.forust.xyz`)" - - "traefik.http.routers.checkmk.entrypoints=websecure" - - "traefik.http.routers.checkmk.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.checkmk-local.rule=Host(`cmk.workstation.internal`)" - - "traefik.http.routers.checkmk-local.entrypoints=websecure" - - "traefik.http.routers.checkmk-local.tls=true" - # Dev Router - - "traefik.http.routers.checkmk-dev.rule=Host(`cmk.gigaforust.internal`)" - - "traefik.http.routers.checkmk-dev.entrypoints=websecure" - - "traefik.http.routers.checkmk-dev.tls=true" - networks: - - proxy -networks: - proxy: - external: true -volumes: - sites: diff --git a/checkmk/k8s/checkmk.yaml b/checkmk/k8s/checkmk.yaml deleted file mode 100644 index f4dcd3a..0000000 --- a/checkmk/k8s/checkmk.yaml +++ /dev/null @@ -1,68 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: checkmk-service - namespace: checkmk -spec: - selector: - app: checkmk - ports: - - port: 5000 - targetPort: 5000 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: checkmk-deployment - namespace: checkmk -spec: - replicas: 1 - selector: - matchLabels: - app: checkmk - template: - metadata: - labels: - app: checkmk - spec: - containers: - - name: checkmk - image: checkmk/check-mk-raw:2.4.0-latest - envFrom: - - secretRef: - name: checkmk-secrets - - configMapRef: - name: checkmk-config - ports: - - containerPort: 5000 - volumeMounts: - - name: sites - mountPath: /omd/sites - - name: tmp - mountPath: /opt/omd/sites/cmk/tmp - resources: - requests: - memory: "2Gi" - cpu: "600m" - limits: - memory: "5Gi" - cpu: "4" - volumes: - - name: sites - persistentVolumeClaim: - claimName: checkmk-sites-pvc - - name: tmp - emptyDir: - medium: Memory ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: checkmk-sites-pvc - namespace: checkmk -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 5Gi diff --git a/checkmk/k8s/config.yaml b/checkmk/k8s/config.yaml deleted file mode 100644 index 9a6d50b..0000000 --- a/checkmk/k8s/config.yaml +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: checkmk-config - namespace: checkmk -data: - TZ: Europe/Bratislava - CMK_SITE_ID: cmk diff --git a/checkmk/k8s/ingress.yaml b/checkmk/k8s/ingress.yaml deleted file mode 100644 index 9b6b46c..0000000 --- a/checkmk/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: checkmk-prod - namespace: checkmk -spec: - entryPoints: - - websecure - routes: - - match: Host(`cmk.forust.xyz`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: checkmk-service - port: 5000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: checkmk-local - namespace: checkmk -spec: - entryPoints: - - websecure - routes: - - match: Host(`cmk.workstation.internal`) || Host(`cmk.gigaforust.internal`) - kind: Rule - services: - - name: checkmk-service - port: 5000 diff --git a/checkmk/k8s/namespace.yaml b/checkmk/k8s/namespace.yaml deleted file mode 100644 index 1e2fe52..0000000 --- a/checkmk/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: checkmk diff --git a/checkmk/k8s/secrets.yaml.example b/checkmk/k8s/secrets.yaml.example deleted file mode 100644 index c1df487..0000000 --- a/checkmk/k8s/secrets.yaml.example +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: checkmk-secrets - namespace: checkmk -type: Opaque -stringData: - CMK_PASSWORD: "password" diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..78a991d --- /dev/null +++ b/compose.yaml @@ -0,0 +1,35 @@ +services: + forust: + build: + context: . + dockerfile: Dockerfile + image: gcr.forust.xyz/forust/userbot:latest + pull_policy: build + restart: unless-stopped + env_file: + - .env + - .env.forust + volumes: + - ./volumes/data_forust:/app/data + - ./Downloads:/app/downloads + - ./volumes/logs_forust:/app/logs + dns: + - 8.8.8.8 + - 1.1.1.1 + + anna: + image: gcr.forust.xyz/forust/userbot:latest + pull_policy: build + depends_on: + - forust + restart: unless-stopped + env_file: + - .env + - .env.anna + volumes: + - ./volumes/data_anna:/app/data + - ./Downloads:/app/downloads + - ./volumes/logs_anna:/app/logs + dns: + - 8.8.8.8 + - 1.1.1.1 diff --git a/converters/.env.example b/converters/.env.example deleted file mode 100644 index b451193..0000000 --- a/converters/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -ACCOUNT_REGISTRATION=false -HTTP_ALLOWED=false -ALLOW_UNAUTHENTICAED=false -AUTO_DELETE_EVERY_N_HOURS=24 -WEBROOT=/convert -HIDE_HISTORY=false -LANGUAGE=en -UNAUTHED_USER_SHARING=false -MAX_CONVERT_PROCESS=0 \ No newline at end of file diff --git a/converters/compose.yaml b/converters/compose.yaml deleted file mode 100644 index 68da044..0000000 --- a/converters/compose.yaml +++ /dev/null @@ -1,70 +0,0 @@ -services: - convertx: - container_name: convertx - image: ghcr.io/c4illin/convertx:latest - restart: unless-stopped - ports: - - "9992:3000" - # https://github.com/C4illin/ConvertX#environment-variables - environment: - - JWT_SECRET=$(JWT_SECRET) - - ACCOUNT_REGISTRATION=$(ACCOUNT_REGISTRATION:-false) - - HTTP_ALLOWED=$(HTTP_ALLOWED:-false) - - ALLOW_UNAUTHENTICATED=$(ALLOW_UNAUTHENTICATED:-false) - - AUTO_DELETE_EVERY_N_HOURS=$(AUTO_DELETE_EVERY_N_HOURS:-24) - - WEBROOT=$(WEBROOT) - - HIDE_HISTORY=$(HIDE_HISTORY:-false) - - LANGUAGE=$(LANGUAGE:-en) - - UNAUTHENTICATED_USER_SHARING=$(UNAUTHENTICATED_USER_SHARING:-false) - - MAX_CONVERT_PROCESS=$(MAX_CONVERT_PROCESS:-0) - labels: - - "traefik.enable=true" - - "traefik.http.services.convertx.loadbalancer.server.port=3000" - # Prod Router - - "traefik.http.routers.convertx.rule=(Host(`forust.xyz`) || Host(`www.forust.xyz`)) && PathPrefix(`/convert`)" - - "traefik.http.routers.convertx.entrypoints=websecure" - - "traefik.http.routers.convertx.priority=50" - - "traefik.http.routers.convertx.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.convertx-local.rule=Host(`workstation.internal`) && PathPrefix(`/convert`)" - - "traefik.http.routers.convertx-local.entrypoints=websecure" - - "traefik.http.routers.convertx-local.priority=50" - - "traefik.http.routers.convertx-local.tls=true" - # Dev Router - - "traefik.http.routers.convertx-dev.rule=Host(`gigaforust.internal`) && PathPrefix(`/convert`)" - - "traefik.http.routers.convertx-dev.entrypoints=websecure" - - "traefik.http.routers.convertx-dev.priority=50" - - "traefik.http.routers.convertx-dev.tls=true" - networks: - - proxy - volumes: - - data:/app/data - - bentopdf: - container_name: bentopdf - image: bentopdf/bentopdf:latest - restart: unless-stopped - labels: - - "traefik.enable=true" - - "traefik.http.services.bentopdf.loadbalancer.server.port=8080" - - # Prod router - - "traefik.http.routers.bentopdf.rule=Host(`pdf.forust.xyz`)" - - "traefik.http.routers.bentopdf.entrypoints=websecure" - - "traefik.http.routers.bentopdf.tls.certresolver=letsencrypt" - - "traefik.http.routers.bentopdf.tls=true" - # Local router - - "traefik.http.routers.bentopdf-local.rule=Host(`pdf.wokstation.internal`)" - - "traefik.http.routers.bentopdf-local.entrypoints=websecure" - - "traefik.http.routers.bentopdf-local.tls=true" - # Dev router - - "traefik.http.routers.bentopdf-dev.rule=Host(`pdf.gigaforust.internal`)" - - "traefik.http.routers.bentopdf-dev.entrypoints=websecure" - - "traefik.http.routers.bentopdf-dev.tls=true" - networks: - - proxy -networks: - proxy: - external: true -volumes: - data: diff --git a/converters/k8s/bentopdf.yaml b/converters/k8s/bentopdf.yaml deleted file mode 100644 index 8aeb5cc..0000000 --- a/converters/k8s/bentopdf.yaml +++ /dev/null @@ -1,42 +0,0 @@ -kind: Service -apiVersion: v1 -metadata: - name: bentopdf-service - namespace: converters -spec: - selector: - app: bentopdf - ports: - - port: 8080 - targetPort: 8080 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: bentopdf-deployment - namespace: converters -spec: - replicas: 2 - selector: - matchLabels: - app: bentopdf - template: - metadata: - labels: - app: bentopdf - spec: - containers: - - image: bentopdf/bentopdf:latest - imagePullPolicy: Always - name: bentopdf - ports: - - containerPort: 8080 - resources: - requests: - memory: "50Mi" - cpu: "50m" - ephemeral-storage: "100Mi" - limits: - memory: "700Mi" - cpu: "700m" - ephemeral-storage: "5Gi" diff --git a/converters/k8s/config.yaml.example b/converters/k8s/config.yaml.example deleted file mode 100644 index 9255743..0000000 --- a/converters/k8s/config.yaml.example +++ /dev/null @@ -1,16 +0,0 @@ -# test manifest with docker and k8s config keys mismatch -apiVersion: v1 -kind: ConfigMap -metadata: - name: convertx-config - namespace: converters -data: - ACCOUNT_REGISTRATION: "false" - HTTP_ALLOWED: "false" - ALLOW_UNAUTHENTICAED: "false" - AUTO_DELETE_EVERY_N_HOURS: "24" - WEBROOT: "/convert" - HIDE_HISTORY: "false" - LANGUAGE: "en" - UNAUTHED_USER_SHARING: "false" - MAX_CONVERT_PROCESS: "0" diff --git a/converters/k8s/convertx.yaml b/converters/k8s/convertx.yaml deleted file mode 100644 index db9aade..0000000 --- a/converters/k8s/convertx.yaml +++ /dev/null @@ -1,63 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: convertx-service - namespace: converters -spec: - selector: - app: convertx - ports: - - port: 3000 - targetPort: 3000 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: convertx-deployment - namespace: converters -spec: - replicas: 1 - selector: - matchLabels: - app: convertx - template: - metadata: - labels: - app: convertx - spec: - containers: - - image: ghcr.io/c4illin/convertx:latest - name: convertx - envFrom: - - configMapRef: - name: convertx-config - - secretRef: - name: convertx-secrets - ports: - - containerPort: 3000 - volumeMounts: - - mountPath: /data - name: data - resources: - requests: - memory: "250Mi" - cpu: "100m" - limits: - cpu: "1500m" - memory: "1.5Gi" - volumes: - - name: data - persistentVolumeClaim: - claimName: convertx-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: convertx-pvc - namespace: converters -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 2Gi diff --git a/converters/k8s/ingress.yaml b/converters/k8s/ingress.yaml deleted file mode 100644 index 1e201af..0000000 --- a/converters/k8s/ingress.yaml +++ /dev/null @@ -1,65 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: convertx-prod - namespace: converters -spec: - entryPoints: - - websecure - routes: - - match: (Host(`forust.xyz`) || Host(`www.forust.xyz`)) && PathPrefix(`/convert`) - kind: Rule - priority: 50 - services: - - name: convertx-service - port: 3000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: convertx-local - namespace: converters -spec: - entryPoints: - - websecure - routes: - - match: (Host(`workstation.internal`) || Host(`gigaforust.internal`)) && PathPrefix(`/convert`) - kind: Rule - priority: 50 - services: - - name: convertx-service - port: 3000 ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: bentopdf-prod - namespace: converters -spec: - entryPoints: - - websecure - routes: - - match: Host(`pdf.forust.xyz`) - kind: Rule - services: - - name: bentopdf-service - port: 8080 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: bentopdf-local - namespace: converters -spec: - entryPoints: - - websecure - routes: - - match: Host(`pdf.workstation.internal`) || Host(`pdf.gigaforust.internal`) - kind: Rule - services: - - name: bentopdf-service - port: 8080 diff --git a/converters/k8s/namespace.yaml b/converters/k8s/namespace.yaml deleted file mode 100644 index 68c8cdf..0000000 --- a/converters/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: converters diff --git a/converters/k8s/secrets.yaml.example b/converters/k8s/secrets.yaml.example deleted file mode 100644 index f827fb1..0000000 --- a/converters/k8s/secrets.yaml.example +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: convertx-secrets - namespace: converters -type: Opaque -stringData: - jwt-secret: "" diff --git a/crowdsec/k8s/crowdsec-middleware.yaml b/crowdsec/k8s/crowdsec-middleware.yaml deleted file mode 100644 index 09d51ee..0000000 --- a/crowdsec/k8s/crowdsec-middleware.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: crowdsec-bouncer - namespace: crowdsec -spec: - plugin: - crowdsec-bouncer: - enabled: true - LogLevel: INFO - CrowdsecMode: live - CrowdsecLapiScheme: http - CrowdsecLapiHost: crowdsec-service.crowdsec.svc.cluster.local:8080 - CrowdsecLapiKeyFile: "/etc/traefik/secrets/traefik-api-key" diff --git a/crowdsec/k8s/crowdsec-values.yaml b/crowdsec/k8s/crowdsec-values.yaml deleted file mode 100644 index ba3b23b..0000000 --- a/crowdsec/k8s/crowdsec-values.yaml +++ /dev/null @@ -1,57 +0,0 @@ -container_runtime: containerd -agent: - env: - - name: COLLECTIONS - value: "crowdsecurity/traefik crowdsecurity/base-http-scenarios crowdsecurity/sshd" - extraVolumes: - - name: journal-dir - hostPath: - path: /var/log/journal - type: DirectoryOrCreate - - name: run-journal-dir - hostPath: - path: /run/log/journal - type: DirectoryOrCreate - extraVolumeMounts: - - name: journal-dir - mountPath: /var/log/journal - readOnly: true - - name: run-journal-dir - mountPath: /run/log/journal - readOnly: true - - acquisition: - - namespace: traefik - podName: "*traefik*" - program: traefik - poll_without_inotify: true - - acquisitionCustom: | - - source: journalctl - journalctl_filter: - - _SYSTEMD_UNIT=sshd.service - labels: - type: syslog - - resources: - requests: - cpu: 50m - memory: 100Mi - limits: - cpu: 200m - memory: 500Mi - -lapi: - env: - - name: COLLECTIONS - value: "crowdsecurity/traefik crowdsecurity/base-http-scenarios crowdsecurity/sshd" - service: - type: NodePort - nodePort: 30011 - resources: - requests: - cpu: 50m - memory: 150Mi - limits: - cpu: 200m - memory: 500Mi diff --git a/dockmon/compose.yaml b/dockmon/compose.yaml deleted file mode 100644 index 9863b0a..0000000 --- a/dockmon/compose.yaml +++ /dev/null @@ -1,46 +0,0 @@ -services: - dockmon: - image: darthnorse/dockmon:latest - container_name: dockmon - restart: unless-stopped - # ports: - # - 8000:443 - volumes: - - data:/app/data - - /var/run/docker.sock:/var/run/docker.sock - healthcheck: - test: ["CMD", "curl", "-k", "-f", "https://localhost:443/health"] - interval: 30s - timeout: 10s - retries: 3 - labels: - - "traefik.enable=true" - - "traefik.http.services.dockmon.loadbalancer.server.port=443" - - "traefik.http.services.dockmon.loadbalancer.server.scheme=https" - - "traefik.http.services.dockmon.loadbalancer.serverstransport=insecureTransport@file" - - # Prod Router - - "traefik.http.routers.dockmon.rule=Host(`dockmon.forust.xyz`)" - - "traefik.http.routers.dockmon.entrypoints=websecure" - - "traefik.http.routers.dockmon.middlewares=security-headers@file" - - "traefik.http.routers.dockmon.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.dockmon-local.rule=Host(`dockmon.workstation.internal`)" - - "traefik.http.routers.dockmon-local.entrypoints=websecure" - - "traefik.http.routers.dockmon-local.tls=true" - # Dev Router - - "traefik.http.routers.dockmon-dev.rule=Host(`dockmon.gigaforust.internal`)" - - "traefik.http.routers.dockmon-dev.entrypoints=websecure" - - "traefik.http.routers.dockmon-dev.tls=true" - - # Glance Metadata - - glance.name=dockmon - - glance.url=https://dockmon.forust.xyz/ - - glance.description=Dockmon is a lightweight Docker container monitoring and management tool with a user-friendly web interface. - networks: - - proxy -volumes: - data: -networks: - proxy: - external: true diff --git a/dockmon/k8s/dockmon.yaml b/dockmon/k8s/dockmon.yaml deleted file mode 100644 index 230fb65..0000000 --- a/dockmon/k8s/dockmon.yaml +++ /dev/null @@ -1,68 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: dockmon-service - namespace: dockmon -spec: - clusterIP: None - selector: - app: dockmon - ports: - - port: 443 - targetPort: 443 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: dockmon-statefulset - namespace: dockmon -spec: - serviceName: dockmon-service - replicas: 1 - selector: - matchLabels: - app: dockmon - template: - metadata: - labels: - app: dockmon - spec: - containers: - - name: dockmon - image: darthnorse/dockmon:latest - ports: - - containerPort: 443 - volumeMounts: - - name: data - mountPath: /app/data - - name: docker-sock - mountPath: /var/run/docker.sock - livenessProbe: - httpGet: - path: /health - port: 443 - scheme: HTTPS - initialDelaySeconds: 30 - periodSeconds: 30 - timeoutSeconds: 10 - failureThreshold: 3 - resources: - requests: - memory: "512Mi" - cpu: "200m" - limits: - memory: "1.5Gi" - cpu: "700m " - volumes: - - name: docker-sock - hostPath: - path: /var/run/docker.sock - type: Socket - volumeClaimTemplates: - - metadata: - name: data - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 1Gi diff --git a/dockmon/k8s/ingress.yaml b/dockmon/k8s/ingress.yaml deleted file mode 100644 index 99e10c5..0000000 --- a/dockmon/k8s/ingress.yaml +++ /dev/null @@ -1,44 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: ServersTransport -metadata: - name: dockmon-transport - namespace: dockmon -spec: - insecureSkipVerify: true ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: dockmon-prod - namespace: dockmon -spec: - entryPoints: - - websecure - routes: - - match: Host(`dockmon.forust.xyz`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - - name: security-headers@file - services: - - name: dockmon-service - port: 443 - serversTransport: dockmon-transport - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: dockmon-local - namespace: dockmon -spec: - entryPoints: - - websecure - routes: - - match: Host(`dockmon.workstation.internal`) || Host(`dockmon.gigaforust.internal`) - kind: Rule - services: - - name: dockmon-service - port: 443 - serversTransport: dockmon-transport diff --git a/dockmon/k8s/namespace.yaml b/dockmon/k8s/namespace.yaml deleted file mode 100644 index 521ecc4..0000000 --- a/dockmon/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: dockmon diff --git a/downtify/compose.yaml b/downtify/compose.yaml deleted file mode 100644 index 645ec3c..0000000 --- a/downtify/compose.yaml +++ /dev/null @@ -1,31 +0,0 @@ -services: - downtify: - container_name: downtify - image: ghcr.io/henriquesebastiao/downtify:latest - restart: unless-stopped - # ports: - # - '7077:8000' - volumes: - - ./Downtify_downloads:/downloads - labels: - - "traefik.enable=true" - - "traefik.http.services.downtify.loadbalancer.server.port=8000" - - # Prod Router - - "traefik.http.routers.downtify.rule=Host(`downtify.forust.xyz`)" - - "traefik.http.routers.downtify.entrypoints=websecure" - - "traefik.http.routers.downtify.middlewares=security-chain@file" - - "traefik.http.routers.downtify.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.downtify-local.rule=Host(`downtify.workstation.internal`)" - - "traefik.http.routers.downtify-local.entrypoints=websecure" - - "traefik.http.routers.downtify-local.tls=true" - # Dev Router - - "traefik.http.routers.downtify-dev.rule=Host(`downtify.gigaforust.internal`)" - - "traefik.http.routers.downtify-dev.entrypoints=websecure" - - "traefik.http.routers.downtify-dev.tls=true" - networks: - - proxy -networks: - proxy: - external: true diff --git a/downtify/k8s/downtify.yaml b/downtify/k8s/downtify.yaml deleted file mode 100644 index f99ccbb..0000000 --- a/downtify/k8s/downtify.yaml +++ /dev/null @@ -1,58 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: downtify-service - namespace: downtify -spec: - selector: - app: downtify - ports: - - port: 8000 - targetPort: 8000 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: downtify-deployment - namespace: downtify -spec: - replicas: 1 - selector: - matchLabels: - app: downtify - template: - metadata: - labels: - app: downtify - spec: - containers: - - name: downtify - image: ghcr.io/henriquesebastiao/downtify:latest - ports: - - containerPort: 8000 - volumeMounts: - - name: downloads - mountPath: /downloads - resources: - requests: - memory: "128Mi" - cpu: "200m" - limits: - memory: "1Gi" - cpu: "1" - volumes: - - name: downloads - persistentVolumeClaim: - claimName: downtify-downloads-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: downtify-downloads-pvc - namespace: downtify -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi diff --git a/downtify/k8s/ingress.yaml b/downtify/k8s/ingress.yaml deleted file mode 100644 index c381a66..0000000 --- a/downtify/k8s/ingress.yaml +++ /dev/null @@ -1,34 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: downtify-prod - namespace: downtify -spec: - entryPoints: - - websecure - routes: - - match: Host(`downtify.forust.xyz`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - - name: security-chain@file - services: - - name: downtify-service - port: 8000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: downtify-local - namespace: downtify -spec: - entryPoints: - - websecure - routes: - - match: Host(`downtify.workstation.internal`) || Host(`downtify.gigaforust.internal`) - kind: Rule - services: - - name: downtify-service - port: 8000 diff --git a/downtify/k8s/namespace.yaml b/downtify/k8s/namespace.yaml deleted file mode 100644 index 1f5361e..0000000 --- a/downtify/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: downtify diff --git a/dtek_notif/Dockerfile b/dtek_notif/Dockerfile deleted file mode 100644 index 7860cf1..0000000 --- a/dtek_notif/Dockerfile +++ /dev/null @@ -1,13 +0,0 @@ -FROM python:3.9-alpine - -WORKDIR /app - -# Установка зависимостей -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# Копирование кода -COPY main.py . -COPY .env . -# Запуск бота -CMD ["python", "-u", "main.py"] \ No newline at end of file diff --git a/dtek_notif/LICENSE b/dtek_notif/LICENSE deleted file mode 100644 index ee6256c..0000000 --- a/dtek_notif/LICENSE +++ /dev/null @@ -1,373 +0,0 @@ -Mozilla Public License Version 2.0 -================================== - -1. Definitions --------------- - -1.1. "Contributor" - means each individual or legal entity that creates, contributes to - the creation of, or owns Covered Software. - -1.2. "Contributor Version" - means the combination of the Contributions of others (if any) used - by a Contributor and that particular Contributor's Contribution. - -1.3. "Contribution" - means Covered Software of a particular Contributor. - -1.4. "Covered Software" - means Source Code Form to which the initial Contributor has attached - the notice in Exhibit A, the Executable Form of such Source Code - Form, and Modifications of such Source Code Form, in each case - including portions thereof. - -1.5. "Incompatible With Secondary Licenses" - means - - (a) that the initial Contributor has attached the notice described - in Exhibit B to the Covered Software; or - - (b) that the Covered Software was made available under the terms of - version 1.1 or earlier of the License, but not also under the - terms of a Secondary License. - -1.6. "Executable Form" - means any form of the work other than Source Code Form. - -1.7. "Larger Work" - means a work that combines Covered Software with other material, in - a separate file or files, that is not Covered Software. - -1.8. "License" - means this document. - -1.9. "Licensable" - means having the right to grant, to the maximum extent possible, - whether at the time of the initial grant or subsequently, any and - all of the rights conveyed by this License. - -1.10. "Modifications" - means any of the following: - - (a) any file in Source Code Form that results from an addition to, - deletion from, or modification of the contents of Covered - Software; or - - (b) any new file in Source Code Form that contains any Covered - Software. - -1.11. "Patent Claims" of a Contributor - means any patent claim(s), including without limitation, method, - process, and apparatus claims, in any patent Licensable by such - Contributor that would be infringed, but for the grant of the - License, by the making, using, selling, offering for sale, having - made, import, or transfer of either its Contributions or its - Contributor Version. - -1.12. "Secondary License" - means either the GNU General Public License, Version 2.0, the GNU - Lesser General Public License, Version 2.1, the GNU Affero General - Public License, Version 3.0, or any later versions of those - licenses. - -1.13. "Source Code Form" - means the form of the work preferred for making modifications. - -1.14. "You" (or "Your") - means an individual or a legal entity exercising rights under this - License. For legal entities, "You" includes any entity that - controls, is controlled by, or is under common control with You. For - purposes of this definition, "control" means (a) the power, direct - or indirect, to cause the direction or management of such entity, - whether by contract or otherwise, or (b) ownership of more than - fifty percent (50%) of the outstanding shares or beneficial - ownership of such entity. - -2. License Grants and Conditions --------------------------------- - -2.1. Grants - -Each Contributor hereby grants You a world-wide, royalty-free, -non-exclusive license: - -(a) under intellectual property rights (other than patent or trademark) - Licensable by such Contributor to use, reproduce, make available, - modify, display, perform, distribute, and otherwise exploit its - Contributions, either on an unmodified basis, with Modifications, or - as part of a Larger Work; and - -(b) under Patent Claims of such Contributor to make, use, sell, offer - for sale, have made, import, and otherwise transfer either its - Contributions or its Contributor Version. - -2.2. Effective Date - -The licenses granted in Section 2.1 with respect to any Contribution -become effective for each Contribution on the date the Contributor first -distributes such Contribution. - -2.3. Limitations on Grant Scope - -The licenses granted in this Section 2 are the only rights granted under -this License. No additional rights or licenses will be implied from the -distribution or licensing of Covered Software under this License. -Notwithstanding Section 2.1(b) above, no patent license is granted by a -Contributor: - -(a) for any code that a Contributor has removed from Covered Software; - or - -(b) for infringements caused by: (i) Your and any other third party's - modifications of Covered Software, or (ii) the combination of its - Contributions with other software (except as part of its Contributor - Version); or - -(c) under Patent Claims infringed by Covered Software in the absence of - its Contributions. - -This License does not grant any rights in the trademarks, service marks, -or logos of any Contributor (except as may be necessary to comply with -the notice requirements in Section 3.4). - -2.4. Subsequent Licenses - -No Contributor makes additional grants as a result of Your choice to -distribute the Covered Software under a subsequent version of this -License (see Section 10.2) or under the terms of a Secondary License (if -permitted under the terms of Section 3.3). - -2.5. Representation - -Each Contributor represents that the Contributor believes its -Contributions are its original creation(s) or it has sufficient rights -to grant the rights to its Contributions conveyed by this License. - -2.6. Fair Use - -This License is not intended to limit any rights You have under -applicable copyright doctrines of fair use, fair dealing, or other -equivalents. - -2.7. Conditions - -Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted -in Section 2.1. - -3. Responsibilities -------------------- - -3.1. Distribution of Source Form - -All distribution of Covered Software in Source Code Form, including any -Modifications that You create or to which You contribute, must be under -the terms of this License. You must inform recipients that the Source -Code Form of the Covered Software is governed by the terms of this -License, and how they can obtain a copy of this License. You may not -attempt to alter or restrict the recipients' rights in the Source Code -Form. - -3.2. Distribution of Executable Form - -If You distribute Covered Software in Executable Form then: - -(a) such Covered Software must also be made available in Source Code - Form, as described in Section 3.1, and You must inform recipients of - the Executable Form how they can obtain a copy of such Source Code - Form by reasonable means in a timely manner, at a charge no more - than the cost of distribution to the recipient; and - -(b) You may distribute such Executable Form under the terms of this - License, or sublicense it under different terms, provided that the - license for the Executable Form does not attempt to limit or alter - the recipients' rights in the Source Code Form under this License. - -3.3. Distribution of a Larger Work - -You may create and distribute a Larger Work under terms of Your choice, -provided that You also comply with the requirements of this License for -the Covered Software. If the Larger Work is a combination of Covered -Software with a work governed by one or more Secondary Licenses, and the -Covered Software is not Incompatible With Secondary Licenses, this -License permits You to additionally distribute such Covered Software -under the terms of such Secondary License(s), so that the recipient of -the Larger Work may, at their option, further distribute the Covered -Software under the terms of either this License or such Secondary -License(s). - -3.4. Notices - -You may not remove or alter the substance of any license notices -(including copyright notices, patent notices, disclaimers of warranty, -or limitations of liability) contained within the Source Code Form of -the Covered Software, except that You may alter any license notices to -the extent required to remedy known factual inaccuracies. - -3.5. Application of Additional Terms - -You may choose to offer, and to charge a fee for, warranty, support, -indemnity or liability obligations to one or more recipients of Covered -Software. However, You may do so only on Your own behalf, and not on -behalf of any Contributor. You must make it absolutely clear that any -such warranty, support, indemnity, or liability obligation is offered by -You alone, and You hereby agree to indemnify every Contributor for any -liability incurred by such Contributor as a result of warranty, support, -indemnity or liability terms You offer. You may include additional -disclaimers of warranty and limitations of liability specific to any -jurisdiction. - -4. Inability to Comply Due to Statute or Regulation ---------------------------------------------------- - -If it is impossible for You to comply with any of the terms of this -License with respect to some or all of the Covered Software due to -statute, judicial order, or regulation then You must: (a) comply with -the terms of this License to the maximum extent possible; and (b) -describe the limitations and the code they affect. Such description must -be placed in a text file included with all distributions of the Covered -Software under this License. Except to the extent prohibited by statute -or regulation, such description must be sufficiently detailed for a -recipient of ordinary skill to be able to understand it. - -5. Termination --------------- - -5.1. The rights granted under this License will terminate automatically -if You fail to comply with any of its terms. However, if You become -compliant, then the rights granted under this License from a particular -Contributor are reinstated (a) provisionally, unless and until such -Contributor explicitly and finally terminates Your grants, and (b) on an -ongoing basis, if such Contributor fails to notify You of the -non-compliance by some reasonable means prior to 60 days after You have -come back into compliance. Moreover, Your grants from a particular -Contributor are reinstated on an ongoing basis if such Contributor -notifies You of the non-compliance by some reasonable means, this is the -first time You have received notice of non-compliance with this License -from such Contributor, and You become compliant prior to 30 days after -Your receipt of the notice. - -5.2. If You initiate litigation against any entity by asserting a patent -infringement claim (excluding declaratory judgment actions, -counter-claims, and cross-claims) alleging that a Contributor Version -directly or indirectly infringes any patent, then the rights granted to -You by any and all Contributors for the Covered Software under Section -2.1 of this License shall terminate. - -5.3. In the event of termination under Sections 5.1 or 5.2 above, all -end user license agreements (excluding distributors and resellers) which -have been validly granted by You or Your distributors under this License -prior to termination shall survive termination. - -************************************************************************ -* * -* 6. Disclaimer of Warranty * -* ------------------------- * -* * -* Covered Software is provided under this License on an "as is" * -* basis, without warranty of any kind, either expressed, implied, or * -* statutory, including, without limitation, warranties that the * -* Covered Software is free of defects, merchantable, fit for a * -* particular purpose or non-infringing. The entire risk as to the * -* quality and performance of the Covered Software is with You. * -* Should any Covered Software prove defective in any respect, You * -* (not any Contributor) assume the cost of any necessary servicing, * -* repair, or correction. This disclaimer of warranty constitutes an * -* essential part of this License. No use of any Covered Software is * -* authorized under this License except under this disclaimer. * -* * -************************************************************************ - -************************************************************************ -* * -* 7. Limitation of Liability * -* -------------------------- * -* * -* Under no circumstances and under no legal theory, whether tort * -* (including negligence), contract, or otherwise, shall any * -* Contributor, or anyone who distributes Covered Software as * -* permitted above, be liable to You for any direct, indirect, * -* special, incidental, or consequential damages of any character * -* including, without limitation, damages for lost profits, loss of * -* goodwill, work stoppage, computer failure or malfunction, or any * -* and all other commercial damages or losses, even if such party * -* shall have been informed of the possibility of such damages. This * -* limitation of liability shall not apply to liability for death or * -* personal injury resulting from such party's negligence to the * -* extent applicable law prohibits such limitation. Some * -* jurisdictions do not allow the exclusion or limitation of * -* incidental or consequential damages, so this exclusion and * -* limitation may not apply to You. * -* * -************************************************************************ - -8. Litigation -------------- - -Any litigation relating to this License may be brought only in the -courts of a jurisdiction where the defendant maintains its principal -place of business and such litigation shall be governed by laws of that -jurisdiction, without reference to its conflict-of-law provisions. -Nothing in this Section shall prevent a party's ability to bring -cross-claims or counter-claims. - -9. Miscellaneous ----------------- - -This License represents the complete agreement concerning the subject -matter hereof. If any provision of this License is held to be -unenforceable, such provision shall be reformed only to the extent -necessary to make it enforceable. Any law or regulation which provides -that the language of a contract shall be construed against the drafter -shall not be used to construe this License against a Contributor. - -10. Versions of the License ---------------------------- - -10.1. New Versions - -Mozilla Foundation is the license steward. Except as provided in Section -10.3, no one other than the license steward has the right to modify or -publish new versions of this License. Each version will be given a -distinguishing version number. - -10.2. Effect of New Versions - -You may distribute the Covered Software under the terms of the version -of the License under which You originally received the Covered Software, -or under the terms of any subsequent version published by the license -steward. - -10.3. Modified Versions - -If you create software not governed by this License, and you want to -create a new license for such software, you may create and use a -modified version of this License if you rename the license and remove -any references to the name of the license steward (except to note that -such modified license differs from this License). - -10.4. Distributing Source Code Form that is Incompatible With Secondary -Licenses - -If You choose to distribute Source Code Form that is Incompatible With -Secondary Licenses under the terms of this version of the License, the -notice described in Exhibit B of this License must be attached. - -Exhibit A - Source Code Form License Notice -------------------------------------------- - - This Source Code Form is subject to the terms of the Mozilla Public - License, v. 2.0. If a copy of the MPL was not distributed with this - file, You can obtain one at https://mozilla.org/MPL/2.0/. - -If it is not possible or desirable to put the notice in a particular -file, then You may include the notice in a location (such as a LICENSE -file in a relevant directory) where a recipient would be likely to look -for such a notice. - -You may add additional accurate notices of copyright ownership. - -Exhibit B - "Incompatible With Secondary Licenses" Notice ---------------------------------------------------------- - - This Source Code Form is "Incompatible With Secondary Licenses", as - defined by the Mozilla Public License, v. 2.0. diff --git a/dtek_notif/docker-compose.yaml b/dtek_notif/docker-compose.yaml deleted file mode 100644 index e45fa7c..0000000 --- a/dtek_notif/docker-compose.yaml +++ /dev/null @@ -1,15 +0,0 @@ -services: - dtek_notif: - build: - context: . - dockerfile: Dockerfile - image: gcr.forust.xyz/forust/dtek-notif:latest - pull_policy: build - restart: unless-stopped - environment: - - TZ=Europe/Kyiv - dns: - - 1.1.1.1 - - 8.8.8.8 - networks: - - default diff --git a/dtek_notif/main.py b/dtek_notif/main.py deleted file mode 100644 index 3896473..0000000 --- a/dtek_notif/main.py +++ /dev/null @@ -1,778 +0,0 @@ -import requests -import json -import asyncio -from bs4 import BeautifulSoup -from datetime import datetime, timedelta -from aiogram import Bot, Dispatcher, types -from aiogram.filters import Command -from aiogram.types import Message, KeyboardButton -from aiogram.utils.keyboard import ReplyKeyboardBuilder -import logging -import os -from dotenv import load_dotenv -from typing import Optional, List, Dict - -# Загрузка переменных окружения -load_dotenv() - -# Настройки -TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "YOUR_TOKEN_HERE") -ALLOWED_CHAT_IDS = list(map(int, os.getenv("ALLOWED_CHAT_IDS", "").split(","))) if os.getenv("ALLOWED_CHAT_IDS") else [] -CHECK_INTERVAL = int(os.getenv("CHECK_INTERVAL", "120")) - -# Параметры для запроса -VOE_CITY_ID = int(os.getenv("VOE_CITY_ID", "VOE_CITY_ID")) -VOE_STREET_ID = int(os.getenv("VOE_STREET_ID", "VOE_STREET_ID")) -VOE_HOUSE_ID = int(os.getenv("VOE_HOUSE_ID", "VOE_HOUSE_ID")) - -# Настройка логирования -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' -) -logger = logging.getLogger(__name__) - -# Глобальные переменные -bot = Bot(token=TELEGRAM_TOKEN) -dp = Dispatcher() -last_schedule: Optional[List[Dict]] = None -last_notification_time: Dict[str, datetime] = {} - - -# ============================================================================ -# УТИЛИТЫ -# ============================================================================ - -def format_time_duration(minutes: int) -> str: - """Форматирует время из минут в часы и минуты""" - hours = minutes // 60 - mins = minutes % 60 - - if hours == 0: - return f"{mins}м" - elif mins == 0: - return f"{hours}ч" - return f"{hours}ч {mins}м" - - -def get_day_statistics(day_blocks: List[Dict]) -> Dict[str, int]: - """Получает статистику по дню""" - total_minutes = 0 - confirmed_minutes = 0 - possible_minutes = 0 - - for block in day_blocks: - for half in [block["first_half"], block["second_half"]]: - if half["status"] == "off": - total_minutes += 30 - if half["confirmed"]: - confirmed_minutes += 30 - else: - possible_minutes += 30 - - return { - "total": total_minutes, - "confirmed": confirmed_minutes, - "possible": possible_minutes - } - - -# ============================================================================ -# ПАРСИНГ ДАННЫХ -# ============================================================================ - -def parse_html(html: str) -> List[Dict]: - """Парсит HTML с графиком отключений (логика от 15.11.2024)""" - soup = BeautifulSoup(html, "html.parser") - cells = soup.select(".disconnection-detailed-table-cell.cell") - - schedule = [] - current_hour = 0 - current_day = 0 - - for cell in cells: - if 'legend' in cell.get('class', []) or 'head' in cell.get('class', []): - continue - - cell_classes = cell.get('class', []) - - # ПРоверка статуса отключения на весь час - full_hour_off = 'has_disconnection' in cell_classes and 'full_hour' in cell_classes - no_disconnection_cell = 'no_disconnection' in cell_classes - - hour_block = cell.select_one(".hour_block") - if not hour_block: - continue - - # Проверка подтверждённости отключения для всего часа - cell_confirmed = None - if 'confirm_1' in cell_classes: - cell_confirmed = True - elif 'confirm_0' in cell_classes: - cell_confirmed = False - - # Проверка половин часа - left = hour_block.select_one(".half.left") - right = hour_block.select_one(".half.right") - - def parse_half(half, is_full_hour_off: bool) -> Dict: - """Парсит половину часа""" - if not half: - return {"status": "on", "queue": None, "confirmed": None} - - half_classes = half.get('class', []) - - # Если вся ячейка full_hour - используем статус ячейки - if is_full_hour_off: - return {"status": "off", "queue": None, "confirmed": cell_confirmed} - - # Определяем статус половины - if 'has_disconnection' in half_classes: - status = "off" - elif 'no_disconnection' in half_classes: - status = "on" - else: - status = "on" # По умолчанию считаем включенным - - # Если выключено - ищем подробности - queue = None - confirmed = None - - if status == "off": - disconnection_div = half.select_one(".disconnection") - if disconnection_div: - # Ищем номер черги в title - if disconnection_div.has_attr("title"): - title = disconnection_div["title"] - if "Номер черги" in title or "Номер черги:" in title: - try: - queue = title.split(":")[-1].strip() - except: - pass - - # Определяем подтверждение - disc_classes = disconnection_div.get('class', []) - if 'disconnection_confirm_1' in disc_classes: - confirmed = True - elif 'disconnection_confirm_0' in disc_classes: - confirmed = False - - return {"status": status, "queue": queue, "confirmed": confirmed} - - first_half_data = parse_half(left, full_hour_off) - second_half_data = parse_half(right, full_hour_off) - - schedule.append({ - "hour": current_hour, - "day": current_day, - "first_half": first_half_data, - "second_half": second_half_data, - }) - - current_hour += 1 - if current_hour >= 24: - current_hour = 0 - current_day += 1 - - return schedule - - -def get_voe_html(city_id: int, street_id: int, house_id: int) -> str: - """Получает HTML с сайта VOE""" - url = "https://www.voe.com.ua/disconnection/detailed?ajax_form=1&_wrapper_format=drupal_ajax" - headers = { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "X-Requested-With": "XMLHttpRequest", - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" - } - data = { - "search_type": 0, - "city_id": city_id, - "street_id": street_id, - "house_id": house_id, - "form_build_id": "form-Irv5aHw1R2FT_Ik2apyHOZ47hTH5xPNH_LQnBrmpSTc", - "form_id": "disconnection_detailed_search_form", - "_triggering_element_name": "search", - "_triggering_element_value": "Показати", - "_drupal_ajax": 1, - } - - try: - response = requests.post(url, headers=headers, data=data, timeout=10) - response.raise_for_status() - resp_json = response.json() - - insert_html = next( - (item["data"] for item in resp_json if item.get("command") == "insert"), - None - ) - - if not insert_html: - raise ValueError("HTML не найден в ответе") - - return insert_html - except requests.exceptions.RequestException as e: - logger.error(f"Ошибка запроса VOE: {e}") - raise - - -# ============================================================================ -# ФОРМАТИРОВАНИЕ СООБЩЕНИЙ -# ============================================================================ - -def get_main_keyboard(): - """Создает главную клавиатуру""" - builder = ReplyKeyboardBuilder() - builder.row( - KeyboardButton(text="📊 Графік"), - KeyboardButton(text="🔄 Оновити") - ) - builder.row( - KeyboardButton(text="📅 Сьогодні"), - KeyboardButton(text="📅 Завтра") - ) - builder.row(KeyboardButton(text="ℹ️ Про бота")) - return builder.as_markup(resize_keyboard=True) - - -def format_schedule_message(schedule: List[Dict], days_to_show: int = 2) -> str: - """Форматирует полный график на несколько дней""" - lines = [ - "⚡️ Графік відключень світла", - f"🕐 Оновлено: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}", - "─" * 30, - "" - ] - - start_date = datetime.now() - - for day in range(min(days_to_show, 2)): - day_blocks = [b for b in schedule if b["day"] == day] - if not day_blocks: - continue - - date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y') - day_name = "🌅 Сьогодні" if day == 0 else "🌄 Завтра" - - lines.append(f"{day_name} ({date_str})") - - # Статистика - stats = get_day_statistics(day_blocks) - if stats["total"] > 0: - lines.append(f"⏱ Всього: {format_time_duration(stats['total'])}") - if stats["confirmed"] > 0: - lines.append(f"🔴 Підтверджено: {format_time_duration(stats['confirmed'])}") - if stats["possible"] > 0: - lines.append(f"🟠 Можливо: {format_time_duration(stats['possible'])}") - else: - lines.append("🟢 Відключень немає!") - - lines.append("") - - # Детальный список отключений - disconnections = [] - current_status = None - start_time = None - current_confirmed = None - current_queue = None - - for block in day_blocks: - hour = block["hour"] - - for half_idx, half in enumerate([block["first_half"], block["second_half"]]): - time_str = f"{hour:02d}:00" if half_idx == 0 else f"{hour:02d}:30" - - if half["status"] == "off": - if current_status != "off": - start_time = time_str - current_confirmed = half["confirmed"] - current_queue = half["queue"] - current_status = "off" - else: - if current_status == "off": - icon = "🔴" if current_confirmed else "🟠" - queue_text = f" (Ч{current_queue})" if current_queue else "" - disconnections.append(f"{icon} {start_time} - {time_str}{queue_text}") - current_status = half["status"] - - # Если день закончился на отключении - if current_status == "off": - icon = "🔴" if current_confirmed else "🟠" - queue_text = f" (Ч{current_queue})" if current_queue else "" - next_hour = (day_blocks[-1]["hour"] + 1) % 24 - end_time = f"{next_hour:02d}:00" - disconnections.append(f"{icon} {start_time} - {end_time}{queue_text}") - - if disconnections: - for idx, disc in enumerate(disconnections, 1): - lines.append(f"{idx}. {disc}") - - lines.append("") - - lines.append("🔴 = підтверджено • 🟠 = можливо • 🟢 = світло") - - return "\n".join(lines) - - -def format_single_day_schedule(schedule: List[Dict], day: int) -> str: - """Форматирует график на один день""" - day_blocks = [b for b in schedule if b["day"] == day] - if not day_blocks: - return "❌ Немає даних для цього дня" - - start_date = datetime.now() - date_str = (start_date + timedelta(days=day)).strftime('%d.%m.%Y') - day_name = "🟠 Сьогодні" if day == 0 else "🔶 Завтра" - - lines = [ - f"{day_name} • {date_str}", - "" - ] - - # Статистика - lines.append("📊 Статистика") - stats = get_day_statistics(day_blocks) - - if stats["total"] == 0: - lines.append("└ 🟢 Відключень немає!") - else: - total_time = format_time_duration(stats["total"]) - lines.append(f"├ ⏱ Всього: {total_time}") - - if stats["confirmed"] > 0: - confirmed_time = format_time_duration(stats["confirmed"]) - lines.append(f"├ 🔴 Підтверджено: {confirmed_time}") - - if stats["possible"] > 0: - possible_time = format_time_duration(stats["possible"]) - lines.append(f"└ 🟠 Можливо: {possible_time}") - else: - lines.append(f"└ 🟢 Решта часу світло") - - lines.append("") - - # Детальный список отключений - disconnections = [] - current_status = None - start_time = None - current_confirmed = None - current_queue = None - - for block in day_blocks: - hour = block["hour"] - - for half_idx, half in enumerate([block["first_half"], block["second_half"]]): - time_str = f"{hour:02d}:00" if half_idx == 0 else f"{hour:02d}:30" - - if half["status"] == "off": - if current_status != "off": - start_time = time_str - current_confirmed = half["confirmed"] - current_queue = half["queue"] - current_status = "off" - else: - if current_status == "off": - icon = "🔴" if current_confirmed else "🟠" - queue_text = f" (Ч.{current_queue})" if current_queue else "" - disconnections.append(f"{icon} {start_time} - {time_str}{queue_text}") - current_status = half["status"] - - # Если день закончился на отключении - if current_status == "off": - icon = "🔴" if current_confirmed else "🟠" - queue_text = f" (Ч.{current_queue})" if current_queue else "" - next_hour = (day_blocks[-1]["hour"] + 1) % 24 - end_time = f"{next_hour:02d}:00" - disconnections.append(f"{icon} {start_time} - {end_time}{queue_text}") - - if disconnections: - lines.append("⚡️ Розклад відключень") - for idx, disc in enumerate(disconnections, 1): - lines.append(f"{idx}. {disc}") - - lines.append("") - lines.append("🔴 підтверджено • 🟠 можливо • 🟢 світло") - - return "\n".join(lines) - - -def schedules_differ(old_schedule: Optional[List[Dict]], new_schedule: Optional[List[Dict]]) -> bool: - """Проверяет отличия между графиками""" - if old_schedule is None or new_schedule is None: - return True - - if len(old_schedule) != len(new_schedule): - return True - - for old, new in zip(old_schedule, new_schedule): - if old["day"] >= 2: - break - - if (old["first_half"] != new["first_half"] or - old["second_half"] != new["second_half"]): - return True - - return False - - -# ============================================================================ -# УВЕДОМЛЕНИЯ -# ============================================================================ - -async def send_to_all_users(message_text: str, parse_mode: str = "HTML"): - """Отправляет сообщение всем пользователям""" - if not ALLOWED_CHAT_IDS: - logger.warning("Нет допущенных ID чатов для отправки уведомлений") - return - - for chat_id in ALLOWED_CHAT_IDS: - try: - await bot.send_message(chat_id, message_text, parse_mode=parse_mode) - logger.info(f"✅ Сообщение отправлено пользователю {chat_id}") - except Exception as e: - logger.error(f"❌ Ошибка отправки пользователю {chat_id}: {e}") - await asyncio.sleep(0.5) - - -async def check_schedule(): - """Проверяет график и отправляет уведомления""" - global last_schedule - - try: - logger.info("🔍 Проверка графика...") - html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) - new_schedule = parse_html(html) - - if schedules_differ(last_schedule, new_schedule): - logger.info("✨ Обнаружены изменения!") - message = format_schedule_message(new_schedule, days_to_show=2) - - if last_schedule is not None: - await send_to_all_users(f"🔄 Графік оновлено!\n\n{message}") - - last_schedule = new_schedule - else: - logger.info("✓ Графік без змін") - - except Exception as e: - logger.error(f"❌ Ошибка при проверке графика: {e}") - - -async def check_upcoming_disconnections(): - """Проверяет предстоящие события и отправляет предупреждения за 5 минут""" - global last_notification_time - - if last_schedule is None: - return - - now = datetime.now() - today_blocks = [b for b in last_schedule if b["day"] == 0] - - # Создаем список всех переходов (off -> on или on -> off) - transitions = [] - prev_status = None - - for block in today_blocks: - hour = block["hour"] - - for half_idx, half in enumerate([block["first_half"], block["second_half"]]): - minute = 0 if half_idx == 0 else 30 - time_str = f"{hour:02d}:{minute:02d}" - - current_status = half["status"] - - # Если статус изменился - это переход - if prev_status is not None and prev_status != current_status: - transitions.append({ - "hour": hour, - "minute": minute, - "time_str": time_str, - "from_status": prev_status, - "to_status": current_status, - "confirmed": half.get("confirmed"), - "queue": half.get("queue") - }) - - prev_status = current_status - - # Проверяем переходы - for transition in transitions: - event_time = now.replace( - hour=transition["hour"], - minute=transition["minute"], - second=0, - microsecond=0 - ) - - time_until = (event_time - now).total_seconds() / 60 - notification_key = f"{transition['hour']}:{transition['minute']}_{transition['to_status']}" - - # Если за 5 минут до события (±1 минута) и еще не отправляли - if 4 <= time_until <= 6: - # Проверяем, не отправляли ли уже уведомление сегодня - if notification_key in last_notification_time: - last_notif_time = last_notification_time[notification_key] - if last_notif_time.date() == now.date(): - continue # Уже отправляли сегодня - - # Переход на ОТКЛЮЧЕНИЕ (on -> off) - if transition["from_status"] == "on" and transition["to_status"] == "off": - icon = "🔴" if transition["confirmed"] else "🟠" - status = "підтверджено" if transition["confirmed"] else "можливе" - queue_info = f" (Черга {transition['queue']})" if transition['queue'] else "" - - warning = ( - f"⚠️ УВАГА! ВІДКЛЮЧЕННЯ\n\n" - f"Через ~5 хвилин\n" - f"Час: {transition['time_str']}\n" - f"Статус: {icon} {status}{queue_info}" - ) - - await send_to_all_users(warning) - last_notification_time[notification_key] = now - logger.info(f"📢 Відправлено попередження про ВІДКЛЮЧЕННЯ в {transition['time_str']}") - - # Переход на ВКЛЮЧЕНИЕ (off -> on) - elif transition["from_status"] == "off" and transition["to_status"] == "on": - warning = ( - f"✅ УВАГА! ВКЛЮЧЕННЯ\n\n" - f"Через ~5 хвилин буде світло\n" - f"Час: {transition['time_str']}" - ) - - await send_to_all_users(warning) - last_notification_time[notification_key] = now - logger.info(f"📢 Відправлено попередження про ВКЛЮЧЕННЯ в {transition['time_str']}") - - -async def monitoring_loop(): - """Основной цикл мониторинга""" - await check_schedule() - - while True: - try: - await asyncio.sleep(CHECK_INTERVAL) - await check_schedule() - await check_upcoming_disconnections() - except Exception as e: - logger.error(f"Ошибка в цикле мониторинга: {e}") - await asyncio.sleep(5) - - -# ============================================================================ -# ОБРАБОТЧИКИ КОМАНД -# ============================================================================ - -@dp.message(Command("start")) -async def cmd_start(message: Message): - """Обработчик /start""" - if message.chat.id not in ALLOWED_CHAT_IDS: - await message.answer("❌ У вас немає доступу до цього бота.") - return - - await message.answer( - "👋 Ласкаво просимо!\n\n" - "🤖 Бот для моніторингу графіку відключень світла\n\n" - "✨ Можливості:\n" - "• 📊 Перегляд графіку на сьогодні і завтра\n" - "• 🔔 Автоматичні сповіщення за 5 хвилин до подій\n" - "• 🔄 Моніторинг змін графіку\n\n" - "Використовуйте кнопки нижче 👇", - parse_mode="HTML", - reply_markup=get_main_keyboard() - ) - - -@dp.message(lambda msg: msg.text == "ℹ️ Про бота") -async def cmd_info(message: Message): - """Показывает информацию о боте""" - if message.chat.id not in ALLOWED_CHAT_IDS: - return - - await message.answer( - "ℹ️ Про бота\n\n" - "🚀 Версія: 2.2 (Стабільна)\n\n" - "📝 Реліз-ноути:\n" - "├ 15.11.2024: Адаптація під оновлену логіку сайту VOE\n" - "├ Виправлено парсинг half.left та half.right\n" - "├ Покращено визначення підтвердження відключень\n" - "└ Оптимізовано обробку статусу для всієї години\n\n" - "⚡ Функціональність:\n" - "├ Моніторинг графіку 24/7\n" - "├ Сповіщення за 5 хвилин\n" - "├ Детальна статистика дня\n" - "└ Красива візуалізація\n\n" - "🔐 Безпека: Використовуються .env файли\n" - "💾 Джерело: voe.com.ua", - parse_mode="HTML", - reply_markup=get_main_keyboard() - ) - - -@dp.message(Command("schedule")) -async def cmd_schedule(message: Message): - """Показывает полный график""" - if message.chat.id not in ALLOWED_CHAT_IDS: - await message.answer("❌ У вас немає доступу.") - return - - try: - await message.answer("⏳ Завантаження графіку...") - html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) - schedule = parse_html(html) - text = format_schedule_message(schedule, days_to_show=2) - await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard()) - except Exception as e: - await message.answer( - f"❌ Помилка: {str(e)}", - parse_mode="HTML", - reply_markup=get_main_keyboard() - ) - - -@dp.message(Command("today")) -async def cmd_today(message: Message): - """Показывает график на сегодня""" - if message.chat.id not in ALLOWED_CHAT_IDS: - await message.answer("❌ У вас немає доступу.") - return - - try: - await message.answer("⏳ Завантаження графіку сьогодні...") - html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) - schedule = parse_html(html) - text = format_single_day_schedule(schedule, 0) - await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard()) - except Exception as e: - await message.answer( - f"❌ Помилка: {str(e)}", - parse_mode="HTML", - reply_markup=get_main_keyboard() - ) - - -@dp.message(Command("tomorrow")) -async def cmd_tomorrow(message: Message): - """Показывает график на завтра""" - if message.chat.id not in ALLOWED_CHAT_IDS: - await message.answer("❌ У вас немає доступу.") - return - - try: - await message.answer("⏳ Завантаження графіку завтра...") - html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) - schedule = parse_html(html) - text = format_single_day_schedule(schedule, 1) - await message.answer(text, parse_mode="HTML", reply_markup=get_main_keyboard()) - # await message.answer("❌ Функція тимчасово недоступна. Чекаємо на оновлення сайту", parse_mode="HTML", reply_markup=get_main_keyboard()) - except Exception as e: - await message.answer( - f"❌ Помилка: {str(e)}", - parse_mode="HTML", - reply_markup=get_main_keyboard() - ) - - -@dp.message(Command("check")) -async def cmd_check(message: Message): - """Принудительная проверка графика""" - if message.chat.id not in ALLOWED_CHAT_IDS: - await message.answer("❌ У вас немає доступу.") - return - - try: - await message.answer("🔄 Перевіряю графік...", parse_mode="HTML") - html = get_voe_html(VOE_CITY_ID, VOE_STREET_ID, VOE_HOUSE_ID) - new_schedule = parse_html(html) - - prefix = "✅ Знайдено зміни!\n\n" if schedules_differ(last_schedule, new_schedule) else "✓ Графік без змін\n\n" - result = prefix + format_schedule_message(new_schedule, days_to_show=2) - - await message.answer(result, parse_mode="HTML", reply_markup=get_main_keyboard()) - except Exception as e: - await message.answer( - f"❌ Помилка: {str(e)}", - parse_mode="HTML", - reply_markup=get_main_keyboard() - ) - - -@dp.message() -async def handle_text(message: Message): - """Обработчик текстовых сообщений и кнопок""" - if message.chat.id not in ALLOWED_CHAT_IDS: - return - - text = message.text - - # Кнопка "Графік" - if text == "📊 Графік": - await cmd_schedule(message) - - # Кнопка "Сьогодні" - elif text == "📅 Сьогодні": - await cmd_today(message) - - # Кнопка "Завтра" - elif text == "📅 Завтра": - await cmd_tomorrow(message) - - # Кнопка "Оновити" - elif text == "🔄 Оновити": - await cmd_check(message) - - # Кнопка "Про бота" - elif text == "ℹ️ Про бота": - await cmd_info(message) - - # Неизвестная команда - else: - await message.answer( - "❓ Команда не розпізнана\n\n" - "Використовуйте кнопки на клавіатурі або команди:\n" - "/start • /today • /tomorrow • /schedule • /check", - parse_mode="HTML", - reply_markup=get_main_keyboard() - ) - - -# ============================================================================ -# ГЛАВНАЯ ФУНКЦИЯ -# ============================================================================ - -async def main(): - """Главная функция""" - logger.info("=" * 50) - logger.info("ЗАПУСК БОТА V2.2 (stable 2.2, 15.11.2025)") - logger.info("=" * 50) - - if not TELEGRAM_TOKEN or TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "YOUR_TOKEN_HERE") - logger.error("❌ TELEGRAM_TOKEN не конфігурований! Напишіть токен в .env файл") - return - - if not ALLOWED_CHAT_IDS: - logger.error("❌ ALLOWED_CHAT_IDS не конфігуровані! Напишіть ID в .env файл") - return - - logger.info(f"📌 Allowed chat ids: {ALLOWED_CHAT_IDS}") - logger.info(f"⏱ Інтервал перевірки: {CHECK_INTERVAL} сек") - logger.info("=" * 50) - - # Запускаем мониторинг - monitoring_task = asyncio.create_task(monitoring_loop()) - - try: - await dp.start_polling(bot) - except KeyboardInterrupt: - logger.info("⏹ Бот зупинений користувачем") - finally: - monitoring_task.cancel() - await bot.session.close() - logger.info("✓ Підключення закрито") - - -if __name__ == "__main__": - try: - asyncio.run(main()) - except KeyboardInterrupt: - logger.info("⏹ Завершено") \ No newline at end of file diff --git a/dtek_notif/pyproject.toml b/dtek_notif/pyproject.toml deleted file mode 100644 index 4017156..0000000 --- a/dtek_notif/pyproject.toml +++ /dev/null @@ -1,7 +0,0 @@ -[project] -name = "dtek-notif" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.13" -dependencies = [] diff --git a/dtek_notif/requirements.txt b/dtek_notif/requirements.txt deleted file mode 100644 index 72e2a35..0000000 --- a/dtek_notif/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -requests>=2.31.0 -beautifulsoup4>=4.12.0 -aiogram>=3.3.0 -python-dotenv>=1.0.0 -aiohttp>=3.9.0 diff --git a/edu_master/.env.example b/edu_master/.env.example deleted file mode 100644 index e0c4a1e..0000000 --- a/edu_master/.env.example +++ /dev/null @@ -1,13 +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 -WEBINAR_TELEGRAM_TOKEN=your_telegram_bot_token_here -WEBINAR_ADMIN_ID=123456789 diff --git a/edu_master/compose.yaml b/edu_master/compose.yaml deleted file mode 100644 index 5292200..0000000 --- a/edu_master/compose.yaml +++ /dev/null @@ -1,49 +0,0 @@ -services: - redis: - image: redis: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:latest - 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:latest - 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/phpsessid-bot/Dockerfile b/edu_master/phpsessid-bot/Dockerfile deleted file mode 100644 index b66780c..0000000 --- a/edu_master/phpsessid-bot/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install system dependencies -RUN apt-get update && apt-get install -y redis-tools && rm -rf /var/lib/apt/lists/* - -# Install dependencies -RUN pip install requests redis - -# 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 4c3e3cc..0000000 --- a/edu_master/phpsessid-bot/bot.py +++ /dev/null @@ -1,130 +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 ec581f7..0000000 --- a/edu_master/webinar-checker/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM python:3.11-slim - -WORKDIR /app - -# Install dependencies -RUN pip install --upgrade pip && pip install playwright==1.56.0 redis requests "python-telegram-bot[job-queue]" - -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 e0cee47..0000000 --- a/edu_master/webinar-checker/checker.py +++ /dev/null @@ -1,927 +0,0 @@ -import contextlib -import json -import logging -import os -import re -import time -from datetime import datetime, timedelta - -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" - -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')) - -# 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 - -# 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': "✅ Язык изменен на русский", - 'flag_ru': "🇷🇺 Русский", - 'flag_uk': "🇺🇦 Українська", - 'flag_en': "🇬🇧 English", - 'history_cleared': "✅ История вебинаров очищена", - 'history_clear_failed': "❌ Ошибка при очистке истории", - }, - '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': "✅ Мову змінено на українську", - 'flag_ru': "🇷🇺 Русский", - 'flag_uk': "🇺🇦 Українська", - 'flag_en': "🇬🇧 English", - 'history_cleared': "✅ Історія вебінарів очищена", - 'history_clear_failed': "❌ Помилка при очищенні історії", - }, - '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 English", - 'flag_ru': "🇷🇺 Русский", - 'flag_uk': "🇺🇦 Українська", - 'flag_en': "🇬🇧 English", - 'history_cleared': "✅ Webinar history cleared", - 'history_clear_failed': "❌ Error clearing history", - } -} - -# --- Language Helper Functions --- - -def get_user_language(user_id: int) -> str: - """Get user's preferred language from Redis. Default: Ukrainian.""" - lang = redis_client.get(f"user:{user_id}:language") - return lang if lang in ['ru', 'uk', 'en'] else 'uk' - -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.""" - lang = get_user_language(user_id) - message = TRANSLATIONS.get(lang, TRANSLATIONS['uk']).get(key, key) - if kwargs: - return message.format(**kwargs) - return message - -def get_language_keyboard(): - """Generate language selection keyboard.""" - keyboard = [ - [ - InlineKeyboardButton("🇷🇺 Русский", callback_data="lang_ru"), - InlineKeyboardButton("🇺🇦 Українська", callback_data="lang_uk"), - ], - [ - InlineKeyboardButton("🇬🇧 English", 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 --- - -DIARY_MONTH_NAMES = ['', 'Січня', 'Лютого', 'Березня', 'Квітня', 'Травня', 'Червня', - 'Липня', 'Серпня', 'Вересня', 'Жовтня', 'Листопада', 'Грудня'] - -DIARY_WEEKDAYS_SHORT = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Нд'] - -def get_diary_keyboard(): - today = datetime.now() - keyboard = [ - [ - InlineKeyboardButton(f"📌 Сьогодні ({today.day}.{today.month:02d})", callback_data="diary_today"), - InlineKeyboardButton("📌 Завтра", callback_data="diary_tomorrow"), - ], - [ - InlineKeyboardButton("📅 Цей тиждень", callback_data="diary_week"), - InlineKeyboardButton("📅 Весь місяць", 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}}).""" - 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 - 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 et: - events.append(et) - - weekday = weekdays[col_idx] if col_idx < len(weekdays) else '' - days[day_num] = {'weekday': weekday, 'events': events} - - return month_text, days - - -async def fetch_diary_data(phpsessid: str) -> dict | None: - logger.info("Fetching diary data via Playwright...") - try: - async with async_playwright() as p: - browser = await p.chromium.connect(PLAYWRIGHT_WS) - 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 page.goto(DIARY_URL, wait_until='domcontentloaded') - 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) - 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: - await page.close() - await context_browser.close() - finally: - await browser.close() - 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_diary_day(data: dict, day_num: int) -> 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("Немає подій") - else: - for e in day_data['events']: - lines.append(f"📌 {e}") - lines.append(f"\n🔗 {DIARY_URL}") - return "\n".join(lines) - -def format_diary_week(data: dict, today: datetime) -> str: - days = data.get('days', {}) - _parse_diary_month(data.get('monthFullText', '')) - monday = today - timedelta(days=today.weekday()) - sunday = monday + timedelta(days=6) - lines = [f"📅 Тиждень {monday.day}.{monday.month} – {sunday.day}.{sunday.month}\n"] - for i in range(7): - d = monday + timedelta(days=i) - day_data = days.get(str(d.day)) - lines.append(f"─ {DIARY_WEEKDAYS_SHORT[i]} {d.day}.{d.month} ─") - if not day_data or not day_data.get('events'): - lines.append("Немає подій\n") - else: - for e in day_data['events']: - lines.append(f"📌 {e}") - lines.append("") - lines.append(f"🔗 {DIARY_URL}") - return "\n".join(lines) - -def format_diary_month(data: dict) -> 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] - events = day_data.get('events', []) - weekday = day_data.get('weekday', '') - lines.append(f"─ {weekday} {day_num} ─") - if not events: - lines.append("Немає подій\n") - else: - for e in events: - lines.append(f"📌 {e}") - 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 - -# --- Command Handlers --- - -async def start(update: Update, _context: ContextTypes.DEFAULT_TYPE): - """Handle /start command.""" - user = update.effective_user - chat = update.effective_chat - logger.info(f"User {user.id} ({user.username}) started the bot in chat {chat.id} ({chat.type}).") - - # Check whitelist - MUST be the user executing the command - if not is_whitelisted(user.id): - await update.message.reply_text(t(user.id, 'access_denied')) - return - - # Add to subscribers (Chat ID!) - redis_client.sadd(KEY_SUBSCRIBERS, chat.id) - - msg = t(chat.id, 'welcome', name=user.first_name) - - if user.id == ADMIN_ID and chat.type == "private": - msg += t(chat.id, 'welcome_admin') - await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user.id)) - else: - await update.message.reply_text(msg, parse_mode='HTML') - -async def help_command(update: Update, _context: ContextTypes.DEFAULT_TYPE): - """Handle /help command.""" - user_id = update.effective_user.id - chat_id = update.effective_chat.id - msg = t(chat_id, 'help_title') + t(chat_id, 'help_commands') - - if user_id == ADMIN_ID and update.effective_chat.type == "private": - msg += t(chat_id, 'help_admin') - await update.message.reply_text(msg, parse_mode='HTML', reply_markup=get_admin_keyboard(user_id)) - else: - await update.message.reply_text(msg, parse_mode='HTML') - -async def stop_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Handle /stop command (unsubscribe).""" - user = update.effective_user - chat = update.effective_chat - - # Permission check: Whitelisted user OR Group Admin - if not (is_whitelisted(user.id) or await is_group_admin(update, context)): - await update.message.reply_text(t(chat.id, 'access_denied')) # Or specific "admin only" message - return - - redis_client.srem(KEY_SUBSCRIBERS, chat.id) - await update.message.reply_text(t(chat.id, 'whitelist_disabled').replace(" whitelist", " notifications").replace("Білий список", "Сповіщення").replace("Белый список", "Уведомления") if chat.id else "Unsubscribed") - -async def language_command(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Handle /language command.""" - user = update.effective_user - chat = update.effective_chat - - # Permission check for groups - if not (is_whitelisted(user.id) or await is_group_admin(update, context)): - return - - await update.message.reply_text( - t(chat.id, 'select_language'), - parse_mode='HTML', - reply_markup=get_language_keyboard() - ) - -async def add_user(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Add user to whitelist (admin only).""" - admin_id = update.effective_user.id - if admin_id != ADMIN_ID: - await update.message.reply_text(t(admin_id, 'admin_only')) - return - - if not context.args: - await update.message.reply_text(t(admin_id, 'usage_adduser')) - return - - try: - user_id = int(context.args[0]) - redis_client.sadd(KEY_WHITELIST, str(user_id)) - await update.message.reply_text(t(admin_id, 'user_added', user_id=user_id)) - logger.info(f"Admin added user {user_id} to whitelist") - except ValueError: - await update.message.reply_text(t(admin_id, 'invalid_user_id')) - -async def remove_user(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Remove user from whitelist (admin only).""" - admin_id = update.effective_user.id - if admin_id != ADMIN_ID: - await update.message.reply_text(t(admin_id, 'admin_only')) - return - - if not context.args: - await update.message.reply_text(t(admin_id, 'usage_removeuser')) - return - - try: - user_id = int(context.args[0]) - if str(user_id) == str(ADMIN_ID): - await update.message.reply_text(t(admin_id, 'cannot_remove_admin')) - return - - removed = redis_client.srem(KEY_WHITELIST, str(user_id)) - if removed: - await update.message.reply_text(t(admin_id, 'user_removed', user_id=user_id)) - logger.info(f"Admin removed user {user_id} from whitelist") - else: - await update.message.reply_text(t(admin_id, 'user_not_in_whitelist', user_id=user_id)) - except ValueError: - await update.message.reply_text(t(admin_id, 'invalid_user_id')) - -async def clear_history(update: Update, _context: ContextTypes.DEFAULT_TYPE): - """Clear webinar history (admin only).""" - admin_id = update.effective_user.id - if admin_id != ADMIN_ID: - await update.message.reply_text(t(admin_id, 'admin_only')) - return - - try: - redis_client.delete(KEY_WEBINAR_HISTORY) - await update.message.reply_text(t(admin_id, 'history_cleared')) - logger.info("Admin cleared webinar history") - except Exception as e: - logger.error(f"Failed to clear history: {e}") - await update.message.reply_text(t(admin_id, 'history_clear_failed')) - -async def diary_command(update: Update, _context: ContextTypes.DEFAULT_TYPE): - user = update.effective_user - if not is_whitelisted(user.id): - await update.message.reply_text(t(user.id, 'access_denied')) - return - await update.message.reply_text( - "📅 Щоденник — виберіть період:", - parse_mode='HTML', - reply_markup=get_diary_keyboard() - ) - -async def diary_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): - query = update.callback_query - user_id = query.from_user.id - await query.answer() - - if user_id != ADMIN_ID and not is_whitelisted(user_id): - await query.edit_message_text("⛔ Доступ заборонено.") - return - - data = query.data - if data == "diary_refresh": - context.user_data.pop('diary_cache', None) - await query.edit_message_text("🔄 Завантажую щоденник...") - diary_data = await _get_diary_data(context) - if not diary_data: - await query.edit_message_text("❌ Не вдалося завантажити щоденник. Немає сесії або помилка.") - return - await query.edit_message_text( - "📅 Щоденник — виберіть період:", - parse_mode='HTML', - reply_markup=get_diary_keyboard() - ) - return - - await query.edit_message_text("🔄 Завантажую щоденник...") - diary_data = await _get_diary_data(context) - if not diary_data: - await query.edit_message_text("❌ Не вдалося завантажити щоденник.") - return - - today = datetime.now() - if data == "diary_today": - text = format_diary_day(diary_data, today.day) - elif data == "diary_tomorrow": - tomorrow = today + timedelta(days=1) - if tomorrow.day < today.day: - text = "❌ Дані за наступний місяць недоступні. Перейдіть на сайт." - else: - text = format_diary_day(diary_data, tomorrow.day) - elif data == "diary_week": - text = format_diary_week(diary_data, today) - elif data == "diary_month": - text = format_diary_month(diary_data) - else: - return - - if len(text) > 4096: - text = text[:4090] + "\n\n✂️ ...(обрізано)" - - await query.edit_message_text( - text, - parse_mode='HTML', - reply_markup=get_diary_keyboard() - ) - -# --- Admin Callbacks --- - -async def admin_callback(update: Update, context: ContextTypes.DEFAULT_TYPE): - """Handle admin panel button clicks.""" - query = update.callback_query - user_id = query.from_user.id - - if user_id != ADMIN_ID: - await query.answer(t(user_id, 'admin_only'), show_alert=True) - return - - await query.answer() - data = query.data - - if data == "toggle_whitelist": - current = redis_client.get(KEY_WHITELIST_ENABLED) - new_state = "0" if current != "0" else "1" - redis_client.set(KEY_WHITELIST_ENABLED, new_state) - state_text = t(user_id, 'whitelist_disabled') if new_state == "0" else t(user_id, 'whitelist_enabled') - await query.edit_message_reply_markup(reply_markup=get_admin_keyboard(user_id)) - await query.message.reply_text(state_text) - - elif data == "view_whitelist": - members = redis_client.smembers(KEY_WHITELIST) - msg = t(user_id, 'whitelist_title') + ("\n".join(members) if members else t(user_id, 'empty')) - await query.message.reply_text(msg, parse_mode='HTML') - - elif data == "view_subscribers": - subs = redis_client.smembers(KEY_SUBSCRIBERS) - msg = t(user_id, 'subscribers_title') + ("\n".join(subs) if subs else t(user_id, 'empty')) - await query.message.reply_text(msg, parse_mode='HTML') - - elif data == "force_check": - await query.message.reply_text(t(user_id, 'force_check_running')) - result = await check_webinars_job(context) - - if result is None: - await query.message.reply_text(t(user_id, 'check_failed')) - elif result == 0: - await query.message.reply_text(t(user_id, 'check_completed_none')) - else: - await query.message.reply_text(t(user_id, 'check_completed', count=result)) - -async def language_callback(update: Update, _context: ContextTypes.DEFAULT_TYPE): - """Handle language selection button clicks.""" - query = update.callback_query - user_id = query.from_user.id - data = query.data - - if data.startswith("lang_"): - lang = data.split("_")[1] - set_user_language(user_id, lang) - await query.answer() - await query.edit_message_text( - t(user_id, 'language_changed'), - parse_mode='HTML' - ) - -# --- Webinar Checking Job --- - -def get_webinar_key(url: str) -> str: - """Generate unique key for a webinar based on URL.""" - return url - -def get_stored_webinars() -> list: - """Get list of stored webinar keys from Redis.""" - data = redis_client.get(KEY_WEBINAR_HISTORY) - if data: - try: - return json.loads(data) - except Exception as e: - logger.error(f"Failed to parse webinar history: {e}") - return [] - -def store_webinars(webinar_keys: list): - """Store up to 3 most recent webinar keys in Redis.""" - # Keep only last 3 - webinar_keys = webinar_keys[-3:] - try: - redis_client.set(KEY_WEBINAR_HISTORY, json.dumps(webinar_keys)) - logger.info(f"Stored {len(webinar_keys)} webinar(s) in history") - except Exception as e: - logger.error(f"Failed to store webinar history: {e}") - -async def check_webinars_job(context: ContextTypes.DEFAULT_TYPE): - """Background job to check for webinars using Async Playwright. - - Returns: - int: Number of webinars found, or None if check failed - """ - logger.info("Running webinar check...") - - phpsessid = redis_client.get(KEY_PHPSESSID) - if not phpsessid: - logger.warning("PHPSESSID missing. Skipping check.") - # --- DEBUG LOGGING --- - try: - with open('phpsessid_missing.log', 'a') as f: - f.write(f"[{os.getcwd()}] PHPSESSID missing at {context.job.last_run: %Y-%m-%d %H:%M:%S}\n") - except Exception as e: - logger.error(f"Failed to write PHPSESSID debug log: {e}") - # --------------------- - return None - - current_webinars = [] # List of dicts with name, url, and formatted text - content = "" - - try: - async with async_playwright() as p: - # Connect to remote Playwright service - browser = await p.chromium.connect(PLAYWRIGHT_WS) - - try: - # Create browser context with user agent - context_browser = await browser.new_context(user_agent=USER_AGENT) - - # Add PHPSESSID cookie - await context_browser.add_cookies([{ - 'name': 'PHPSESSID', - 'value': phpsessid, - 'domain': 'edu.edu.vn.ua', - 'path': '/' - }]) - - # Create new page - page = await context_browser.new_page() - - try: - # Navigate to webinar page - await page.goto(WEBINAR_URL, wait_until='domcontentloaded') - - # Wait for the table to load - await page.wait_for_selector('#meetings table', timeout=10000) - await page.wait_for_timeout(2000) - - # Get page content - content = await page.content() - - # Check if "no webinar" message is present - if "Жодного онлайн уроку зараз" not in content: - logger.info("!!! WEBINAR FOUND !!!") - - # Extract webinar details from table rows - rows = page.locator('#meetings table tbody tr') - count = await rows.count() - - for i in range(count): - row = rows.nth(i) - text = await row.inner_text() - - if "Жодного онлайн уроку зараз" not in text: - # Extract name (topic) from first column - name_elem = row.locator('td').nth(0) - name = await name_elem.inner_text() - name = name.strip() - - # Extract join URL from fourth column - url_elem = row.locator('td').nth(3).locator('a[href*="/webinar/join/"]').first - url = await url_elem.get_attribute('href') - - if name and url: - current_webinars.append({ - 'name': name, - 'url': url, - 'text': text.strip() - }) - logger.info(f"Found webinar: {name} -> {url}") - else: - logger.info("No webinars found (expected message present)") - - except Exception as e: - logger.error(f"Error checking page: {e}. Saving content for debug.") - # If page content is available, save it on error - with contextlib.suppress(Exception): - if page and not content: - content = await page.content() - - return None - finally: - await page.close() - await context_browser.close() - - finally: - await browser.close() - - except Exception as e: - logger.error(f"Playwright error: {e}") - return None - - # --- DEBUG LOGGING (Saving last response content) --- - if not current_webinars and content: #if no webinars found, save the page content - try: - with open('response.html', 'w', encoding='utf-8') as f: - f.write(content) - logger.info("Saved page content to response.html for debug.") - except Exception as e: - logger.error(f"Failed to write debug HTML: {e}") - # ----------------------------------------------------- - - # Check for NEW webinars and notify - if current_webinars: - # Get stored webinar history - stored_keys = get_stored_webinars() - logger.info(f"Stored webinar keys: {stored_keys}") - - # Find new webinars (not in history) - new_webinars = [] - current_keys = [] - - for webinar in current_webinars: - key = get_webinar_key(webinar['url']) - current_keys.append(key) - - if key not in stored_keys: - new_webinars.append(webinar) - logger.info(f"NEW webinar detected: {webinar['name']}") - - # Update stored history with current webinars - # Merge old and new, keeping only last 5 - updated_keys = stored_keys + [k for k in current_keys if k not in stored_keys] - store_webinars(updated_keys) - - # Notify subscribers ONLY about NEW webinars - if new_webinars: - subscribers = redis_client.smembers(KEY_SUBSCRIBERS) - logger.info(f"Sending notification about {len(new_webinars)} new webinar(s) to {len(subscribers)} subscriber(s)") - - for sub_id in subscribers: - try: - # Build message in user's language - # sub_id comes from redis set as string, convert to int for translation lookup - webinar_items = "\n\n".join([ - t(int(sub_id), 'webinar_item', name=w['name'], url=w['url']) - for w in new_webinars - ]) - message = t(int(sub_id), 'webinar_found') + webinar_items - - await context.bot.send_message(chat_id=sub_id, text=message, parse_mode='HTML') - logger.info(f"Notification sent to {sub_id}") - except Exception as e: - logger.error(f"Failed to send to {sub_id}: {e}") - else: - logger.info(f"Found {len(current_webinars)} webinar(s), but all are already known") - - return len(current_webinars) - -# --- Main --- - -def main(): - if not WEBINAR_TELEGRAM_TOKEN: - logger.error("WEBINAR_TELEGRAM_TOKEN is missing!") - return - - # Set default whitelist state if not set - if not redis_client.exists(KEY_WHITELIST_ENABLED): - redis_client.set(KEY_WHITELIST_ENABLED, "1") # Enabled by default - - # Add admin to whitelist - if ADMIN_ID: - redis_client.sadd(KEY_WHITELIST, str(ADMIN_ID)) - - app = Application.builder().token(WEBINAR_TELEGRAM_TOKEN).build() - - # Handlers - app.add_handler(CommandHandler("start", start)) - app.add_handler(CommandHandler("stop", stop_command)) - app.add_handler(CommandHandler("help", help_command)) - app.add_handler(CommandHandler("language", language_command)) - app.add_handler(CommandHandler("adduser", add_user)) - app.add_handler(CommandHandler("removeuser", remove_user)) - app.add_handler(CommandHandler("clearhistory", clear_history)) - app.add_handler(CommandHandler("diary", diary_command)) - - # Callback handlers - diary first, then language selection, then admin panel - app.add_handler(CallbackQueryHandler(diary_callback, pattern="^diary_")) - app.add_handler(CallbackQueryHandler(language_callback, pattern="^lang_")) - app.add_handler(CallbackQueryHandler(admin_callback)) - - # Job Queue - job_queue = app.job_queue - job_queue.run_repeating(check_webinars_job, interval=WEBINAR_CHECK_INTERVAL, first=10) - - logger.info("Bot started polling...") - app.run_polling() - -if __name__ == "__main__": - main() diff --git a/errorpages/Dockerfile b/errorpages/Dockerfile deleted file mode 100644 index 712656b..0000000 --- a/errorpages/Dockerfile +++ /dev/null @@ -1,5 +0,0 @@ -FROM nginx:alpine -RUN rm -rf /usr/share/nginx/html/* -COPY html /usr/share/nginx/html -EXPOSE 80 -CMD ["nginx", "-g", "daemon off;"] diff --git a/errorpages/compose.yaml b/errorpages/compose.yaml deleted file mode 100644 index 0048268..0000000 --- a/errorpages/compose.yaml +++ /dev/null @@ -1,21 +0,0 @@ -services: - errorpage: - build: . - image: gcr.forust.xyz/forust/error-pages:latest - pull_policy: build - container_name: error-pages - restart: unless-stopped - # ports: - # - 1234:80 - networks: - - proxy - labels: - - "traefik.enable=true" - - "traefik.http.services.error-pages.loadbalancer.server.port=80" - # Error handler middleware - - "traefik.http.middlewares.error-pages.errors.status=400,402-599" - - "traefik.http.middlewares.error-pages.errors.service=error-pages" - - "traefik.http.middlewares.error-pages.errors.query=/{status}.html" -networks: - proxy: - external: true diff --git a/errorpages/html/403.html b/errorpages/html/403.html deleted file mode 100644 index 0360cf5..0000000 --- a/errorpages/html/403.html +++ /dev/null @@ -1,208 +0,0 @@ - - - - - - - 403 // Forbidden - - - - - - - -
-
-

403

-

> Forbidden / Access Denied.

-
- -
- -
-

./error_message

-

You do not have permission to access this resource.

-
-

Check the System Status if you believe this is an - error.

-
- -
-

root@error:~$ sudo access_resource

-

User is not in the sudoers file. This incident will be reported.

-

© XRock - Just Signal.

-
-
- - - - \ No newline at end of file diff --git a/errorpages/html/404.html b/errorpages/html/404.html deleted file mode 100644 index ead1121..0000000 --- a/errorpages/html/404.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - 404 // Not Found - - - - - - - -
-
-

404

-

> Page Not Found / Lost in the Void.

-
- -
- -
-

./error_message

-

The page you are looking for does not exist or has been moved.

-
-

Check the System Status if you believe this is an error.

-
- -
-

root@error:~$ ping target

-

Destination Host Unreachable

-

© XRock - Just Signal.

-
-
- - - - diff --git a/errorpages/html/500.html b/errorpages/html/500.html deleted file mode 100644 index 4936f5c..0000000 --- a/errorpages/html/500.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - 500 // Server Error - - - - - - - -
-
-

500

-

> Internal Server Error / System Failure.

-
- -
- -
-

./error_message

-

Something went wrong on our end. We are working to fix it.

-
-

Check the System Status for more information.

-
- -
-

root@error:~$ systemctl status service

-

Active: failed (Result: core-dump)

-

© XRock - Just Signal.

-
-
- - - - \ No newline at end of file diff --git a/errorpages/html/502.html b/errorpages/html/502.html deleted file mode 100644 index d81a7bb..0000000 --- a/errorpages/html/502.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - 502 // Bad Gateway - - - - - - - -
-
-

502

-

> Bad Gateway / System Failure.

-
- -
- -
-

./error_message

-

The server received an invalid response from the upstream server.

-
-

Check the System Status for more information.

-
- -
-

root@error:~$ curl -I upstream_host

-

HTTP/1.1 502 Bad Gateway

-

© XRock - Just Signal.

-
-
- - - - \ No newline at end of file diff --git a/errorpages/html/503.html b/errorpages/html/503.html deleted file mode 100644 index 08ebec7..0000000 --- a/errorpages/html/503.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - 503 // Service Unavailable - - - - - - - -
-
-

503

-

> Service Unavailable / System Failure.

-
- -
- -
-

./error_message

-

The server is currently unable to handle the request due to maintenance or overload.

-
-

Check the System Status for more information.

-
- -
-

root@error:~$ systemctl start service

-

Job for service failed because the control process exited with error code.

-

© XRock - Just Signal.

-
-
- - - - \ No newline at end of file diff --git a/errorpages/html/504.html b/errorpages/html/504.html deleted file mode 100644 index 45deca2..0000000 --- a/errorpages/html/504.html +++ /dev/null @@ -1,207 +0,0 @@ - - - - - - - 504 // Gateway Timeout - - - - - - - -
-
-

504

-

> Gateway Timeout / System Failure.

-
- -
- -
-

./error_message

-

The server did not receive a timely response from the upstream server.

-
-

Check the System Status for more information.

-
- -
-

root@error:~$ timeout 30s curl upstream

-

curl: (28) Operation timed out after 30001 milliseconds with 0 bytes received

-

© XRock - Just Signal.

-
-
- - - - \ No newline at end of file diff --git a/errorpages/k8s/error-pages.yaml b/errorpages/k8s/error-pages.yaml deleted file mode 100644 index 107a084..0000000 --- a/errorpages/k8s/error-pages.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: error-pages-service - namespace: error-pages -spec: - selector: - app: error-pages - ports: - - port: 80 - targetPort: 80 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: error-pages-deployment - namespace: error-pages -spec: - replicas: 1 - selector: - matchLabels: - app: error-pages - template: - metadata: - labels: - app: error-pages - spec: - containers: - - name: error-pages - image: gcr.forust.xyz/forust/error-pages:latest - ports: - - containerPort: 80 ---- diff --git a/errorpages/k8s/namespace.yaml b/errorpages/k8s/namespace.yaml deleted file mode 100644 index af279b5..0000000 --- a/errorpages/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: error-pages diff --git a/gitea/.env.example b/gitea/.env.example deleted file mode 100644 index 916e272..0000000 --- a/gitea/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -GITEA_POSTGRES_USER= -GITEA_POSTGRES_PASSWORD= -GITEA_POSTGRES_DB=gitea -GITEA_SMTP_PASS= -MAILER_ADDR= -SERVICE_EMAIL=email.used.by.services@domain.tld \ No newline at end of file diff --git a/gitea/compose.yaml b/gitea/compose.yaml deleted file mode 100644 index cf327e0..0000000 --- a/gitea/compose.yaml +++ /dev/null @@ -1,78 +0,0 @@ -services: - server: - image: docker.gitea.com/gitea:1.26 - container_name: gitea - restart: always - environment: - - USER_UID=1000 - - USER_GID=1000 - # Database - - GITEA__database__DB_TYPE=postgres - - GITEA__database__HOST=db:5432 - - GITEA__database__USER=gitea - - GITEA__database__PASSWD=gitea - - GITEA__database__NAME=gitea - # Server - - GITEA__server__ROOT_URL=https://gitea.forust.xyz - - GITEA__server__SSH_DOMAIN=gitssh.forust.xyz - - GITEA__server__SSH_PORT=2221 - # Mailer - - GITEA__mailer__ENABLED=true - - GITEA__mailer__FROM=${SERVICE_EMAIL} - - GITEA__mailer__SMTP_ADDR=${MAILER_ADDR}:465 - - GITEA__mailer__USER=${SERVICE_EMAIL} - - GITEA__mailer__PASSWD=${GITEA_SMTP_PASS} - - GITEA__mailer__PROTOCOL=SMTP - - GITEA__service__REGISTER_EMAIL_CONFIRM=true - - GITEA__service__ENABLE_NOTIFY_MAIL=true - volumes: - - ./gitea-data:/data - - /etc/timezone:/etc/timezone:ro - - /etc/localtime:/etc/localtime:ro - labels: - - "traefik.enable=true" - - "traefik.http.services.gitea.loadbalancer.server.port=3000" - - # Prod Router - - "traefik.http.routers.gitea.rule=Host(`gitea.forust.xyz`)" - - "traefik.http.routers.gitea.entrypoints=websecure" - - "traefik.http.routers.gitea.tls.certresolver" - # Local Router - - "traefik.http.routers.gitea-local.rule=Host(`gitea.workstation.internal`)" - - "traefik.http.routers.gitea-local.entrypoints=websecure" - - "traefik.http.routers.gitea-local.tls=true" - # Dev Router - - "traefik.http.routers.gitea-dev.rule=Host(`gitea.gigaforust.internal`)" - - "traefik.http.routers.gitea-dev.entrypoints=websecure" - - "traefik.http.routers.gitea-dev.tls=true" - # SSH Router - - "traefik.tcp.services.gitea.loadbalancer.server.port=22" - - "traefik.tcp.routers.gitea.entrypoints=ssh" - - "traefik.tcp.routers.gitea.rule=HostSNI(`*`)" - # Gitea container registry Router - - "traefik.http.routers.gitea-registry.rule=Host(`gcr.forust.xyz`) && PathPrefix(`/v2`)" - - "traefik.http.routers.gitea-registry.entrypoints=websecure" - - "traefik.http.routers.gitea-registry.tls.certresolver=letsencrypt" - ports: - - "2221:22" - networks: - - gitea-db - - proxy - depends_on: - - db - db: - image: docker.io/library/postgres:14 - restart: always - environment: - - POSTGRES_USER=gitea - - POSTGRES_PASSWORD=gitea - - POSTGRES_DB=gitea - volumes: - - ./gitea-db/:/var/lib/postgresql/data - networks: - - gitea-db -networks: - gitea-db: - external: false - proxy: - external: true diff --git a/gitea/k8s/config.yaml b/gitea/k8s/config.yaml deleted file mode 100644 index 5f4f39e..0000000 --- a/gitea/k8s/config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: gitea-config - namespace: gitea -data: - GITEA__server__DOMAIN: "gitea.forust.xyz" - GITEA__server__ROOT_URL: "https://gitea.forust.xyz" - GITEA__server__SSH_DOMAIN: "gitssh.forust.xyz" - GITEA__server__SSH_PORT: "2221" - - GITEA__database__DB_TYPE: "postgres" - GITEA__database__HOST: "gitea-postgres-service:5432" - GITEA__database__NAME: "gitea" - GITEA__security__REVERSE_PROXY_LIMIT: "1" - GITEA__security__REVERSE_PROXY_TRUSTED_PROXIES: "*" - - GITEA__mailer__ENABLED: "false" - USER_UID: "1000" - USER_GID: "1000" diff --git a/gitea/k8s/gitea.yaml b/gitea/k8s/gitea.yaml deleted file mode 100644 index 11e34a8..0000000 --- a/gitea/k8s/gitea.yaml +++ /dev/null @@ -1,71 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: gitea-service - namespace: gitea -spec: - selector: - app: gitea - ports: - - port: 3000 - name: http - targetPort: 3000 - - port: 2221 - name: ssh - targetPort: 22 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: gitea-deployment - namespace: gitea -spec: - replicas: 1 - selector: - matchLabels: - app: gitea - template: - metadata: - labels: - app: gitea - spec: - containers: - - name: gitea - image: docker.gitea.com/gitea:1.26 - envFrom: - - configMapRef: - name: gitea-config - - secretRef: - name: gitea-secrets - ports: - - containerPort: 3000 - name: http - - containerPort: 2221 - name: ssh - volumeMounts: - - name: gitea-data - mountPath: /data - resources: - requests: - memory: "512Mi" - cpu: "300m" - limits: - memory: "1.5Gi" - cpu: "1300m" - volumes: - - name: gitea-data - persistentVolumeClaim: - claimName: gitea-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: gitea-pvc - namespace: gitea -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 5Gi ---- diff --git a/gitea/k8s/ingress.yaml b/gitea/k8s/ingress.yaml deleted file mode 100644 index a00da16..0000000 --- a/gitea/k8s/ingress.yaml +++ /dev/null @@ -1,64 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: gitea-prod - namespace: gitea -spec: - entryPoints: - - websecure - routes: - - match: Host(`gitea.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: gitea-service - port: 3000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: gitea-local - namespace: gitea -spec: - entryPoints: - - websecure - routes: - - match: Host(`gitea.workstation.internal`) || Host(`gitea.gigaforust.internal`) - kind: Rule - services: - - name: gitea-service - port: 3000 ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: gitea-registry - namespace: gitea -spec: - entryPoints: - - websecure - routes: - - match: Host(`gcr.forust.xyz`) && PathPrefix(`/v2`) - kind: Rule - services: - - name: gitea-service - port: 3000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRouteTCP -metadata: - name: gitea-ssh - namespace: gitea -spec: - entryPoints: - - ssh - routes: - - match: HostSNI(`*`) - services: - - name: gitea-service - port: 2221 diff --git a/gitea/k8s/namespace.yaml b/gitea/k8s/namespace.yaml deleted file mode 100644 index 09a988f..0000000 --- a/gitea/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: gitea diff --git a/gitea/k8s/postgres.yaml b/gitea/k8s/postgres.yaml deleted file mode 100644 index 2f08640..0000000 --- a/gitea/k8s/postgres.yaml +++ /dev/null @@ -1,62 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: gitea-postgres-service - namespace: gitea -spec: - clusterIP: None - selector: - app: gitea-postgres - ports: - - port: 5432 - targetPort: 5432 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: gitea-postgres-statefulset - namespace: gitea -spec: - selector: - matchLabels: - app: gitea-postgres - serviceName: gitea-postgres-service - replicas: 1 - template: - metadata: - labels: - app: gitea-postgres - spec: - containers: - - name: gitea-postgres - image: postgres:14 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: gitea-secrets - key: GITEA__database__USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: gitea-secrets - key: GITEA__database__PASSWD - - name: POSTGRES_DB - valueFrom: - secretKeyRef: - name: gitea-secrets - key: GITEA__database__USER - ports: - - containerPort: 5432 - name: postgres - volumeMounts: - - name: postgres-data - mountPath: /var/lib/postgresql/data - volumeClaimTemplates: - - metadata: - name: postgres-data - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 1Gi diff --git a/gitea/k8s/secrets.yaml.example b/gitea/k8s/secrets.yaml.example deleted file mode 100644 index dc26dc2..0000000 --- a/gitea/k8s/secrets.yaml.example +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: gitea-secrets - namespace: gitea -type: Opaque -stringData: - GITEA__database__USER: "gitea" - GITEA__database__PASSWD: "gitea" diff --git a/glance/compose.yaml b/glance/compose.yaml deleted file mode 100644 index cfc66c5..0000000 --- a/glance/compose.yaml +++ /dev/null @@ -1,36 +0,0 @@ -services: - glance: - container_name: glance - image: glanceapp/glance - restart: unless-stopped - volumes: - - ./config:/app/config:ro - - ./assets:/app/assets:ro - - /etc/localtime:/etc/localtime:ro - - /var/run/docker.sock:/var/run/docker.sock:ro - env_file: .env - labels: - - "traefik.enable=true" - - "traefik.docker.network=proxy" - - "traefik.services.glance.loadbalancer.server.port=8080" - - # Prod Router - - "traefik.http.routers.glance.rule=(Host(`forust.xyz`) || Host(`www.forust.xyz`)) && PathPrefix(`/glance`)" - - "traefik.http.routers.glance.entrypoints=websecure" - - "traefik.http.routers.glance.priority=50" - - "traefik.http.routers.glance.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.glance-local.rule=Host(`workstation.internal`) && PathPrefix(`/glance`)" - - "traefik.http.routers.glance-local.entrypoints=websecure" - - "traefik.http.routers.glance-local.priority=50" - - "traefik.http.routers.glance-local.tls=true" - # Dev Router - - "traefik.http.routers.glance-dev.rule=Host(`gigaforust.internal`) && PathPrefix(`/glance`)" - - "traefik.http.routers.glance-dev.entrypoints=websecure" - - "traefik.http.routers.glance-dev.priority=50" - - "traefik.http.routers.glance-dev.tls=true" - networks: - - proxy -networks: - proxy: - external: true diff --git a/glance/config/glance.yml b/glance/config/glance.yml deleted file mode 100644 index bad1f16..0000000 --- a/glance/config/glance.yml +++ /dev/null @@ -1,16 +0,0 @@ -server: - assets-path: /app/assets - -theme: - background-color: 240 21 15 - contrast-multiplier: 1.2 - primary-color: 217 92 83 - positive-color: 115 54 76 - negative-color: 347 70 65 - custom-css-file: /assets/user.css - -pages: - # It's not necessary to create a new file for each page and include it, you can simply - # put its contents here, though multiple pages are easier to manage when separated - - $include: home.yml - - $include: monitor.yml diff --git a/glance/config/home.yml b/glance/config/home.yml deleted file mode 100644 index 13d66fb..0000000 --- a/glance/config/home.yml +++ /dev/null @@ -1,76 +0,0 @@ -- name: Home - # Optionally, if you only have a single page you can hide the desktop navigation for a cleaner look - # hide-desktop-navigation: true - columns: - - size: small - widgets: - - type: calendar - first-day-of-week: monday - - - type: rss - limit: 10 - collapse-after: 3 - cache: 12h - feeds: - - url: https://selfh.st/rss/ - title: selfh.st - - - type: twitch-channels - channels: - - DeadP47_ - - - size: full - widgets: - - type: group - widgets: - - type: hacker-news - - type: lobsters - - - type: videos - channels: - - UCXuqSBlHAE6Xw-yeJA0Tunw # Linus Tech Tips - - UCR-DXc1voovS8nhAvccRZhg # Jeff Geerling - - UCsBjURrPoezykLs9EqgamOA # Fireship - - UCBJycsmduvYEL83R_U4JriQ # Marques Brownlee - - UCHnyfMqiRRG1u-2MsSQLbXA # Veritasium - - - type: group - widgets: - - type: reddit - subreddit: technology - show-thumbnails: true - - type: reddit - subreddit: selfhosted - show-thumbnails: true - - - size: small - widgets: - - type: weather - location: London, United Kingdom - units: metric # alternatively "imperial" - hour-format: 12h # alternatively "24h" - # Optionally hide the location from being displayed in the widget - hide-location: true - - - type: markets - markets: - - symbol: SPY - name: S&P 500 - - symbol: BTC-USD - name: Bitcoin - - symbol: NVDA - name: NVIDIA - - symbol: AAPL - name: Apple - - symbol: MSFT - name: Microsoft - - - type: releases - cache: 1d - # Without authentication the Github API allows for up to 60 requests per hour. You can create a - # read-only token from your Github account settings and use it here to increase the limit. - # token: ... - repositories: - - glanceapp/glance - - go-gitea/gitea - - nextcloud/all-in-one diff --git a/glance/config/monitor.yml b/glance/config/monitor.yml deleted file mode 100644 index f03bc92..0000000 --- a/glance/config/monitor.yml +++ /dev/null @@ -1,13 +0,0 @@ -- name: Monitoring - columns: - - size: small - widgets: - - type: dns-stats - service: adguard - url: http://adguardhome:3000 - username: forust - password: ${ADGUARD_PASSWORD} - - size: full - widgets: - - type: docker-containers - hide-by-default: false diff --git a/glance/k8s/glance-config.yaml b/glance/k8s/glance-config.yaml deleted file mode 100644 index 5593220..0000000 --- a/glance/k8s/glance-config.yaml +++ /dev/null @@ -1,211 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: glance-assets - namespace: glance -data: - # Инжектируем твой брутализм напрямую в ассеты - user.css: | - :root { - --bg-color: #050505; - --text-color: #e0e0e0; - --accent: #ffffff; - --dim: #666666; - --font-mono: 'Courier New', Courier, monospace; - } - - /* Принудительно ставим моноширинный шрифт для всего дашборда */ - body, id, main, div, span, p, a, h1, h2, h3 { - font-family: var(--font-mono) !important; - letter-spacing: -0.5px; - } - - /* Ломаем закругления Glance и делаем жесткие рамки */ - .widget, .card, main div, [class*="widget"], [class*="card"] { - border-radius: 0px !important; - border: 1px solid var(--dim) !important; - box-shadow: none !important; - background-color: var(--bg-color) !important; - } - - /* Стилизация ссылок под ховер-эффект из твоего style.css */ - a { - color: var(--text-color) !important; - text-decoration: none !important; - border-bottom: 1px solid var(--dim) !important; - transition: all 0.2s; - } - a:hover { - background-color: var(--text-color) !important; - color: var(--bg-color) !important; - border-color: var(--text-color) !important; - } - - /* Кастомизация заголовков внутри модулей */ - h2, .widget-title, [class*="title"] { - text-transform: uppercase; - font-weight: bold; - } ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: glance-config - namespace: glance -data: - glance.yml: | - server: - assets-path: /app/assets - proxied: true - base-url: /glance - theme: - # Перевели #050505 и #e0e0e0 в формат HSL для Glance - background-color: 0 0 2 # Истинно черный фон - primary-color: 0 0 88 # Светло-серый текст - contrast-multiplier: 1.4 - positive-color: 140 50 50 # Зеленый для UP-сервисов (не вырвиглазный) - negative-color: 0 70 50 # Красный для упавших сайтов - custom-css-file: /assets/user.css - pages: - - $include: home.yml - - $include: docker.yml - - $include: monitor.yml - - home.yml: | - - name: Home - columns: - - size: small - widgets: - - type: clock - hour-format: 24h - timezones: - - timezone: Europe/Bratislava - label: Bratislava - - timezone: Europe/Kyiv - label: Kyiv - - timezone: Europe/Moscow - label: St. Petersburg - - type: calendar - first-day-of-week: monday - - type: rss - limit: 10 - collapse-after: 3 - cache: 12h - feeds: - - url: https://selfh.st/rss/ - title: selfh.st - - size: full - widgets: - - type: group - widgets: - - type: hacker-news - - type: lobsters - - type: videos - channels: - - UCXuqSBlHAE6Xw-yeJA0Tunw - - UCR-DXc1voovS8nhAvccRZhg - - UCsBjURrPoezykLs9EqgamOA - - UCBJycsmduvYEL83R_U4JriQ - - UCHnyfMqiRRG1u-2MsSQLbXA - - type: group - widgets: - - type: reddit - subreddit: technology - show-thumbnails: true - - type: reddit - subreddit: selfhosted - show-thumbnails: true - - size: small - widgets: - - type: weather - location: London, United Kingdom - units: metric - hour-format: 12h - hide-location: true - - type: markets - markets: - - symbol: SPY - name: S&P 500 - - symbol: BTC-USD - name: Bitcoin - - symbol: NVDA - name: NVIDIA - - symbol: AAPL - name: Apple - - symbol: MSFT - name: Microsoft - - type: releases - cache: 1d - repositories: - - glanceapp/glance - - go-gitea/gitea - - nextcloud/all-in-one - - docker.yml: | - - name: Docker - columns: - - size: full - widgets: - - type: docker-containers - hide-by-default: false - - monitor.yml: | - - name: Monitoring - columns: - - size: small - widgets: - - type: dns-stats - service: adguard - url: http://adguard-service.adguard.svc.cluster.local:3000 - username: forust - password: ${ADGUARD_PASSWORD} - - size: full - widgets: - - type: monitor - title: Services Status - cache: 1m - sites: - - title: forust.xyz - url: https://forust.xyz - - title: dns.forust.xyz - url: https://dns.forust.xyz - - title: www.lk-tour.com.ua - url: https://www.lk-tour.com.ua - - title: lk-tour.com.ua - url: https://lk-tour.com.ua - # - title: gitssh.forust.xyz - # url: https://gitssh.forust.xyz - # - title: gcr.forust.xyz - # url: https://gcr.forust.xyz/v2/ - - title: gitea.forust.xyz - url: https://gitea.forust.xyz - - title: nextcloud.forust.xyz - url: https://nextcloud.forust.xyz - - title: mc.forust.xyz - url: https://mc.forust.xyz/map - - title: auth.forust.xyz - url: https://auth.forust.xyz - - title: metube.forust.xyz - url: https://metube.forust.xyz - - title: dockmon.forust.xyz - url: https://dockmon.forust.xyz - - title: portainer.forust.xyz - url: https://portainer.forust.xyz - - title: termix.forust.xyz - url: https://termix.forust.xyz - - title: uptime.forust.xyz - url: https://uptime.forust.xyz - - title: cmk.forust.xyz - url: https://cmk.forust.xyz - - title: search.forust.xyz - url: https://search.forust.xyz - - title: status.forust.xyz - url: https://status.forust.xyz - - title: traefik.forust.xyz - url: https://traefik.forust.xyz - - title: media.forust.xyz - url: https://media.forust.xyz - - title: wfs.forust.xyz - url: https://wfs.forust.xyz - - title: forust.xyz/convert - url: https://forust.xyz/convert diff --git a/glance/k8s/glance.yaml b/glance/k8s/glance.yaml deleted file mode 100644 index 4c9d0a8..0000000 --- a/glance/k8s/glance.yaml +++ /dev/null @@ -1,78 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: glance-service - namespace: glance -spec: - selector: - app: glance - ports: - - port: 8080 - targetPort: 8080 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: glance-deployment - namespace: glance -spec: - replicas: 1 - selector: - matchLabels: - app: glance - template: - metadata: - labels: - app: glance - spec: - containers: - - name: glance - image: glanceapp/glance - envFrom: - - secretRef: - name: glance-secrets - ports: - - containerPort: 8080 - volumeMounts: - - name: glance-config - mountPath: /app/config/glance.yml - subPath: glance.yml - - name: glance-config - mountPath: /app/config/home.yml - subPath: home.yml - - name: glance-config - mountPath: /app/config/docker.yml - subPath: docker.yml - - name: glance-config - mountPath: /app/config/monitor.yml - subPath: monitor.yml - - name: glance-assets - mountPath: /app/assets/user.css - subPath: user.css - - name: docker-socket - mountPath: /var/run/docker.sock - - name: localtime - mountPath: /etc/localtime - readOnly: true - resources: - requests: - memory: "30Mi" - cpu: "20m" - limits: - memory: "100Mi" - cpu: "50m" - volumes: - - name: glance-config - configMap: - name: glance-config - - name: glance-assets - configMap: - name: glance-config - - name: docker-socket - hostPath: - path: /var/run/docker.sock - type: Socket - - name: localtime - hostPath: - path: /etc/localtime - type: File diff --git a/glance/k8s/ingress.yaml b/glance/k8s/ingress.yaml deleted file mode 100644 index 2fe4ad0..0000000 --- a/glance/k8s/ingress.yaml +++ /dev/null @@ -1,47 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: glance-prod - namespace: glance -spec: - entryPoints: - - websecure - routes: - - match: (Host(`forust.xyz`) || Host(`www.forust.xyz`)) && PathPrefix(`/glance`) - kind: Rule - priority: 50 - middlewares: - - name: glance-stripprefix - services: - - name: glance-service - port: 8080 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: glance-local - namespace: glance -spec: - entryPoints: - - websecure - routes: - - match: (Host(`glance.workstation.internal`) || Host(`glance.gigaforust.internal`)) && PathPrefix(`/glance`) - kind: Rule - middlewares: - - name: glance-stripprefix - priority: 50 - services: - - name: glance-service - port: 8080 ---- -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: glance-stripprefix - namespace: glance -spec: - stripPrefix: - prefixes: - - /glance diff --git a/glance/k8s/namespace.yaml b/glance/k8s/namespace.yaml deleted file mode 100644 index 1dae605..0000000 --- a/glance/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: glance diff --git a/headscale/compose.yaml b/headscale/compose.yaml deleted file mode 100644 index 76422f9..0000000 --- a/headscale/compose.yaml +++ /dev/null @@ -1,132 +0,0 @@ -services: - headscale: - image: headscale/headscale:latest - restart: unless-stopped - container_name: headscale-server - command: serve - ports: - - 18080:8080 - - 19090:9090 - networks: - - proxy - volumes: - - ./config/headscale.yaml:/etc/headscale/config.yaml - - data:/var/lib/headscale - - ./config/policy.json:/var/lib/headscale/policy.json - labels: - - "me.tale.headplane.target: headscale" - - "traefik.enable=true" - - "traefik.http.services.headscale.loadbalancer.server.port=8080" - - "traefik.http.services.headscale-metrics.loadbalancer.server.port=9090" - - ## SERVICE - # Prod Router - - "traefik.http.routers.headscale.rule=Host(`hs.forust.xyz`)" - - "traefik.http.routers.headscale.entrypoints=websecure" - - "traefik.http.routers.headscale.service=headscale" - - "traefik.http.routers.headscale.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.headscale-local.rule=Host(`hs.workstation.internal`)" - - "traefik.http.routers.headscale-local.entrypoints=websecure" - - "traefik.http.routers.headscale-local.service=headscale" - - "traefik.http.routers.headscale-local.tls=true" - # Dev Router - - "traefik.http.routers.headscale-dev.rule=Host(`hs.gigaforust.internal`)" - - "traefik.http.routers.headscale-dev.entrypoints=websecure" - - "traefik.http.routers.headscale-dev.service=headscale" - - "traefik.http.routers.headscale-dev.tls=true" - - ## METRICS - # Prod Router - - "traefik.http.routers.headscale-metrics.rule=Host(`hs.forust.xyz`) && PathPrefix(`/metrics`)" - - "traefik.http.routers.headscale-metrics.entrypoints=websecure" - - "traefik.http.routers.headscale-metrics.service=headscale-metrics" - - "traefik.http.routers.headscale-metrics.tls=true" - # Local Router - - "traefik.http.routers.headscale-metrics-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/metrics`)" - - "traefik.http.routers.headscale-metrics-local.entrypoints=websecure" - - "traefik.http.routers.headscale-metrics-local.service=headscale-metrics" - - "traefik.http.routers.headscale-metrics-local.tls=true" - # Dev Router - - "traefik.http.routers.headscale-metrics-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/metrics`)" - - "traefik.http.routers.headscale-metrics-dev.entrypoints=websecure" - - "traefik.http.routers.headscale-metrics-dev.service=headscale-metrics" - - "traefik.http.routers.headscale-metrics-dev.tls=true" - headplane: - image: ghcr.io/tale/headplane:latest - container_name: headplane - restart: unless-stopped - ports: - - "13000:3000" - volumes: - - ./config/headplane.yaml:/etc/headplane/config.yaml - - ./config/headscale.yaml:/etc/headscale/config.yaml - - headplane-data:/var/lib/headplane - - /var/run/docker.sock:/var/run/docker.sock:ro - networks: - - proxy - labels: - - "traefik.enable=true" - - "traefik.http.services.headplane.loadbalancer.server.port=3000" - - # Middleware: add /admin prefix for root requests - - "traefik.http.middlewares.headplane-prefix.addPrefix.prefix=/admin" - - # Prod Root Router - - "traefik.http.routers.headplane-root.rule=Host(`hp.forust.xyz`)" - - "traefik.http.routers.headplane-root.entrypoints=websecure" - - "traefik.http.routers.headplane-root.middlewares=headplane-prefix" - - "traefik.http.routers.headplane-root.tls.certresolver=letsencrypt" - # Prod Router - - "traefik.http.routers.headplane.rule=Host(`hp.forust.xyz`) && PathPrefix(`/admin`)" - - "traefik.http.routers.headplane.entrypoints=websecure" - - "traefik.http.routers.headplane.tls.certresolver=letsencrypt" - # Local Root Router - - "traefik.http.routers.headplane-root-local.rule=Host(`hp.workstation.internal`)" - - "traefik.http.routers.headplane-root-local.entrypoints=websecure" - - "traefik.http.routers.headplane-root-local.middlewares=headplane-prefix" - - "traefik.http.routers.headplane-root-local.tls=true" - # Local Router - - "traefik.http.routers.headplane-local.rule=Host(`hp.workstation.internal`) && PathPrefix(`/admin`)" - - "traefik.http.routers.headplane-local.entrypoints=websecure" - - "traefik.http.routers.headplane-local.tls=true" - # Dev Root Router - - "traefik.http.routers.headplane-root-dev.rule=Host(`hp.gigaforust.internal`)" - - "traefik.http.routers.headplane-root-dev.entrypoints=websecure" - - "traefik.http.routers.headplane-root-dev.middlewares=headplane-prefix" - - "traefik.http.routers.headplane-root-dev.tls=true" - # Dev Router - - "traefik.http.routers.headplane-dev.rule=Host(`hp.gigaforust.internal`) && PathPrefix(`/admin`)" - - "traefik.http.routers.headplane-dev.entrypoints=websecure" - - "traefik.http.routers.headplane-dev.tls=true" - web: - image: goodieshq/headscale-admin:latest - restart: unless-stopped - ports: - - 10080:80 - labels: - - "traefik.enable=true" - - "traefik.http.services.headscale-ui.loadbalancer.server.port=80" - - # Prod Router - - "traefik.http.routers.headscale-ui.rule=Host(`hs.forust.xyz`) && PathPrefix(`/admin`)" - - "traefik.http.routers.headscale-ui.entrypoints=websecure" - - "traefik.http.routers.headscale-ui.middlewares=security-chain@file" - - "traefik.http.routers.headscale-ui.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.headscale-ui-local.rule=Host(`hs.workstation.internal`) && PathPrefix(`/admin`)" - - "traefik.http.routers.headscale-ui-local.entrypoints=websecure" - - "traefik.http.routers.headscale-ui-local.tls=true" - # Dev Router - - "traefik.http.routers.headscale-ui-dev.rule=Host(`hs.gigaforust.internal`) && PathPrefix(`/admin`)" - - "traefik.http.routers.headscale-ui-dev.entrypoints=websecure" - - "traefik.http.routers.headscale-ui-dev.tls=true" - networks: - - proxy -volumes: - data: - headplane-data: - name: headplane_data -networks: - proxy: - external: true diff --git a/headscale/config/headplane.yaml.example b/headscale/config/headplane.yaml.example deleted file mode 100644 index fc11b0c..0000000 --- a/headscale/config/headplane.yaml.example +++ /dev/null @@ -1,221 +0,0 @@ -# Configuration for the Headplane server and web application -server: - # These are the default values, change them as needed - host: "0.0.0.0" - port: 3000 - # Should not include the dashboard prefix (/admin) portion. - # # Prod server_url - # base_url: https://hs.forust.xyz - # # Local base_url - # base_url: https://hs.workstation.internal - # # Dev base_url - # base_url: https://hs.gigaforust.internal - - # You may provide `cookie_secret_path` instead to read a value from disk. - # See https://headplane.net/configuration/#sensitive-values - cookie_secret: "" - - # Whether cookies should be marked as Secure - # * Should be false if running without HTTPs - # * Should be true if running behind a reverse proxy with HTTPs - cookie_secure: true - # The maximum age of the session cookie in seconds - cookie_max_age: 86400 # 1 day in seconds - - # This is not required, but if you want to restrict the cookie - # to a specific domain, set it here. Otherwise leave it commented out. - # This may not work as expected if not using a reverse proxy. - # cookie_domain: "" - - # The path to persist Headplane specific data. All data going forward - # is stored in this directory, including the internal database and - # any cache related files. - data_path: "/var/lib/headplane" - - # The info secret is optional and allows access to certain debug endpoints - # that may expose sensitive information about your Headplane instance. - # - # As of now, this protects the /api/info endpoint which exposes details about - # the Headplane and Headscale versions in use. In the future, more endpoints - # may be protected by this secret. - # - # If not set, these endpoints will be disabled. - # info_secret: "" - -# Headscale specific settings to allow Headplane to talk -# to Headscale and access deep integration features -headscale: - # The URL to your Headscale instance - # (All API requests are routed through this URL) - # (THIS IS NOT the gRPC endpoint, but the HTTP endpoint) - # - # IMPORTANT: If you are using TLS this MUST be set to `https://` - url: "http://headscale-server:8080" - - # If you use the TLS configuration in Headscale, and you are not using - # Let's Encrypt for your certificate, pass in the path to the certificate. - # (This has no effect if `url` does not start with `https://`) - # tls_cert_path: "/var/lib/headplane/tls.crt" - - # Optional, public URL if its different from the `headscale.url` - # This affects certain parts of the web UI which shows Headscale's URL - public_url: "https://headscale.example.com" - - # Path to the Headscale configuration file - # This is optional, but HIGHLY recommended for the best experience - # If this is read only, Headplane will show your configuration settings - # in the Web UI, but they cannot be changed. - config_path: "/etc/headscale/config.yaml" - - # Whether the Headscale configuration should be strictly validated - # when reading from `config_path`. If true, Headplane will not interact - # with Headscale if there are any issues with the configuration file. - # - # This is recommended to be true for production deployments to, however it - # may not work if you are using a version of Headscale that has configuration - # options unknown to Headplane. - config_strict: true - - # If you are using `dns.extra_records_path` in your Headscale - # configuration, you need to set this to the path for Headplane - # to be able to read the DNS records. - # - # Pass it in if using Docker and ensure that the file is both - # readable and writable to the Headplane process. - # When using this, Headplane will no longer need to automatically - # restart Headscale for DNS record changes. - # dns_records_path: "/var/lib/headscale/extra_records.json" - -# Integration configurations for Headplane to interact with Headscale -integration: - # The Headplane agent allows retrieving information about nodes - # This allows the UI to display version, OS, and connectivity data - # You will see the Headplane agent in your Tailnet as a node when - # it connects. - agent: - enabled: false - - # To connect to your Tailnet, you need to generate a pre-auth key - # This can be done via the web UI or through the `headscale` CLI. - pre_authkey: "" - - # Optionally change the name of the agent in the Tailnet. - # host_name: "headplane-agent" - - # Configure different caching settings. By default, the agent will store - # caches in the path below for a maximum of 1 minute. If you want data - # to update faster, reduce the TTL, but this will increase the frequency - # of requests to Headscale. - # cache_ttl: 60 - # cache_path: /var/lib/headplane/agent_cache.json - - # The work_dir represents where the agent will store its data to be able - # to automatically reauthenticate with your Tailnet. It needs to be - # writable by the user running the Headplane process. - # - # If using Docker, it is best to leave this as the default. - # work_dir: "/var/lib/headplane/agent" - - # Only one of these should be enabled at a time or you will get errors - # This does not include the agent integration (above), which can be enabled - # at the same time as any of these and is recommended for the best experience. - docker: - enabled: true - - # By default we check for the presence of a container label (see the docs) - # to determine the container to signal when changes are made to DNS settings. - container_label: "me.tale.headplane.target=headscale" - - # HOWEVER, you can fallback to a container name if you desire, but this is - # not recommended as its brittle and doesn't work with orchestrators that - # automatically assign container names. - # - # If `container_name` is set, it will override any label checks. - # container_name: "headscale-server" - - # The path to the Docker socket (do not change this if you are unsure) - # Docker socket paths must start with unix:// or tcp:// and at the moment - # https connections are not supported. - socket: "unix:///var/run/docker.sock" - - # Please refer to docs/integration/Kubernetes.md for more information - # on how to configure the Kubernetes integration. There are requirements in - # order to allow Headscale to be controlled by Headplane in a cluster. - kubernetes: - enabled: false - # Validates the manifest for the Pod to ensure all of the criteria - # are set correctly. Turn this off if you are having issues with - # shareProcessNamespace not being validated correctly. - validate_manifest: true - # This should be the name of the Pod running Headscale and Headplane. - # If this isn't static you should be using the Kubernetes Downward API - # to set this value (refer to docs/Integrated-Mode.md for more info). - pod_name: "headscale" - - # Proc is the "Native" integration that only works when Headscale and - # Headplane are running outside of a container. There is no configuration, - # but you need to ensure that the Headplane process can terminate the - # Headscale process. - # - # (If they are both running under systemd as sudo, this will work). - proc: - enabled: false - -# OIDC Configuration for simpler authentication -# (This is optional, but recommended for the best experience) -# oidc: -# The OIDC issuer URL -# issuer: "https://accounts.google.com" - -# If you are using OIDC, you need to generate an API key -# that can be used to authenticate other sessions when signing in. -# -# This can be done with `headscale apikeys create --expiration 999d` -# headscale_api_key: "" - -# If your OIDC provider does not support discovery (does not have the URL at -# `/.well-known/openid-configuration`), you need to manually set endpoints. -# This also works to override endpoints if you so desire or if your OIDC -# discovery is missing certain endpoints (ie GitHub). -# For some typical providers, see https://headplane.net/features/sso. -# authorization_endpoint: "" -# token_endpoint: "" -# userinfo_endpoint: "" - -# The authentication method to use when communicating with the token endpoint. -# This is fully optional and Headplane will attempt to auto-detect the best -# method and fall back to `client_secret_basic` if unsure. -# token_endpoint_auth_method: "client_secret_post" - -# The client ID for the OIDC client -# For the best experience please ensure this is *identical* to the client_id -# you are using for Headscale. because -# client_id: "your-client-id" - -# The client secret for the OIDC client -# You may also provide `client_secret_path` instead to read a value from disk. -# See https://headplane.net/configuration/#sensitive-values -# client_secret: "" - -# Whether to use PKCE when authenticating users. This is recommended as it -# adds an extra layer of security to the authentication process. Enabling this -# means your OIDC provider must support PKCE and it must be enabled on the -# client. -# use_pkce: true - -# If you want to disable traditional login via Headscale API keys -# disable_api_key_login: false - -# By default profile pictures are pulled from the OIDC provider when -# we go to fetch the userinfo endpoint. Optionally, this can be set to -# "oidc" or "gravatar" as of 0.6.1. -# profile_picture_source: "gravatar" - -# The scopes to request when authenticating users. The default is below. -# scope: "openid email profile" - -# Extra query parameters can be passed to the authorization endpoint -# by setting them here. This is useful for providers that require any kind -# of custom hinting. -# extra_params: -# prompt: "select_account" # Example: force account selection on Google diff --git a/headscale/config/headscale.yaml.example b/headscale/config/headscale.yaml.example deleted file mode 100644 index 6242ccc..0000000 --- a/headscale/config/headscale.yaml.example +++ /dev/null @@ -1,80 +0,0 @@ -# https://wiki.serversatho.me/en/headscale - -# # Prod server_url -# server_url: https://hs.example.com -# # Local server_url -# server_url: https://hs.internal_domain.internal -# # Dev server_url -# server_url: https://hs.dev_internal_domain.internal -listen_addr: 0.0.0.0:8080 -metrics_listen_addr: 127.0.0.1:9090 -grpc_listen_addr: 127.0.0.1:50443 -grpc_allow_insecure: false -noise: - private_key_path: /var/lib/headscale/noise_private.key -prefixes: - v4: 100.64.0.0/10 - v6: fd7a:115c:a1e0::/48 - allocation: sequential -derp: - server: - enabled: true - region_id: 999 - region_code: "headscale" - region_name: "Headscale Embedded DERP" - stun_listen_addr: "0.0.0.0:3478" - private_key_path: /var/lib/headscale/derp_server_private.key - automatically_add_embedded_derp_region: true - ipv4: 1.2.3.4 - ipv6: 2001:db8::1 - urls: - - https://controlplane.tailscale.com/derpmap/default - paths: [] - auto_update_enabled: true - update_frequency: 24h -disable_check_updates: false -node: - ephemeral: - inactivity_timeout: 30m -database: - type: sqlite - debug: false - gorm: - prepare_stmt: true - parameterized_queries: true - skip_err_record_not_found: true - slow_threshold: 1000 - sqlite: - path: /var/lib/headscale/db.sqlite - write_ahead_log: true - wal_autocheckpoint: 1000 -acme_url: https://acme-v02.api.letsencrypt.org/directory -acme_email: "" -tls_letsencrypt_hostname: "" -tls_letsencrypt_cache_dir: /var/lib/headscale/cache -tls_letsencrypt_challenge_type: HTTP-01 -tls_letsencrypt_listen: ":http" -tls_cert_path: "" -tls_key_path: "" -log: - format: text - level: info -policy: - mode: database - path: "" -dns: - magic_dns: true - base_domain: example.com - nameservers: - global: - - 1.1.1.1 - - 1.0.0.1 - - 2606:4700:4700::1111 - - 2606:4700:4700::1001 - split: {} - search_domains: [] - extra_records: [] -unix_socket: /var/run/headscale/headscale.sock -unix_socket_permission: "0770" -logtail: - enabled: false diff --git a/headscale/config/policy.json.example b/headscale/config/policy.json.example deleted file mode 100644 index a45879f..0000000 --- a/headscale/config/policy.json.example +++ /dev/null @@ -1,27 +0,0 @@ -{ - "groups": { - "group:admin": [ - "admin@" - ], - "group:users": [] - }, - "tagOwners": {}, - "hosts": {}, - "acls": [ -{ - "randomizeClientPort": false, - "#ha-meta": { - "name": "users", - "open": true - }, - "action": "accept", - "src": [ - "autogroup:member" - ], - "dst": [ - "autogroup:self:*" - ] - } - ], - "ssh": [] -} \ No newline at end of file diff --git a/headscale/k8s/external-service.yaml b/headscale/k8s/external-service.yaml deleted file mode 100644 index c5e12ec..0000000 --- a/headscale/k8s/external-service.yaml +++ /dev/null @@ -1,86 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: headscale-server-external - namespace: headscale -spec: - ports: - - port: 8080 - targetPort: 18080 - name: http - - port: 9090 - targetPort: 19090 - name: metrics ---- -apiVersion: discovery.k8s.io/v1 -kind: EndpointSlice -metadata: - name: headscale-server-external - namespace: headscale - labels: - kubernetes.io/service-name: headscale-server-external -addressType: IPv4 -ports: - - port: 18080 - protocol: TCP - name: http - - port: 19090 - protocol: TCP - name: metrics -endpoints: - - addresses: - - "192.168.88.100" ---- -apiVersion: v1 -kind: Service -metadata: - name: headscale-ui-external - namespace: headscale -spec: - ports: - - port: 80 - targetPort: 10080 - name: http ---- -apiVersion: discovery.k8s.io/v1 -kind: EndpointSlice -metadata: - name: headscale-ui-external - namespace: headscale - labels: - kubernetes.io/service-name: headscale-ui-external -addressType: IPv4 -ports: - - port: 10080 - protocol: TCP - name: http -endpoints: - - addresses: - - "192.168.88.100" ---- -apiVersion: v1 -kind: Service -metadata: - name: headplane-external - namespace: headscale -spec: - ports: - - port: 3000 - targetPort: 13000 - name: http ---- -apiVersion: discovery.k8s.io/v1 -kind: EndpointSlice -metadata: - name: headplane-external - namespace: headscale - labels: - kubernetes.io/service-name: headplane-external -addressType: IPv4 -ports: - - port: 13000 - protocol: TCP - name: http -endpoints: - - addresses: - - "192.168.88.100" diff --git a/headscale/k8s/ingress.yaml b/headscale/k8s/ingress.yaml deleted file mode 100644 index 4f4e877..0000000 --- a/headscale/k8s/ingress.yaml +++ /dev/null @@ -1,111 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: Middleware -metadata: - name: headplane-prefix - namespace: headscale -spec: - addPrefix: - prefix: "/admin" ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: headscale-prod - namespace: headscale -spec: - entryPoints: - - websecure - routes: - - match: Host(`hs.forust.xyz`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: headscale-server-external - port: 8080 - - match: Host(`hs.forust.xyz`) && PathPrefix(`/admin`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: headscale-ui-external - port: 80 - - match: Host(`hs.forust.xyz`) && PathPrefix(`/metrics`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: headscale-server-external - port: 9090 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: headplane-prod - namespace: headscale -spec: - entryPoints: - - websecure - routes: - - match: Host(`hp.forust.xyz`) - kind: Rule - middlewares: - - name: headplane-prefix - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: headplane-external - port: 3000 - - match: Host(`hp.forust.xyz`) && PathPrefix(`/admin`) - kind: Rule - services: - - name: headplane-external - port: 3000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: headscale-local - namespace: headscale -spec: - entryPoints: - - websecure - routes: - - match: Host(`hs.workstation.internal`) || Host(`hs.gigaforust.internal`) - kind: Rule - services: - - name: headscale-server-external - port: 8080 - - match: (Host(`hs.workstation.internal`) || Host(`hs.gigaforust.internal`)) && PathPrefix(`/admin`) - kind: Rule - services: - - name: headscale-ui-external - port: 80 - - match: (Host(`hs.workstation.internal`) || Host(`hs.gigaforust.internal`)) && PathPrefix(`/metrics`) - kind: Rule - services: - - name: headscale-server-external - port: 9090 ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: headplane-local - namespace: headscale -spec: - routes: - - match: Host(`hp.workstation.internal`) || Host(`hp.gigaforust.internal`) - kind: Rule - middlewares: - - name: headplane-prefix - services: - - name: headplane-external - port: 3000 - - match: (Host(`hp.workstation.internal`) || Host(`hp.gigaforust.internal`)) && PathPrefix(`/admin`) - kind: Rule - services: - - name: headplane-external - port: 3000 diff --git a/headscale/k8s/namespace.yaml b/headscale/k8s/namespace.yaml deleted file mode 100644 index 74f64ea..0000000 --- a/headscale/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: headscale diff --git a/homepages/Dockerfile.forust b/homepages/Dockerfile.forust deleted file mode 100644 index 1ad49a9..0000000 --- a/homepages/Dockerfile.forust +++ /dev/null @@ -1,10 +0,0 @@ -# everyone use that -FROM nginx:alpine - -RUN rm -rf /usr/share/nginx/html/* - -COPY ./forust_files /usr/share/nginx/html - -EXPOSE 80 -# Start -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/homepages/Dockerfile.xdfnx b/homepages/Dockerfile.xdfnx deleted file mode 100644 index e788c02..0000000 --- a/homepages/Dockerfile.xdfnx +++ /dev/null @@ -1,10 +0,0 @@ -# everyone use that -FROM nginx:alpine - -RUN rm -rf /usr/share/nginx/html/* - -COPY ./xdfnx_files /usr/share/nginx/html - -EXPOSE 80 -# Start -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/homepages/compose.yaml b/homepages/compose.yaml deleted file mode 100644 index e6f5c73..0000000 --- a/homepages/compose.yaml +++ /dev/null @@ -1,65 +0,0 @@ -services: - forust: - build: - context: . - dockerfile: Dockerfile.forust - image: gcr.forust.xyz/forust/forust-homepage:latest - pull_policy: build - # ports: - # - "8085:80" - restart: unless-stopped - volumes: - - ./forust_files:/usr/share/nginx/html - networks: - - proxy - labels: - - "traefik.enable=true" - - "traefik.http.services.forust-homepage.loadbalancer.server.port=80" - - # Prod Router - - "traefik.http.routers.forust-homepage.rule=Host(`forust.xyz`) || Host(`www.forust.xyz`)" - - "traefik.http.routers.forust-homepage.entrypoints=websecure" - - "traefik.http.routers.forust-homepage.priority=10" - - "traefik.http.routers.forust-homepage.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.forust-homepage-local.rule=Host(`landing.workstation.internal`)" - - "traefik.http.routers.forust-homepage-local.entrypoints=websecure" - - "traefik.http.routers.forust-homepage-local.priority=10" - - "traefik.http.routers.forust-homepage-local.tls=true" - # Dev Router - - "traefik.http.routers.forust-homepage-dev.rule=Host(`landing.gigaforust.internal`)" - - "traefik.http.routers.forust-homepage-dev.entrypoints=websecure" - - "traefik.http.routers.forust-homepage-dev.priority=10" - - "traefik.http.routers.forust-homepage-dev.tls=true" - xdfnx: - build: - context: . - dockerfile: Dockerfile.xdfnx - image: gcr.forust.xyz/forust/xdfnx-homepage:latest - pull_policy: build - restart: unless-stopped - # ports: - # - "8086:80" - volumes: - - ./xdfnx_files:/usr/share/nginx/html - labels: - - "traefik.enable=true" - - "traefik.http.services.xdfnx-homepage.loadbalancer.server.port=80" - - # Prod Router - - "traefik.http.routers.xdfnx.rule=Host(`xdfnx.cfd`) || Host(`www.xdfnx.cfd`)" - - "traefik.http.routers.xdfnx.entrypoints=websecure" - - "traefik.http.routers.xdfnx.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.xdfnx-local.rule=Host(`xdfnx.workstation.internal`)" - - "traefik.http.routers.xdfnx-local.entrypoints=websecure" - - "traefik.http.routers.xdfnx-local.tls=true" - # Dev Router - - "traefik.http.routers.xdfnx-dev.rule=Host(`xdfnx.gigaforust.internal`)" - - "traefik.http.routers.xdfnx-dev.entrypoints=websecure" - - "traefik.http.routers.xdfnx-dev.tls=true" - networks: - - proxy -networks: - proxy: - external: true diff --git a/homepages/forust_files/assets/css/style.css b/homepages/forust_files/assets/css/style.css deleted file mode 100644 index bbaa64d..0000000 --- a/homepages/forust_files/assets/css/style.css +++ /dev/null @@ -1,167 +0,0 @@ -/* hidden in a plain sight? */ -:root { - --bg-color: #050505; - --text-color: #e0e0e0; - --accent: #ffffff; - --dim: #666666; - --font-mono: 'Courier New', Courier, monospace; -} - -* { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -body { - background-color: var(--bg-color); - color: var(--text-color); - font-family: var(--font-mono); - line-height: 1.6; - font-size: 16px; - padding: 2rem; -} - -a { - color: var(--text-color); - text-decoration: none; - border-bottom: 1px solid var(--dim); - transition: all 0.2s; -} - -a:hover { - background-color: var(--text-color); - color: var(--bg-color); - border-color: var(--text-color); -} - -.container { - max-width: 800px; - margin: 0 auto; -} - -/* TEXT */ -h1 { - font-size: 2.5rem; - text-transform: uppercase; - letter-spacing: -2px; - margin-bottom: 0.5rem; -} - -h2 { - font-size: 1.2rem; - margin-bottom: 1.5rem; - border-bottom: 1px solid var(--dim); - display: inline-block; - padding-right: 20px; -} - -.subtitle { - color: var(--dim); - margin-bottom: 2rem; -} - -hr { - border: 0; - border-top: 1px dashed var(--dim); - margin: 2rem 0; -} - -.comment { - color: var(--dim); - font-size: 0.9rem; - margin-left: 10px; -} - -/* SECTIONS */ -section { - margin-bottom: 3rem; -} - -/* LISTS */ -ul { - list-style: none; -} - -.link-list li { - margin-bottom: 0.8rem; - display: flex; - align-items: center; - gap: 15px; -} - -/* STACK GRID */ -.grid-2 { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 2rem; -} - -.skill-item { - display: flex; - justify-content: space-between; - margin-bottom: 0.5rem; -} - -.level { - font-weight: bold; -} - -.special .level { - color: var(--text-color); - text-shadow: 1px 0 0 red, -1px 0 0 blue; -} - -/* my dudes */ -.team-grid { - display: flex; - gap: 2rem; - flex-wrap: wrap; - margin-top: 1rem; -} - -.member { - text-align: center; - width: 100px; -} - -.avatar { - width: 80px; - height: 80px; - background-color: #222; - border: 2px solid var(--text-color); - margin: 0 auto 10px auto; - background-size: cover; -} - -/* if no avatar added: */ -.placeholder::before { - content: "?"; - display: flex; - align-items: center; - justify-content: center; - height: 100%; - font-size: 2rem; - color: var(--dim); -} - -/* REPOS */ -.repo-list li { - margin-bottom: 1rem; -} - -/* FOOTER */ -footer { - text-align: center; - color: var(--dim); - font-size: 0.8rem; -/* flag{why-are-you-here?} */ - margin-top: 4rem; -} -/* SMTH RESPONSIVE */ -@media (max-width: 600px) { - .grid-2 { - grid-template-columns: 1fr; - gap: 0; - } -} \ No newline at end of file diff --git a/homepages/forust_files/assets/images/love.png b/homepages/forust_files/assets/images/love.png deleted file mode 100644 index 3a70214..0000000 Binary files a/homepages/forust_files/assets/images/love.png and /dev/null differ diff --git a/homepages/forust_files/assets/images/team/anna.jpg b/homepages/forust_files/assets/images/team/anna.jpg deleted file mode 100644 index f7577e7..0000000 Binary files a/homepages/forust_files/assets/images/team/anna.jpg and /dev/null differ diff --git a/homepages/forust_files/assets/images/team/chernuha.jpg b/homepages/forust_files/assets/images/team/chernuha.jpg deleted file mode 100644 index b8805cb..0000000 Binary files a/homepages/forust_files/assets/images/team/chernuha.jpg and /dev/null differ diff --git a/homepages/forust_files/assets/images/team/hudan.jpg b/homepages/forust_files/assets/images/team/hudan.jpg deleted file mode 100644 index b6d082c..0000000 Binary files a/homepages/forust_files/assets/images/team/hudan.jpg and /dev/null differ diff --git a/homepages/forust_files/assets/images/team/mrforust.jpg b/homepages/forust_files/assets/images/team/mrforust.jpg deleted file mode 100644 index 3d9fb73..0000000 Binary files a/homepages/forust_files/assets/images/team/mrforust.jpg and /dev/null differ diff --git a/homepages/forust_files/assets/images/team/vv.jpg b/homepages/forust_files/assets/images/team/vv.jpg deleted file mode 100644 index 5939e75..0000000 Binary files a/homepages/forust_files/assets/images/team/vv.jpg and /dev/null differ diff --git a/homepages/forust_files/assets/images/team/xdfnx.jpg b/homepages/forust_files/assets/images/team/xdfnx.jpg deleted file mode 100644 index 366d0c8..0000000 Binary files a/homepages/forust_files/assets/images/team/xdfnx.jpg and /dev/null differ diff --git a/homepages/forust_files/index.html b/homepages/forust_files/index.html deleted file mode 100644 index 3a66a2a..0000000 --- a/homepages/forust_files/index.html +++ /dev/null @@ -1,215 +0,0 @@ - - - - - - MrForust // XRock - - - - - - -
-
-

Mr-Forust

-

> CTF Player / XRock_Team / Just Signal.

-
- -
- -
-
-

./contacts

- -
- -
-

./tools

- -
-
- -
-

./skills

-
-
-
- ArchLinux # btw - [#######...] -
-
Kubernetes [###.......]
-
Docker [####......]
-
- Docker Compose - [#####.....] -
-
Web Pentest [#####.....]
-
Burpsuite [#####.....]
-
Steganography [####......]
-
-
-
Cryptography [####......]
-
OSINT [####......]
-
Python [###.......]
-
HTML [###.......]
-
Bash [##........]
-
Golang [#.........]
-
-
-
- -
-

./xrock_team

-

# It's a select caste. Cybershamans. Cryptoanarchists. Shadows on the net..

- -
-
-
- MrForust -
- -
-
- Anna~ -
- -
-
- Chernuha -
- -
-
- p1ngvi -
- -
-
- xdfnx -
-
-
- -
-

./favorite_repos

- -
- -
-

root@xrock:~$ cat ./miku

-

miku?

-

root@xrock:~$ shutdown -h now

-

© XRock - Just Signal.

-
-
- - diff --git a/homepages/forust_files/miku.html b/homepages/forust_files/miku.html deleted file mode 100644 index 3cb24dd..0000000 --- a/homepages/forust_files/miku.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - miku? - - - -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠛⠋⠉⠉⠀⠀⠀⠀⠀⠀⠛⠘⠛⠛⠛⠿⠿⠿⠿⠿⣽⠙⣛⡛⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠋⠁⠀⠀⠀⠀⠀⢀⣀⣀⣀⣀⣀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠂⠈⠙⠛⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⢋⣰⢋⣴⠻⣿⡟⢹⡟⠋⠀⠀⠀⠀⢀⣠⣴⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣷⣶⣶⣦⣤⣄⣀⠀⠀⠉⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠋⡀⠏⣰⠟⠁⡰⢋⠀⠀⠀⠀⠀⢀⣠⣾⣿⣿⣿⡿⠟⠛⣛⣽⣯⣿⣽⣯⣿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣖⡄⠀⠀⠉⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⣠⠾⠋⠀⢊⡴⠋⠀⠀⠀⣀⣴⣿⣿⣿⡿⠋⢀⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣿⣿⣽⣿⣯⣿⣯⣿⣽⣯⣿⢿⣷⣷⣄⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠁⠀⢠⠞⠁⠀⠀⣰⠋⠀⠀⢀⣤⣾⣿⣿⣿⠿⣋⣴⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣝⣛⡫⠷⣿⡿⣯⣿⢿⣺⣄⠀⠈⠋⠙⣿⡻⣧⠹⣏⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠁⠀⢀⡴⠋⠀⠀⢠⡾⠁⠀⢀⣴⣿⣿⣿⣿⣿⣿⣾⠿⠛⣩⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣭⣛⣿⣿⣻⣿⣷⣤⠀⠀⠘⢷⠘⣧⠹⣆⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠋⠀⢀⡰⠍⠀⠀⢀⣴⡟⠁⢀⣶⣿⣿⣿⣿⣿⣿⠟⣋⠴⢂⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠀⠀⠀⠹⣦⠘⠀⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠿⠀⠀⢀⠀⠀⠀⢰⣾⠏⢀⣴⣾⣿⣿⡿⣻⡿⢛⣡⠞⣁⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢿⣻⣽⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⢠⡀⠁⠘⢧⠀⠀⠹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠠⠀⠀⠀⣿⠏⣴⣿⣿⣿⡿⣋⣾⠟⣱⠋⢂⣶⣿⣿⣿⣿⣿⣿⡿⣯⣿⣽⢿⡾⣟⣿⡽⣾⢽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⠱⠀⠀⠈⢧⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡋⠀⠠⢀⠏⠀⠀⠀⠀⠀⠀⢋⣾⣿⣿⣿⣿⣷⠟⡡⠞⢁⣼⣿⣿⣿⣿⣿⢟⣿⢿⣽⣟⡷⣯⣟⡿⣽⢯⡿⣽⢊⢷⣻⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⣟⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡂⠀⠀⠀⢫⠄⠀⠘⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⡈⠁⡌⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⠟⢁⢮⡱⣳⢾⣟⠋⣿⣿⡿⠃⣼⢯⡟⣾⡽⣳⠶⣭⠿⣽⣛⡞⣷⡈⢎⢷⡹⣯⣟⣿⣿⣿⣿⣿⣿⡿⣿⣿⣿⣿⣿⣿⣯⣿⣿⣿⣿⣿⣿⣟⣿⣿⣿⣿⣿⣿⣿⣷⡆⠀⠀⠀⠫⡄⠀⠈⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⠀⡔⠀⢈⠆⠀⠀⠀⣠⣾⣿⣿⣿⣿⢛⢋⡰⣋⢮⡱⢏⡾⠍⣰⣿⡿⠁⡼⣏⠷⡘⢼⣓⠧⡓⡜⣯⢳⣭⣛⢶⡁⢊⠶⣙⢧⡻⣷⣻⣯⣿⢿⣷⣿⢿⣯⣿⢿⣽⣾⢿⣳⣿⣽⡿⣿⣿⡿⣟⣾⣽⣻⢿⣿⣿⣿⣿⣧⠀⠀⠀⠄⠀⠀⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡧⠀⡘⠀⡀⠌⡐⠀⠀⣼⣿⣿⣿⣿⣿⠃⢀⠦⡓⢭⡲⢭⣓⠞⢠⣿⡟⢀⣼⢳⡍⠧⡑⠈⡝⠦⡑⡍⡖⣏⢶⡩⢞⠁⡄⠘⡱⢎⡵⢫⢷⣻⣽⣿⣻⣾⢿⣽⡾⣟⣯⡿⣟⣿⣳⣯⢿⡟⣿⣿⣟⡿⣞⡿⣯⣟⣿⢿⣿⣯⡀⠀⢀⠂⠀⠀⠟⡛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠠⠘⠰⢀⡘⠔⢀⣾⣿⣿⣿⣿⣿⠃⠀⡎⠵⣙⠶⣹⢣⡞⠁⣾⡿⢁⡾⣡⠗⡬⠑⢀⡆⡙⢆⠱⡘⠼⣘⠦⣝⢪⠀⣗⡀⠙⡼⣘⢏⡞⡵⣛⡾⣽⢯⣟⠾⡽⣭⢿⣹⢟⣷⡻⣎⢯⢳⡌⢿⣾⣟⣯⢳⡝⣺⠽⣻⣽⣻⡇⠀⠀⠀⠀⢰⡄⠸⡈⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠂⡍⡖⠀⠌⣠⣿⣿⣿⡿⣿⡿⠃⠀⡜⣌⠳⣬⢛⡴⢫⡜⢸⣿⠃⡼⣱⢃⠞⡠⢁⣾⠁⢹⠠⢃⢌⠣⡝⣚⢬⢣⠀⣿⣦⠀⠰⡩⠞⡼⣱⢫⡝⣧⢟⡾⢫⡕⣣⢏⢞⡹⢲⡝⡜⣊⠖⠲⠈⢿⣾⣻⠸⡜⣥⠫⣝⡷⣟⡧⠀⠀⠀⠀⠰⣃⠐⢠⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢀⠱⢠⠄⠈⣴⣿⣿⣿⠟⣼⡿⢡⠃⡜⡰⢊⢷⡸⣍⢞⡣⠄⣿⣏⠴⣣⠧⣉⠎⢁⣼⣯⠇⠰⣉⠌⡄⠳⣌⠧⢎⢧⠀⣿⡽⣷⠀⠈⠽⡰⢣⠳⣜⢬⢫⡜⣳⠸⣑⢎⠎⡕⢣⠜⡰⢡⠎⣡⢃⠘⣿⣽⡆⢹⠰⣍⢲⡹⣟⣿⠀⠀⠀⢀⡃⠲⠀⢠⠂⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⠄⡂⡜⢀⣾⣿⣿⣿⢋⣾⣟⢠⠃⡸⢰⡑⢯⡒⢷⣘⢮⡑⢸⣯⢏⡞⣡⠞⡤⠁⣾⠋⣾⠡⠐⡥⢂⠈⡱⣈⢮⡙⢦⠐⣏⠻⣿⣷⣤⠈⠱⣋⠳⣜⢪⡓⡼⣡⠳⣉⢎⢎⡙⠦⡙⡔⠣⢎⠔⡈⠆⢹⣯⣧⠈⡳⢌⠦⣱⣛⣮⠀⠀⠀⠆⡌⢡⠃⠄⢂⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠀⠆⡑⢠⣾⣿⣿⡻⢃⣾⣿⠋⠆⠰⣁⠦⣙⠦⣏⠳⡜⢦⠁⣾⣏⠞⡴⢱⢊⠀⣼⢏⣾⣿⡅⠈⠴⠈⠐⢠⡑⢦⡙⣧⠈⣟⣦⠹⣿⣿⣄⠀⠉⠷⣌⠳⡜⣥⢃⠯⣜⣊⢦⡙⢦⠱⣌⠳⡌⢎⠱⡈⠄⢻⣿⡄⡹⢌⠲⡡⢞⡵⠀⠀⠀⡇⠄⡃⠥⢈⠄⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⡈⠂⣴⣟⠫⡍⠰⢁⣾⣿⠃⡜⢀⠣⣐⠣⣍⠞⣌⢳⢩⢚⠀⡿⣌⠏⡼⢡⠂⠰⣿⡾⢿⣿⣇⠀⠓⡄⡄⠂⡜⢢⡝⡶⠐⣸⣿⣷⡜⢿⣿⣷⡠⠘⢌⡳⢍⠶⣩⠞⡴⣊⢦⡙⢆⡳⢌⠣⡜⣌⠲⡁⠎⠈⣿⣷⡱⢋⡒⠥⣫⠖⠀⠠⢌⡓⠌⡰⢁⠎⡐⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠀⢀⡾⢋⡌⢣⠌⢁⣿⡿⠁⡜⠀⢢⠑⡄⠳⣈⠞⡌⢎⠦⡉⢸⡗⡭⢊⡵⠁⣰⣶⣤⣤⣌⡙⠉⠷⠈⠄⢣⠐⣈⠳⣜⣳⠀⣿⣿⣿⣿⣦⡙⣿⣷⡀⠈⠼⣩⠞⣥⢛⡴⣍⠶⣉⠦⣑⠮⣑⢮⣐⢣⠱⡈⠅⠸⣷⡏⡵⢘⡐⢧⣻⠀⡡⢂⡝⢠⠐⡩⠐⢠⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠂⡰⢋⠔⠣⠘⢠⠃⣾⡟⠄⡐⢀⡘⠄⠣⢌⠱⠠⢎⡐⢣⡘⠁⣾⢸⡱⢋⠄⣘⣿⣿⣿⣿⣿⣿⣿⡦⠀⠁⢸⡄⢠⢋⢾⣱⠠⣿⣿⣿⣿⣿⣿⣌⢻⣿⣤⠈⢱⡚⢦⡛⣶⡩⢞⢥⠓⣜⢢⡍⠶⣨⢆⢣⡑⠌⡀⢿⣷⢡⠣⡘⠦⣏⠔⡁⠢⣜⠠⠂⢥⠃⠄⠂⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠅⢀⠰⠌⡌⢁⠊⠄⣸⡟⠀⡐⠀⡄⢢⢉⠒⡌⢂⠇⢢⠘⢤⠘⢰⠋⣦⡙⠂⣠⣾⣿⣿⣿⣿⣿⣿⣿⣿⡄⠘⢸⣧⠀⡚⣼⢳⠀⣿⣿⣿⣿⣿⣿⣿⡷⢈⢁⣤⠀⠍⡧⡝⢦⠻⣍⢮⡙⣤⠣⡜⢳⡰⢎⠲⣌⠒⡀⠘⣿⣎⠱⣀⠛⡼⢐⡈⠔⣌⠂⡉⠔⡨⢈⠄⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠃⠈⠐⠈⠆⢠⠂⠌⡐⢰⡿⠂⡐⠀⠰⡈⠆⡌⠒⡌⢌⡘⠤⡉⢆⠁⣼⣙⢦⠙⣠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡀⠈⣿⡇⠘⡴⣻⠀⣹⣿⣿⣿⣿⣿⣿⣶⣾⣷⣿⡖⠀⠱⣩⠏⣝⢮⠳⡜⢤⠓⣍⠲⣱⢪⡑⢆⠣⡔⠁⢹⣎⠧⢠⠙⣞⠀⢎⠐⣌⠂⣁⠚⠄⢢⠀⢽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⢀⣠⣶⠁⠌⠂⡌⢀⠂⢠⡿⠁⠀⠀⠀⣎⠡⡘⢠⠃⢌⠢⠄⡃⠜⡠⢀⡿⣜⠋⣰⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡀⢿⣿⡆⢱⢻⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣆⣄⠑⢮⡱⢎⠿⣜⠦⡙⢤⠛⣤⠓⡼⣈⠇⣆⠡⠀⢻⡜⡠⢙⡼⠈⠆⡌⣰⠡⠠⠘⡌⠠⠂⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⢛⣡⣴⣿⣿⡏⠐⢨⠀⠆⠠⠁⣼⠃⠀⠀⠀⡜⢄⠢⠑⡄⢊⠄⡃⠜⡠⢃⠔⠰⣯⠍⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣾⣿⣿⣀⠹⡇⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡦⡀⠹⢎⡝⣮⢧⠙⣢⠙⣤⠛⣔⢣⡚⢤⢃⠅⡈⠞⠤⡁⢞⡥⠘⡄⢲⠀⡅⢣⠐⡡⠁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⡈⠄⡘⢠⠁⢸⠇⠀⠀⠀⣼⠱⡈⠄⡡⠐⠌⢂⡘⠤⠑⠢⠀⣼⠇⠀⠀⠉⠙⠻⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣄⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣧⣀⠉⢞⡬⣛⣇⠰⣉⠦⡹⢌⡖⡍⣆⠫⡔⡀⢈⠱⢈⠺⣄⠱⣌⠲⢁⠰⢀⠇⠄⡡⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠠⠐⢡⡘⠤⢀⡏⠀⠀⠀⣸⡱⢂⡁⠂⠄⡑⠈⡄⠰⡈⢡⠁⠐⡟⢀⣶⣤⣄⣀⠀⠀⠈⠙⠿⣿⣿⡙⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⢀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⡈⠲⡝⡾⣇⠈⡖⣩⠲⣌⠳⣌⢳⣜⡰⠀⠄⠊⣜⣣⢘⡧⡘⠄⢂⠡⢊⡐⠐⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠠⣉⠐⢆⠂⡼⠀⠀⠀⣼⢣⢇⠃⠠⢈⠐⠠⠁⢄⠃⠤⠁⠌⡀⢡⣿⣿⣿⣿⣿⣿⣷⣦⣄⠀⠀⠙⠻⢷⣬⣙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⣿⣿⣿⣿⠿⠟⠛⠛⠁⠀⠘⠵⣻⡄⠸⣄⠳⣌⠳⣌⠧⣿⢰⢃⠘⡀⠰⣧⢸⠲⡍⠂⡌⢐⡃⢄⡉⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢂⠤⡙⡄⢰⠁⠀⠀⢬⡷⣩⠎⡄⠁⠄⡈⠄⡁⢂⠌⠄⣉⠰⠀⢠⣿⡟⠉⠉⠉⠉⠀⠁⠈⠁⠀⠀⠀⠀⠈⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⢡⣶⠾⠟⠋⠉⠀⠀⠀⢀⣀⣀⣠⣤⣤⡀⠓⠋⡀⢨⠓⣌⠳⣌⢳⡌⢠⡋⠄⠀⠐⣽⢸⠣⡅⢃⠐⢂⡅⢂⠰⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⡌⢂⡜⠀⠃⠀⠀⡜⢲⡝⠂⣁⡀⠈⡀⠄⡐⠐⡠⢈⠂⢄⠒⠀⠈⣿⣷⣀⣠⣤⣤⣶⣶⣶⣶⣶⣶⣶⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⠁⠀⠀⠀⠀⠀⠶⠾⣿⣿⣿⣿⣿⣿⣿⣿⠄⠀⠱⠀⡹⢄⣛⠈⣁⡘⢂⣇⠈⠆⠀⢘⣯⠃⡜⣀⠊⢄⡒⠈⡔⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⡐⢌⠒⠆⡈⠀⢠⠘⣌⢻⠌⣸⣿⠇⠀⢀⠐⠠⢁⡐⠠⠌⠂⢌⠀⠀⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣦⣤⣄⣀⠀⠀⠀⠉⠙⠻⢿⣿⡏⠀⠐⡀⠅⢀⠣⠎⠐⣿⣧⡀⢺⡀⠌⢡⠘⣮⠑⡌⠤⠈⡔⢨⢁⠰⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⡰⢈⢜⡂⠀⢀⡎⠴⡨⡇⢰⣿⣿⡁⠀⠀⠌⡐⠠⠀⡡⠘⡈⠤⠀⠀⠀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⣄⣀⣠⣿⡟⠀⡈⠄⡐⠌⡄⢀⠩⠀⢿⣿⡄⠠⡇⠈⠄⠢⣝⠢⠜⡠⢃⡐⢃⠄⢊⠁⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⠠⡐⠡⡸⠀⢠⢃⡜⢰⠱⠇⣼⣿⣿⠄⠀⠈⠄⠠⠁⠠⢂⠡⡐⠐⡀⢠⣶⡎⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠠⠀⢂⠄⠃⠀⣬⣂⠀⢼⣿⣧⠀⢳⠈⠌⢡⢘⡇⡱⠄⡃⠌⡆⡘⠤⠁⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⡰⢁⠣⡅⡘⢄⠺⡄⢣⠛⢰⣿⣿⣿⠀⠀⠌⠠⠁⣴⣆⠁⢂⠔⠡⠀⣿⣿⣿⡸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⠀⡅⠀⢠⣄⡀⢈⣿⣿⣄⠈⣿⣿⣄⠈⠧⡘⢀⠎⡖⢱⡈⠔⡡⢚⡀⢆⡁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡯⠀⡔⢡⢊⡵⢈⠜⣸⠐⣣⠁⣾⣿⣿⠿⠀⠀⠌⢀⣾⣿⡏⠀⠌⡠⢁⠀⠻⣿⣿⣷⡜⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠿⠿⠿⠿⠿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢋⣄⠀⡘⠀⠠⠘⢻⡻⣿⣿⣿⣿⠆⣿⣿⣧⠀⢃⠆⡡⢂⢝⠢⠜⡠⢁⠧⡈⢄⠒⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠰⡈⢆⣩⠆⣅⠚⣤⠙⡄⠸⣽⣿⣿⠁⠀⠀⣠⣿⣿⡿⠰⠀⢂⡁⢂⠀⣆⡈⠻⣿⣿⣦⡹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⣻⢖⡶⣹⢖⡶⣤⠦⠄⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⣡⣿⡟⠠⠁⣀⠃⢀⢸⣿⣿⣿⣿⣿⡇⢿⣿⣿⡄⠈⢆⠡⢂⠌⣇⠓⠤⡉⢖⠡⠊⡌⢈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠅⢢⠑⡌⡲⢌⡄⣋⠴⣉⠀⣩⣿⣿⡏⠀⢀⣴⣿⣿⣿⢁⣿⡅⢀⠒⠠⢀⣿⣷⣆⡈⠛⠿⠛⠛⠻⠿⠿⠿⢿⣿⣿⣿⣿⣷⡈⠻⣜⣧⣻⡜⡧⠋⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⣋⣴⣿⡿⠀⠀⠐⠤⠁⣾⣇⢹⣿⡿⢿⣿⣿⢸⣿⣿⣯⠀⢨⠂⡅⢊⡼⡘⠤⡑⢎⠰⢡⠘⡀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢢⡉⠴⣙⠤⡘⢤⡓⠌⢠⣾⣿⣿⠀⣠⣾⣿⣿⣿⠇⣾⣿⣯⠀⠌⡡⠠⣿⣿⣿⣿⣦⣄⣀⠀⠂⠀⠀⠀⠀⠀⠀⠀⠉⠛⢿⣷⣬⣌⣁⣭⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⣋⣥⣾⣿⣿⣿⢃⡆⠀⢩⢀⣾⣿⣿⡌⢿⣿⣎⣿⣿⡄⣿⣿⣿⡆⢀⠳⣸⠠⡹⢄⠣⡘⢼⢀⠣⡘⡀⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡷⢀⠣⡘⢔⡫⢰⡉⢦⠍⠂⣸⣿⣿⡿⠁⣾⡟⣽⣿⡟⣰⣿⣿⣿⡀⢂⠡⢐⣿⣿⣿⣿⣿⣿⣿⣷⣤⠀⠀⠀⠋⠀⠀⠂⠀⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠋⣁⣀⣀⣉⣙⣛⣻⣿⣧⣞⠀⠨⢀⣾⣿⣿⣿⣿⡘⣿⣿⡜⣿⣷⠸⣿⣿⣷⡀⠱⢸⠅⣟⠄⢣⠑⢮⠠⢃⠴⠁⢺⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⢈⣒⣉⠆⠛⣀⣈⣐⢃⣴⣿⣿⣿⠅⢘⣿⣽⢿⡿⢠⣿⣿⣿⣿⣧⠀⠆⢈⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣶⢢⣤⡠⢤⣤⣤⡴⢿⣿⣿⣿⣿⣿⣿⡿⠿⣛⣭⠇⣠⡴⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⠇⠀⢠⣾⣿⣿⣿⣿⣿⣧⢹⣿⣷⣽⣿⡆⢿⣿⣿⣷⡄⢚⣉⣈⠋⠠⣍⣺⠀⠉⠦⣉⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠠⠚⣃⣋⣰⣿⣿⣿⣟⣡⣿⣿⣿⣿⣿⣿⣿⡤⣶⢀⣾⣿⣿⣿⣿⣿⣂⠨⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣬⠛⡓⢦⡙⠻⣿⣷⣬⣍⣛⣭⣵⣶⣾⡿⢋⣡⠞⣉⢰⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⡆⢩⣦⣛⣿⣿⣾⣿⣿⣿⣧⡙⢿⣿⣯⡑⣈⣑⣋⠳⢢⡀⠈⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣿⠃⡄⢰⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡔⡌⢿⣽⣿⣿⣿⣿⣿⣿⠄⠠⣿⣿⣿⣿⣿⣿⣿⣿⡿⢃⡿⣷⣶⠉⢢⡽⣷⣬⣙⠻⢿⣿⣿⠟⢋⣡⣴⢟⣡⡾⣽⣦⢿⣝⡻⣿⣿⣿⣿⣿⣿⠛⢀⣾⣿⣿⣿⣿⣿⣿⣿⡿⠻⠉⣄⣾⣿⣿⣿⣿⣿⣿⣿⣿⣼⣿⣿⣷⡘⣿⣿⡗⠀⢷⡀⣿⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⣇⢸⡁⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣮⢽⠃⠙⢿⣿⣿⣿⣷⠀⢿⣿⣿⣿⣿⡿⠛⠁⣶⢏⣼⣿⣿⣷⣦⣿⣿⣿⣿⡗⢢⡙⣡⢾⣿⣿⣿⣿⣿⣿⣿⣿⣷⣟⣿⡈⠻⠿⣿⣿⣿⣷⣾⣿⣿⣿⣿⣿⡿⠟⠡⡄⣊⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡗⠠⣸⠇⢹⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⣿⠇⠸⣷⡀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣀⠸⣦⣈⠻⢿⣿⣧⣿⣿⣿⣿⣿⠀⠘⡇⢻⣼⣿⣿⣿⣿⣿⣿⣿⣿⠿⢿⣦⠙⢹⠏⣿⣿⣿⣿⣿⣿⠟⢿⣿⣿⣿⠀⠳⣀⢻⣿⣿⣿⣿⣿⣿⣿⠿⢋⣤⡞⢠⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠀⣰⡿⢀⢸⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⣿⡏⠀⠀⠙⢷⡄⠹⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣟⠀⠈⠙⢷⣆⡙⢿⣿⣿⣿⣿⣿⠁⢠⣆⠸⣿⣿⣿⣿⣿⣿⣿⣿⣿⠗⣀⣄⣤⣤⣌⡐⢼⣿⣿⡿⠁⢰⡦⢙⠿⡿⢀⣃⠈⣿⣿⣿⣿⣿⣿⠛⣡⡶⠟⠁⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⠋⢀⣼⠟⠀⠠⠈⣿⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⠈⠛⢷⣄⡙⠻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡗⠀⠀⠀⠀⠙⢷⣆⠹⣿⣿⡿⠃⠄⣸⡆⠀⢻⣿⣿⣿⣿⣿⣿⡟⠁⢰⡿⣯⢿⡽⣯⢿⡀⢻⣿⠁⢠⣧⣶⡏⣰⠁⢸⣇⠀⠈⣿⣿⣿⠏⢀⣶⠟⠁⠀⠀⠀⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠉⣀⣴⠿⠉⠀⠀⠀⠁⠸⣿⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠉⠻⢶⣄⡙⠛⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠙⣷⡌⠻⣧⠐⣰⣿⣷⠀⠈⢿⣿⣿⣿⡿⠋⡁⠔⢺⠿⣽⢫⡽⣯⣟⠲⠄⡙⠿⣦⣍⡛⣰⠏⠀⣿⣿⡄⠀⢨⡿⠃⣴⡟⠁⠀⠀⠀⠀⠀⠀⠙⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠏⢁⣁⣴⠞⠋⠁⠀⠀⠀⠀⠀⠀⠀⢹⣿⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠷⣦⣄⡉⠛⠻⢿⣿⣿⣿⣿⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣄⠁⣰⣾⣿⣿⣧⠀⠈⠉⢉⣀⣴⡾⣷⡀⠈⢻⣓⣯⣙⡳⠃⣀⣶⣌⣐⠠⢠⡉⢀⠀⣶⡿⣿⣿⣄⠘⠁⣼⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⢯⢿⣿⡿⡿⠻⠛⢉⣀⣤⡾⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿⣿⣿⣿⣿ -⣿⣿⣿⣿⠏⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠻⠶⣤⡈⠉⠙⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⢁⠂⠀⣿⠀⠙⣿⣿⣽⣿⣿⣿⣷⣾⡿⣷⣻⢷⣻⠀⢠⣟⡾⣽⢣⠀⣾⣿⣿⣻⣷⣶⣶⣿⣯⣿⣷⢿⣿⡟⠀⢸⠃⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⢀⣠⡴⠚⠋⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿⣿⣿⣿⣿ -⣿⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⠙⠲⢍⡖⣆⠦⠤⠤⠤⢠⣄⡌⣤⢤⣤⣙⠖⢠⣿⣿⣻⣾⣿⣿⣿⣷⣿⣳⣽⣻⣿⠇⢸⣞⡽⣽⢫⠐⠿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣾⣿⡃⠐⣮⣤⢤⠤⣤⠤⣄⠤⠤⢤⢒⡤⢎⡜⠣⠛⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣿⣿⣿⣿ -⣿⣿⡟⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠉⠓⠉⠓⠾⠜⠻⠞⠙⠁⣰⣾⣿⣿⣽⣿⣿⣿⣿⣿⣷⣿⣞⣿⣿⠀⢸⣎⣟⢮⣏⠀⢺⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣦⣀⠙⠋⠛⠲⠛⠜⠉⠛⠈⠁⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠹⣿⣿⣿ -⣿⡿⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢺⠀⣿⣿⣿⣿⣷⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣿⠀⢸⡮⣝⡞⡼⠀⣸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡄⢸⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣿⣿ -⣿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⠀⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠀⡿⣼⡹⣜⡱⠂⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡅⠈⢸⣷⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣿ -⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⡿⢃⠀⡿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⢠⢳⢧⡝⢦⡹⡁⢹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⠄⢈⠻⣿⣧⡄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⢿ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡰⢻⣿⣿⠇⠀⢀⣾⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡏⢰⣋⢶⡹⢎⡵⣁⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⣝⢿⣿⣿⡄⠈⠰⣿⣿⣿⣦⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣰⣿⣿⡘⣿⠅⠠⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠸⡜⡶⣩⢳⢎⡵⢸⣿⣿⣿⡏⠹⠿⠿⠟⠻⠛⡟⠻⠟⠀⣿⣿⣷⡀⠠⠙⣿⡏⣸⣿⣶⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⡇⠝⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠂⢼⡹⣱⢏⡾⣘⠖⠘⣿⣿⣿⣿⣦⣉⣉⣭⣭⣤⣭⣤⣈⣸⣿⣿⣿⣧⠀⠘⢿⢀⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣾⣿⣿⣿⣿⣿⣿⠀⠊⢀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠀⢧⡛⡽⢪⠵⣩⠞⠀⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠹⣿⣿⣿⣿⣆⠈⠀⢸⣿⣿⣿⣿⣿⣿⣦⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣾⣿⣿⣿⣿⣿⣿⣿⡿⠂⠀⢸⣿⣿⣿⣟⡳⢯⣛⠿⣻⢿⡿⣿⠻⣟⣿⣿⣿⣿⠀⣎⠵⣩⢳⡙⢦⢫⠄⢻⣿⣿⣿⣿⢿⣻⡟⣿⢫⠟⡵⢪⢇⣹⣿⣿⣿⣿⡇⢈⠹⣿⣿⣿⣿⣿⣿⣿⣷⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ -⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⣿⣿⣿⣿⣿⣿⣿⣿⣿⡆⠀⣠⣾⢿⣿⡗⢮⡑⢣⠘⡬⢑⠮⡑⢎⡱⢎⡞⡽⢹⡃⠀⡎⢧⠣⢧⣝⢣⢏⠆⡸⣏⠿⣱⢎⢳⡘⡜⢢⢋⡜⢌⡑⢊⠴⢻⣿⣿⣿⡇⠈⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ - - - \ No newline at end of file diff --git a/homepages/k8s/homepages.yaml b/homepages/k8s/homepages.yaml deleted file mode 100644 index ae6a795..0000000 --- a/homepages/k8s/homepages.yaml +++ /dev/null @@ -1,81 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: forust-homepage-service - namespace: homepages -spec: - selector: - app: forust-homepage - ports: - - port: 80 - targetPort: 80 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: forust-homepage-deployment - namespace: homepages -spec: - replicas: 1 - selector: - matchLabels: - app: forust-homepage - template: - metadata: - labels: - app: forust-homepage - spec: - containers: - - name: forust-homepage - image: gcr.forust.xyz/forust/forust-homepage:latest - imagePullPolicy: Always - ports: - - containerPort: 80 - resources: - requests: - memory: "10Mi" - cpu: "20m" - limits: - memory: "100Mi" - cpu: "50m" ---- -apiVersion: v1 -kind: Service -metadata: - name: xdfnx-homepage-service - namespace: homepages -spec: - selector: - app: xdfnx-homepage - ports: - - port: 80 - targetPort: 80 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: xdfnx-homepage-deployment - namespace: homepages -spec: - replicas: 1 - selector: - matchLabels: - app: xdfnx-homepage - template: - metadata: - labels: - app: xdfnx-homepage - spec: - containers: - - name: xdfnx-homepage - image: gcr.forust.xyz/forust/xdfnx-homepage:latest - imagePullPolicy: Always - ports: - - containerPort: 80 - resources: - requests: - memory: "10Mi" - cpu: "20m" - limits: - memory: "100Mi" - cpu: "50m" diff --git a/homepages/k8s/ingress.yaml b/homepages/k8s/ingress.yaml deleted file mode 100644 index ab6c15a..0000000 --- a/homepages/k8s/ingress.yaml +++ /dev/null @@ -1,69 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: forust-homepage-prod - namespace: homepages -spec: - entryPoints: - - websecure - routes: - - match: Host(`forust.xyz`) || Host(`www.forust.xyz`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - priority: 10 - services: - - name: forust-homepage-service - port: 80 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: forust-homepage-local - namespace: homepages -spec: - entryPoints: - - websecure - routes: - - match: Host(`landing.workstation.internal`) || Host(`landing.gigaforust.internal`) - kind: Rule - priority: 10 - services: - - name: forust-homepage-service - port: 80 ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: xdfnx-homepage-prod - namespace: homepages -spec: - entryPoints: - - websecure - routes: - - match: Host(`xdfnx.cfd`) || Host(`www.xdfnx.cfd`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: xdfnx-homepage-service - port: 80 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: xdfnx-homepage-local - namespace: homepages -spec: - entryPoints: - - websecure - routes: - - match: Host(`xdfnx.workstation.internal`) || Host(`xdfnx.gigaforust.internal`) - kind: Rule - services: - - name: xdfnx-homepage-service - port: 80 diff --git a/homepages/k8s/namespace.yaml b/homepages/k8s/namespace.yaml deleted file mode 100644 index 66473ee..0000000 --- a/homepages/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: homepages diff --git a/homepages/xdfnx_files/favicon.ico b/homepages/xdfnx_files/favicon.ico deleted file mode 100644 index 93ed59e..0000000 Binary files a/homepages/xdfnx_files/favicon.ico and /dev/null differ diff --git a/homepages/xdfnx_files/index.html b/homepages/xdfnx_files/index.html deleted file mode 100644 index 8f623bd..0000000 --- a/homepages/xdfnx_files/index.html +++ /dev/null @@ -1,1142 +0,0 @@ - - - - - - - xdfnx - - - - - - - -
-
-
-
- -
-
- - - -
- -
-
-
Open for Complex Projects -
-

xdfnx

-

- Senior Full Stack Engineer with a focus on system performance. - I build scalable web applications and optimize core infrastructure using Rust & - C++. -

- - - -
-
- 6+ - Years Experience -
-
- 20+ - Production Deploys -
-
- 100% - Performance Focused -
-
-
- -
-

Professional Experience

-
- -
-

Senior Full Stack Engineer

- TechFlow Systems - 2025 - Present -

- Leading the backend migration to microservices. Implemented a high-throughput event processing pipeline - using Python and Rust, reducing latency by 40%. Oversaw the React frontend - architecture for the main dashboard. -

-
- -
-

Software Engineer

- Creative Solutions Ltd. - 2020 - 2023 -

- Developed full-stack web applications using Node.js and TypeScript. Integrated native C++ - modules for image processing tasks, speeding up user workflows by 3x. -

-
- -
-
- -
-

Technical Arsenal

-
- -
-
Modern Frontend
-
- Frontend -
-

- Building responsive, type-safe interfaces with a focus on UX and accessibility. -

-
- -
-
Backend & Infra
-
- Backend -
-

- Scalable architectures using Node & Python. Dockerized deployments and cloud infrastructure. -

-
- -
-
Systems & Low Level
-
- Systems -
-

- When JS isn't fast enough. Writing memory-safe, high-performance modules for critical paths. -

-
- -
-
- -
-

Selected Work

-
- -
-
-

Fintech Analytics Engine

-
-

- A real-time dashboard for financial data visualization. The backend aggregates streams from multiple sources - using a custom Rust service for zero-cost abstraction performance. -

-
- Next.js - Rust - WebSockets -
-
- -
-
-

AI Data Pipeline

-
-

- Automated ETL pipeline processing terabytes of data. Written in Python for flexibility with - C++ bindings for heavy computational steps. -

-
- Python - C++ - Docker -
-
- -
-
-

Distributed Task Queue

-
-

- Fault-tolerant job scheduler inspired by Celery but optimized for low-memory environments. -

-
- Node.js - Redis - System Design -
-
- -
-
- -
-

The Team

-
- - -
-
C
-

chernuha

- chernuha.space -
- - -
-
M
-

MrForust

- forust.xyz -
- - -
-
H
-

hudan

- hudan.xyz -
- -
-
- -
-

Ready to scale?

-

Let's discuss how I can help your team build faster and better.

- contact@xdfnx.cfd -

© 2025 xdfnx. Engineering - Excellence. -

-
- -
- - - - - \ No newline at end of file diff --git a/images/icons.py b/images/icons.py new file mode 100644 index 0000000..0c9737d --- /dev/null +++ b/images/icons.py @@ -0,0 +1,110 @@ +import random +import re +from io import BytesIO + +import requests +from bs4 import BeautifulSoup as BS +from pyrogram import Client, filters +from pyrogram.errors import RPCError +from pyrogram.types import InputMediaPhoto, Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('icon', prefix) & filters.me) +async def search_icon(_, message: Message): + if len(message.command) != 2: + return await message.edit_text('Please provide some text to search icons from Flaticon.com.') + query = message.text.split(maxsplit=1)[1] + + await message.edit_text('Searching for icons...') + search_query = query.replace(' ', '%20') + url = f'https://www.flaticon.com/search?word={search_query}' + + try: + html_content = requests.get(url, timeout=10).text + soup = BS(html_content, 'html.parser') + results = soup.find_all( + 'img', + src=re.compile(r'https://cdn-icons-png.flaticon.com/128/[0-9]+/[0-9]+.png'), + ) + + if not results: + return await message.edit('No results found.') + + random.shuffle(results) + icons = [] + for i in range(5): + icons.append(results[i]['src'].replace('128', '512')) + + for icon in icons: + await message.reply_document(icon) + + return await message.delete() + except Exception as e: + await message.edit(f'An error occurred: {e}') + print(f'Error: {e}') + + +@Client.on_message(filters.command('freepik', prefix) & filters.me) +async def freepik_search(client: Client, message: Message): + parts = message.text.split(' ', 1) + if len(parts) < 2: + await message.edit_text('Please provide a search query!') + return + + query = parts[1] + limit = 5 + if ' ; ' in query: + match, limit_str = query.split(' ; ', 1) + try: + limit = int(limit_str) + except ValueError: + await message.edit_text('Invalid limit! Using the default value of 5.') + else: + match = query + + match = match.replace(' ', '%20') + await message.edit_text('Searching Freepik...') + + try: + url = f'https://www.freepik.com/api/regular/search?locale=en&term={match}' + json_content = requests.get(url, timeout=10).json() + results = [] + for i in json_content['items']: + results.append(i['preview']['url']) + + if results is None: + return await message.edit_text('No results found.') + + random.shuffle(results) + img_urls = results[:limit] + + media_group = [] + for img_url in img_urls: + icon = requests.get(img_url, timeout=10) + if icon.status_code == 200: + media_group.append(InputMediaPhoto(media=BytesIO(icon.content))) + + if not media_group: + await message.edit_text('No images could be downloaded.') + return + + try: + await client.send_media_group(chat_id=message.chat.id, media=media_group) + except RPCError: + await message.edit_text('Failed to send some images. Retrying individually...') + for media in media_group: + try: + await message.reply_photo(photo=media.media) + except Exception as e: + await message.edit_text(f'Error sending image: {e}') + + except Exception as e: + await message.edit_text(f'Failed to fetch data: {e}') + print(f'Error: {e}') + + +modules_help['icons'] = { + 'icon [query]': 'Search for icons on Flaticon.', + 'freepik [query] [limit]': 'Search for images on Freepik. Limit is optional and defaults to 5.', +} diff --git a/images/imgur.py b/images/imgur.py new file mode 100644 index 0000000..643a78e --- /dev/null +++ b/images/imgur.py @@ -0,0 +1,47 @@ +import base64 + +import requests +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['imgur'], prefix) & filters.me) +async def imgur(_, message: Message): + # Check if a reply exists + msg = await message.edit_text('🎉 Please wait. trying to upload...') + if message.reply_to_message and message.reply_to_message.photo: + # Download the photo + photo_path = await message.reply_to_message.download() + # Read the photo file and encode as base64 + with open(photo_path, 'rb') as file: + data = file.read() + base64_data = base64.b64encode(data) + # Set API endpoint and headers for image upload + url = 'https://api.imgur.com/3/image' + headers = {'Authorization': 'Client-ID a10ad04550b0648'} + # Upload image to Imgur and get URL + response = requests.post(url, headers=headers, data={'image': base64_data}, timeout=10) + result = response.json() + await msg.edit_text(result['data']['link']) + elif message.reply_to_message and message.reply_to_message.animation: + # Download the animation (GIF) + animation_path = await message.reply_to_message.download() + # Read the animation file and encode as base64 + with open(animation_path, 'rb') as file: + data = file.read() + base64_data = base64.b64encode(data) + # Set API endpoint and headers for animation upload + url = 'https://api.imgur.com/3/image' + headers = {'Authorization': 'Client-ID a10ad04550b0648'} + # Upload animation to Imgur and get URL + response = requests.post(url, headers=headers, data={'image': base64_data}, timeout=10) + result = response.json() + await msg.edit_text(result['data']['link']) + else: + await msg.edit_text('Please reply to a photo or animation (GIF) to upload to Imgur.') + + +modules_help['imgur'] = { + 'imgur [img]*': 'upload a photo or animation (GIF) to imgur', +} diff --git a/images/ncode.py b/images/ncode.py new file mode 100644 index 0000000..9b80f06 --- /dev/null +++ b/images/ncode.py @@ -0,0 +1,47 @@ +import os + +import pygments +from pygments.formatters import ImageFormatter +from pygments.lexers import Python3Lexer +from pyrogram import Client, filters +from pyrogram.errors import MessageNotModified +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('ncode', prefix) & filters.me) +async def coder_print(client, message: Message): + if message.reply_to_message: + reply_message = message.reply_to_message + if reply_message.media: + download_path = await client.download_media(reply_message) + with open(download_path) as file: + code = file.read() + if os.path.exists(download_path): + os.remove(download_path) + pygments.highlight( + f'{code}', + Python3Lexer(), + ImageFormatter(font_name='DejaVu Sans Mono', line_numbers=True), + 'result.png', + ) + try: + sent_message = await message.edit_text('Pasting this code on my page...') + await client.send_document( + chat_id=message.chat.id, + document='result.png', + caption='Code highlighted by Pygments', + reply_to_message_id=message.id, + ) + except MessageNotModified: + pass + await sent_message.delete() + if os.path.exists('result.png'): + os.remove('result.png') + else: + return await message.reply_text('Please reply to a text or a file.') + else: + return await message.reply_text('Please reply to a text or a file.') + + +modules_help['ncode'] = {'ncode': 'Highlight the code using Pygments and send it as an image.'} diff --git a/images/pinterest.py b/images/pinterest.py new file mode 100644 index 0000000..355f4b2 --- /dev/null +++ b/images/pinterest.py @@ -0,0 +1,91 @@ +import asyncio +from io import BytesIO + +import requests +from PIL import Image +from pyrogram import Client, enums, filters +from pyrogram.types import InputMediaPhoto, Message +from utils.misc import modules_help, prefix + +# Pinterest API URL +API_URL = 'https://bk9.fun/pinterest/search?q=' + + +def resize_image(image_bytes): + try: + with Image.open(image_bytes) as img: + max_size = (1280, 1280) + if img.size > max_size: + img.thumbnail(max_size) + output = BytesIO() + img.save(output, format='JPEG') + output.seek(0) + return output + image_bytes.seek(0) # Reset pointer if not resized + return image_bytes + except Exception as e: + print(f'Error resizing image: {e}') + return image_bytes + + +async def download_image(url): + try: + response = requests.get(url, timeout=10) + if response.status_code == 200: + img_bytes = BytesIO(response.content) + return resize_image(img_bytes) + except Exception as e: + print(f'Error downloading image: {e}') + return None + + +@Client.on_message(filters.command('pinterest', prefix) & filters.me) +async def pinterest_search(client: Client, message: Message): + if len(message.command) < 2: + await message.edit('Usage: `pinterest [number] `', parse_mode=enums.ParseMode.MARKDOWN) + return + + num_pics = int(message.command[1]) if message.command[1].isdigit() else 10 + query = ' '.join(message.command[2:]) + + # Update status + status_message = await message.edit('Searching for images...', parse_mode=enums.ParseMode.MARKDOWN) + + url = f'{API_URL}{query}' + response = requests.get(url, timeout=10) + + if response.status_code == 200: + data = response.json() + if data.get('status'): + urls = [item['images_url'] for item in data.get('BK9', [])[:num_pics]] + images = [download_image(img_url) for img_url in urls] + + # Download images + downloaded_images = await asyncio.gather(*images) + + media = [InputMediaPhoto(media=img_bytes) for img_bytes in downloaded_images if img_bytes] + + if media: + await status_message.edit('Uploading pictures...', parse_mode=enums.ParseMode.MARKDOWN) + while media: + batch = media[:10] + media = media[10:] + await message.reply_media_group(batch) + await status_message.delete() # Delete status message after uploading + else: + await status_message.edit('No valid images found.', parse_mode=enums.ParseMode.MARKDOWN) + else: + await status_message.edit( + 'No images found for the given query.', + parse_mode=enums.ParseMode.MARKDOWN, + ) + else: + await status_message.edit( + 'An error occurred, please try again later.', + parse_mode=enums.ParseMode.MARKDOWN, + ) + + +modules_help['pinterest'] = { + 'pinterest [number]* [query]': 'Get images from Pinterest. Default number of images is 10', +} diff --git a/images/risearch.py b/images/risearch.py new file mode 100644 index 0000000..461db1e --- /dev/null +++ b/images/risearch.py @@ -0,0 +1,100 @@ +import os + +import requests +from modules.url import generate_screenshot +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +# API endpoints for reverse image search engines +SEARCH_ENGINES = { + 'lens': 'https://lens.google.com/uploadbyurl?url={image}', + 'reverse': 'https://www.google.com/searchbyimage?sbisrc=4chanx&image_url={image}&safe=off', + 'tineye': 'https://www.tineye.com/search?url={image}', + 'bing': 'https://www.bing.com/images/search?view=detailv2&iss=sbi&form=SBIVSP&sbisrc=UrlPaste&q=imgurl:{image}', + 'yandex': 'https://yandex.com/images/search?source=collections&&url={image}&rpt=imageview', + 'saucenao': 'https://saucenao.com/search.php?db=999&url={image}', +} + + +@Client.on_message(filters.command('risearch', prefix) & filters.reply) +async def reverse_image_search(client: Client, message: Message): + if not message.reply_to_message or not message.reply_to_message.photo: + await message.reply_text( + f'Please reply to an image with {prefix}risearch [engine] or {prefix}risearch.' + ) + return + + command_parts = message.text.split(maxsplit=1) + engines_to_use = ( + [command_parts[1].strip().lower()] + if len(command_parts) > 1 and command_parts[1].strip() + else list(SEARCH_ENGINES.keys()) + ) + + invalid_engines = [engine for engine in engines_to_use if engine not in SEARCH_ENGINES] + if invalid_engines: + await message.reply_text( + f'Invalid engine(s): {", ".join(invalid_engines)}. Available: {", ".join(SEARCH_ENGINES.keys())}' + ) + return + + processing_message = await message.edit_text('Processing the image...') + + try: + # Download and upload the image + photo_path = await message.reply_to_message.download() + img_url = upload_image(photo_path) + print(img_url) + if not img_url: + await processing_message.edit('Error: Could not upload the image.') + return + + # Perform searches for the selected engines + for engine in engines_to_use: + search_url = SEARCH_ENGINES[engine].format(image=img_url) + await send_screenshot(client, message, search_url, engine) + except Exception as e: + await processing_message.edit(f'An error occurred: {e}') + finally: + if photo_path and os.path.exists(photo_path): + os.remove(photo_path) + + +def upload_image(photo_path): + """Uploads an image to tmpfiles.org and returns the direct download URL.""" + try: + with open(photo_path, 'rb') as image_file: + response = requests.post('https://tmpfiles.org/api/v1/upload', files={'file': image_file}, timeout=10) + if response.status_code == 200: + data = response.json() + url = data['data']['url'] + pic_url = url.split('/')[-2] + '/' + url.split('/')[-1] + direct_download_url = url.replace(f'/{pic_url}', f'/dl/{pic_url}') + print(direct_download_url) + return direct_download_url + else: + return None + except Exception: + return None + + +async def send_screenshot(client, message, url, engine_name): + """Takes a screenshot of the URL and sends it to the chat.""" + screenshot_data = generate_screenshot(url) + if screenshot_data: + await client.send_photo( + message.chat.id, + screenshot_data, + caption=f'{engine_name.capitalize()} Result\nURL: {url}', + reply_to_message_id=message.id, + ) + else: + await message.reply(f'Failed to take screenshot for {engine_name.capitalize()}.') + + +# Add module details to help +modules_help['risearch'] = { + 'risearch': f'Reply to a photo with `{prefix}risearch [engine]` (e.g., `{prefix}risearch lens`, `{prefix}risearch bing`) ' + f'\nor use `{prefix}risearch` to analyze the image with all engines.', +} diff --git a/images/unsplash.py b/images/unsplash.py new file mode 100644 index 0000000..38abc93 --- /dev/null +++ b/images/unsplash.py @@ -0,0 +1,85 @@ +import asyncio +import os +import shutil + +import aiohttp +import requests +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +class AioHttp: + async def get_json(self, link): + headers = { + 'accept': '*/*', + 'accept-language': 'en-US', + 'cache-control': 'no-cache', + 'client-geo-region': 'global', + 'dnt': '1', + 'pragma': 'no-cache', + 'priority': 'u=1, i', + 'sec-ch-ua': '"Chromium";v="130", "Microsoft Edge";v="130", "Not?A_Brand";v="99"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'empty', + 'sec-fetch-mode': 'cors', + 'sec-fetch-site': 'same-origin', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0', + } + + async with aiohttp.ClientSession() as session, session.get(link, headers=headers) as resp: + return await resp.json() + + +@Client.on_message(filters.command('unsplash', prefix) & filters.me) +async def unsplash(client: Client, message: Message): + if len(message.command) > 1 and isinstance(message.command[1], str): + keyword = message.command[1] + unsplash_dir = 'downloads/unsplash/' + if not os.path.exists(unsplash_dir): + os.makedirs(unsplash_dir) + + if len(message.command) > 2 and 2 <= int(message.command[2]) <= 10: + await message.edit('Getting Pictures', parse_mode=enums.ParseMode.HTML) + count = int(message.command[2]) + images = [] + data = await AioHttp().get_json( + f'https://unsplash.com/napi/search/photos?page=1&per_page={count}&query={keyword}' + ) + while len(images) < count: + for ia in range(len(images), count): + img = data['results'][ia]['urls']['raw'] + if img.startswith('https://images.unsplash.com/photo'): + image_content = requests.get(img, timeout=10).content + with open(f'{unsplash_dir}/unsplash_{ia}.jpg', 'wb') as f: + f.write(image_content) + imgr = f'{unsplash_dir}/unsplash_{ia}.jpg' + images.append(imgr) + else: + images.append(img) + if len(images) == count: + break + + for img in images: + await client.send_document(message.chat.id, img) + + await message.delete() + shutil.rmtree(unsplash_dir) + return + else: + await message.edit('Getting Picture', parse_mode=enums.ParseMode.HTML) + data = await AioHttp().get_json( + f'https://unsplash.com/napi/search/photos?page=1&per_page=1&query={keyword}' + ) + img = data['results'][0]['urls']['raw'] + await asyncio.gather(message.delete(), client.send_document(message.chat.id, str(img))) + + +modules_help['unsplash'] = { + 'unsplash': '[keyword]* [number of results you want]*\n' + 'Makes a request to unsplash.com and sends the image with the keyword you provided.\n\n' + 'Note:\n1. The number of results you can get is limited to 10.\n' + '2. Keyword is required and should be of one word only!.\n' + '3. Images are sent as document to maintain quality.', +} diff --git a/images/unsplash2.py b/images/unsplash2.py new file mode 100644 index 0000000..859a358 --- /dev/null +++ b/images/unsplash2.py @@ -0,0 +1,92 @@ +import asyncio +from io import BytesIO + +import requests +from PIL import Image +from pyrogram import Client, enums, filters +from pyrogram.types import InputMediaPhoto, Message +from utils.misc import modules_help, prefix + +API_URL = 'https://bk9.fun/search/unsplash?q=' + + +def resize_image(image_bytes): + try: + with Image.open(image_bytes) as img: + max_size = (1280, 1280) + if img.size > max_size: + img.thumbnail(max_size) + output = BytesIO() + img.save(output, format='JPEG') + output.seek(0) + return output + image_bytes.seek(0) # Reset pointer if not resized + return image_bytes + except Exception as e: + print(f'Error resizing image: {e}') + return image_bytes + + +async def download_image(url): + try: + response = requests.get(url, timeout=10) + if response.status_code == 200: + img_bytes = BytesIO(response.content) + resized_img_bytes = resize_image(img_bytes) + return resized_img_bytes + except Exception as e: + print(f'Error downloading image: {e}') + return None + + +@Client.on_message(filters.command(['unsplash2', 'usp2'], prefix) & filters.me) +async def imgsearch(client: Client, message: Message): + if len(message.command) < 2: + await message.edit('Usage: `img [number] `', parse_mode=enums.ParseMode.MARKDOWN) + return + + num_pics = int(message.command[1]) if message.command[1].isdigit() else 10 + query = ' '.join(message.command[2:]) + + # Update status + status_message = await message.edit('Searching for images...', parse_mode=enums.ParseMode.MARKDOWN) + + url = f'{API_URL}{query}' + response = requests.get(url, timeout=10) + + if response.status_code == 200: + data = response.json() + if data.get('status'): + urls = data.get('BK9', [])[:num_pics] + images = [download_image(img_url) for img_url in urls] + + # Download images + downloaded_images = await asyncio.gather(*images) + + media = [InputMediaPhoto(media=img_bytes) for img_bytes in downloaded_images if img_bytes] + + if media: + await status_message.edit('Uploading pictures...', parse_mode=enums.ParseMode.MARKDOWN) + while media: + batch = media[:10] + media = media[10:] + await message.reply_media_group(batch) + await status_message.delete() # Delete status message after uploading + else: + await status_message.edit('No valid images found.', parse_mode=enums.ParseMode.MARKDOWN) + else: + await status_message.edit( + 'No images found for the given query.', + parse_mode=enums.ParseMode.MARKDOWN, + ) + else: + await status_message.edit( + 'An error occurred, please try again later.', + parse_mode=enums.ParseMode.MARKDOWN, + ) + + +modules_help['unsplash2'] = { + 'unsplash2 [number]* [query]': 'Get HD images. Default number of images is 10', + 'usp2 [number]* [query]': 'Get HD images. Default number of images is 10', +} diff --git a/incident-archive/Overlay FS failure.md b/incident-archive/Overlay FS failure.md deleted file mode 100644 index 726a548..0000000 --- a/incident-archive/Overlay FS failure.md +++ /dev/null @@ -1,54 +0,0 @@ -# Resolved: Overlay FS failure (and so containers) - -15-12-2025 03:02 AM EET: Degraded control panels' performances, following by full cascade docker failure - -15-12-2025 04:36 AM EET: Identified: Services are terminated due to server software (Overlay FS) + hardware issues (HDD). - -15-12-2025 08:45 PM EET: Restored NextCloud service with few tweaks to lower I/O - ---- - -16-12-2025 08:34 PM EET: Ordered new HDD, ETA 22nd of December - 2nd of January - ---- - -17-12-2025 02:47 AM EET: To avoid additional I/O into kuma's database, disabled uptime monitoring for non-critical services, such as: - -- Game servers -- Gitea (no public projects being hosted yet) -- Landings -- Cloud services -- PenPot -- Auth provider -- Secondary management tools -- Chernuha's non-important infrastructure - -These can be identified by seeing ">2m ago" under monitor's heartbeats. - ---- - -09-01-2026 02:32 PM EET: NextCloud's frontend files are corrupted due to the unknown issue. All user data is integrity-verified. To prevent user data corruption, NextCloud service will be restored after new hardware will be available. - ---- - -14-01-2026 08:42 PM EET: After planned updating and restarting server, critical firmware software were corrupted because of physical degradation of the disk. Server inaccessible in any way - ---- - -15-01-2026 11:30 AM EET: A new NAS-Grade HDD (Seagate IronWolf Pro) was ordered. ETA 16-01-2026 EET Before 12:00 PM - ---- - -16-01-2026 12:47 AM EET: New server system is installed, data backed up. Experiencing docker memory leak. - ---- - -17-01-2026 01:27 PM EET: All services except NextCloud and Satisfactory server are online. - -17-01-2026 03:48 PM EET: Nextcloud is online. Satisfactory will be provided on-demand. Monitoring status - ---- - -##### Status: All services are online - -**Solution: moving all infrastructure onto new NAS-Grade HDD with fresh OS install** diff --git a/install.py b/install.py new file mode 100644 index 0000000..3ecf9a2 --- /dev/null +++ b/install.py @@ -0,0 +1,52 @@ +import sys +from datetime import datetime + +from pyrogram import Client +from utils import config + +common_params = { + 'api_id': config.api_id, + 'api_hash': config.api_hash, + 'hide_password': True, + 'test_mode': config.test_server, +} + +if __name__ == '__main__': + if config.STRINGSESSION: + common_params['session_string'] = config.STRINGSESSION + + app = Client('my_account', **common_params) + + if config.db_type in ['mongo', 'mongodb']: + from pymongo import MongoClient, errors + + db = MongoClient(config.db_url) + try: + db.server_info() + except errors.ConnectionFailure as e: + raise RuntimeError( + "MongoDB server isn't available! " + f'Provided url: {config.db_url}. ' + 'Enter valid URL and restart installation' + ) from e + + install_type = sys.argv[1] if len(sys.argv) > 1 else '3' + if install_type == '1': + restart = 'pm2 restart Moon' + elif install_type == '2': + restart = 'sudo systemctl restart Moon' + else: + restart = 'cd Moon-Userbot/ && python main.py' + + app.start() + try: + app.send_message( + 'me', + f'[{datetime.now()}] Userbot launched! \n' + 'Custom modules: @moonub_modules\n' + f'For restart, enter:\n' + f'{restart}', + ) + except Exception as e: + print(f'[ERROR]: Sending Message to me failed! {e}') + app.stop() diff --git a/k8s/base/common-config.yaml b/k8s/base/common-config.yaml new file mode 100644 index 0000000..4a11507 --- /dev/null +++ b/k8s/base/common-config.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: userbot-common-config +data: + DATABASE_TYPE: "" + DATABASE_NAME: "" diff --git a/k8s/base/kustomization.yaml b/k8s/base/kustomization.yaml new file mode 100644 index 0000000..ea5fa72 --- /dev/null +++ b/k8s/base/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - userbots.yaml + - common-secret.yaml + - common-config.yaml + - forust-secrets.yaml + - anna-secrets.yaml diff --git a/k8s/base/userbots.yaml b/k8s/base/userbots.yaml new file mode 100644 index 0000000..74618d9 --- /dev/null +++ b/k8s/base/userbots.yaml @@ -0,0 +1,114 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: forust-userbot-deployment + labels: + app: forust-userbot +spec: + replicas: 1 + selector: + matchLabels: + app: forust-userbot + template: + metadata: + labels: + app: forust-userbot + spec: + containers: + - name: forust-userbot + image: gcr.forust.xyz/forust/userbot:latest + imagePullPolicy: Always + resources: + limits: + memory: "1.5Gi" + cpu: "300m" + requests: + memory: "512Mi" + cpu: "80m" + envFrom: + - secretRef: + name: userbot-common-secrets + - configMapRef: + name: userbot-common-config + - secretRef: + name: userbot-forust-secrets + volumeMounts: + - name: forust-storage + mountPath: /app/data + subPath: data + - name: forust-storage + mountPath: /app/logs + subPath: logs + volumes: + - name: forust-storage + persistentVolumeClaim: + claimName: forust-pvc +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: forust-pvc +spec: + resources: + requests: + storage: 1Gi + accessModes: + - ReadWriteOnce + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: anna-userbot-deployment + labels: + app: anna-userbot +spec: + replicas: 1 + selector: + matchLabels: + app: anna-userbot + template: + metadata: + labels: + app: anna-userbot + spec: + containers: + - name: anna-userbot + image: gcr.forust.xyz/forust/userbot:latest + imagePullPolicy: Always + resources: + limits: + memory: "1.5Gi" + cpu: "300m" + requests: + memory: "512Mi" + cpu: "80m" + envFrom: + - secretRef: + name: userbot-common-secrets + - configMapRef: + name: userbot-common-config + - secretRef: + name: userbot-anna-secrets + volumeMounts: + - name: anna-storage + mountPath: /app/data + subPath: data + - name: anna-storage + mountPath: /app/logs + subPath: logs + volumes: + - name: anna-storage + persistentVolumeClaim: + claimName: anna-pvc +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: anna-pvc +spec: + resources: + requests: + storage: 1Gi + accessModes: + - ReadWriteOnce diff --git a/k8s/overlays/dev/kustomization.yaml b/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..1e8df73 --- /dev/null +++ b/k8s/overlays/dev/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + - path: patch-downloads.yaml diff --git a/k8s/overlays/dev/patch-downloads.yaml b/k8s/overlays/dev/patch-downloads.yaml new file mode 100644 index 0000000..0dcab07 --- /dev/null +++ b/k8s/overlays/dev/patch-downloads.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: forust-userbot-deployment +spec: + template: + spec: + containers: + - name: forust-userbot + volumeMounts: + - name: downloads + mountPath: /app/downloads + volumes: + - name: downloads + hostPath: + path: /home/user/projects/homelab/userbot/Downloads + type: DirectoryOrCreate +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: anna-userbot-deployment +spec: + template: + spec: + containers: + - name: anna-userbot + volumeMounts: + - name: downloads + mountPath: /app/downloads + volumes: + - name: downloads + hostPath: + path: /home/user/projects/homelab/userbot/Downloads + type: DirectoryOrCreate diff --git a/k8s/overlays/prod/kustomization.yaml b/k8s/overlays/prod/kustomization.yaml new file mode 100644 index 0000000..1e8df73 --- /dev/null +++ b/k8s/overlays/prod/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ../../base + +patches: + - path: patch-downloads.yaml diff --git a/k8s/overlays/prod/patch-downloads.yaml b/k8s/overlays/prod/patch-downloads.yaml new file mode 100644 index 0000000..af06d6a --- /dev/null +++ b/k8s/overlays/prod/patch-downloads.yaml @@ -0,0 +1,35 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: forust-userbot-deployment +spec: + template: + spec: + containers: + - name: forust-userbot + volumeMounts: + - name: downloads + mountPath: /app/downloads + volumes: + - name: downloads + hostPath: + path: /srv/homelab/userbot/Downloads + type: DirectoryOrCreate +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: anna-userbot-deployment +spec: + template: + spec: + containers: + - name: anna-userbot + volumeMounts: + - name: downloads + mountPath: /app/downloads + volumes: + - name: downloads + hostPath: + path: /srv/homelab/userbot/Downloads + type: DirectoryOrCreate diff --git a/kener/.env.example b/kener/.env.example deleted file mode 100644 index 35469a3..0000000 --- a/kener/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -KENER_SECRET_KEY=your_secret_key_here -ORIGIN=http://localhost:3000 -TZ=Etc/UTC \ No newline at end of file diff --git a/kener/compose.yaml b/kener/compose.yaml deleted file mode 100644 index 18dc6cc..0000000 --- a/kener/compose.yaml +++ /dev/null @@ -1,59 +0,0 @@ -services: - kener: - image: rajnandan1/kener:4.0.23 - container_name: kener - restart: unless-stopped - # ports: - # - 3000:3000/tcp - environment: - - KENER_SECRET_KEY=${KENER_SECRET_KEY?Kener requires a secret key} - - ORIGIN=${ORIGIN:-http://localhost:3000} - - REDIS_URL=redis://redis:6379 - - TZ:${TZ:-Etc/UTC} - depends_on: - redis: - condition: service_healthy - volumes: - - db:/app/database - - uploads:/app/uploads - labels: - - "traefik.enable=true" - - "traefik.http.services.kener.loadbalancer.server.port=3000" - - # Prod Router - - "traefik.http.routers.kener.rule=Host(`status.forust.xyz`)" - - "traefik.http.routers.kener.entrypoints=websecure" - - "traefik.http.routers.kener.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.kener-local.rule=Host(`status.workstation.internal`)" - - "traefik.http.routers.kener-local.entrypoints=websecure" - - "traefik.http.routers.kener-local.tls=true" - # Dev Router - - "traefik.http.routers.kener-dev.rule=Host(`status.gigaforust.internal`)" - - "traefik.http.routers.kener-dev.entrypoints=websecure" - - "traefik.http.routers.kener-dev.tls=true" - networks: - - proxy - - kener - redis: - image: redis:7-alpine - container_name: kener-redis - restart: unless-stopped - volumes: - - redis:/data - healthcheck: - test: ["CMD", "redis-cli", "ping"] - interval: 6s - timeout: 5s - retries: 5 - networks: - - kener -volumes: - db: - uploads: - redis: -networks: - proxy: - external: true - kener: - external: false diff --git a/kener/k8s/config.yaml b/kener/k8s/config.yaml deleted file mode 100644 index 58fb123..0000000 --- a/kener/k8s/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: kener-config - namespace: kener -data: - ORIGIN: "https://status.forust.xyz" - REDIS_URL: "redis://kener-redis-service:6379" - TZ: "Etc/UTC" diff --git a/kener/k8s/ingress.yaml b/kener/k8s/ingress.yaml deleted file mode 100644 index c35e01a..0000000 --- a/kener/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: kener-prod - namespace: kener -spec: - entryPoints: - - websecure - routes: - - match: Host(`status.forust.xyz`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: kener-service - port: 3000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: kener-local - namespace: kener -spec: - entryPoints: - - websecure - routes: - - match: Host(`status.workstation.internal`) || Host(`status.gigaforust.internal`) - kind: Rule - services: - - name: kener-service - port: 3000 diff --git a/kener/k8s/kener.yaml b/kener/k8s/kener.yaml deleted file mode 100644 index f382f16..0000000 --- a/kener/k8s/kener.yaml +++ /dev/null @@ -1,80 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: kener-service - namespace: kener -spec: - selector: - app: kener - ports: - - port: 3000 - targetPort: 3000 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: kener-deployment - namespace: kener -spec: - replicas: 1 - selector: - matchLabels: - app: kener - template: - metadata: - labels: - app: kener - spec: - containers: - - name: kener - image: rajnandan1/kener:4.1.0 - envFrom: - - configMapRef: - name: kener-config - - secretRef: - name: kener-secrets - resources: - requests: - memory: "300Mi" - cpu: "350m" - limits: - memory: "2Gi" - cpu: "1" - ports: - - containerPort: 3000 - volumeMounts: - - name: kener-db - mountPath: /app/database - - name: kener-uploads - mountPath: /app/uploads - volumes: - - name: kener-db - persistentVolumeClaim: - claimName: kener-db-pvc - - name: kener-uploads - persistentVolumeClaim: - claimName: kener-uploads-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: kener-db-pvc - namespace: kener -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 2Gi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: kener-uploads-pvc - namespace: kener -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi diff --git a/kener/k8s/namespace.yaml b/kener/k8s/namespace.yaml deleted file mode 100644 index 659b4f5..0000000 --- a/kener/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: kener diff --git a/kener/k8s/redis.yaml b/kener/k8s/redis.yaml deleted file mode 100644 index 88f280b..0000000 --- a/kener/k8s/redis.yaml +++ /dev/null @@ -1,46 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: kener-redis-service - namespace: kener -spec: - clusterIP: None - selector: - app: kener-redis - ports: - - name: redis - port: 6379 - targetPort: 6379 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: kener-redis - namespace: kener -spec: - serviceName: kener-redis-service - replicas: 1 - selector: - matchLabels: - app: kener-redis - template: - metadata: - labels: - app: kener-redis - spec: - containers: - - name: redis - image: redis:7-alpine - ports: - - containerPort: 6379 - volumeMounts: - - name: redis-data - mountPath: /data - volumeClaimTemplates: - - metadata: - name: redis-data - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 1Gi diff --git a/kener/k8s/secrets.yaml.example b/kener/k8s/secrets.yaml.example deleted file mode 100644 index f1ef80a..0000000 --- a/kener/k8s/secrets.yaml.example +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: kener-secrets - namespace: kener -type: Opaque -stringData: - KENER_SECRET_KEY: "" diff --git a/main.py b/main.py new file mode 100644 index 0000000..dc0a65a --- /dev/null +++ b/main.py @@ -0,0 +1,177 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pip", +# "pyrofork", +# "tgcrypto", +# "wheel", +# "gunicorn", +# "flask", +# "humanize", +# "pygments", +# "pymongo", +# "psutil", +# "Pillow>=10.3.0", +# "click", +# "dnspython", +# "requests", +# "environs", +# "GitPython", +# "beautifulsoup4", +# "aiohttp", +# "aiofiles", +# "pySmartDL", +# ] +# /// +import contextlib +import logging +import os +import platform +import sqlite3 +import subprocess + +import requests +from pyrogram import Client, errors, idle +from pyrogram.enums.parse_mode import ParseMode +from pyrogram.raw.functions.account import DeleteAccount, GetAuthorizations +from utils import config +from utils.db import db +from utils.misc import userbot_version +from utils.module import ModuleManager +from utils.rentry import rentry_cleanup_job +from utils.scripts import restart + +SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__)) +if os.getcwd() != SCRIPT_PATH: + os.chdir(SCRIPT_PATH) + +common_params = { + 'api_id': config.api_id, + 'api_hash': config.api_hash, + 'hide_password': True, + 'workdir': SCRIPT_PATH, + 'app_version': userbot_version, + 'device_model': 'mUserbot', + 'system_version': platform.version() + ' ' + platform.machine(), + 'sleep_threshold': 30, + 'test_mode': config.test_server, + 'parse_mode': ParseMode.HTML, +} + +if config.STRINGSESSION: + common_params['session_string'] = config.STRINGSESSION + +app = Client('my_account', **common_params) + + +def load_missing_modules(): + all_modules = db.get('custom.modules', 'allModules', []) + if not all_modules: + return + + custom_modules_path = f'{SCRIPT_PATH}/modules/custom_modules' + os.makedirs(custom_modules_path, exist_ok=True) + + try: + resp = requests.get( + 'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/full.txt', + timeout=10, + ) + if not resp.ok: + logging.error( + 'Failed to fetch custom modules list: HTTP %s', + resp.status_code, + ) + return + f = resp.text + except Exception as e: + logging.error('Failed to fetch custom modules list: %s', e) + return + modules_dict = {line.split('/')[-1].split()[0]: line.strip() for line in f.splitlines()} + + for module_name in all_modules: + module_path = f'{custom_modules_path}/{module_name}.py' + if not os.path.exists(module_path) and module_name in modules_dict: + url = f'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/{modules_dict[module_name]}.py' + resp = requests.get(url, timeout=10) + if resp.ok: + with open(module_path, 'wb') as f: + f.write(resp.content) + logging.info('Loaded missing module: %s', module_name) + else: + logging.warning('Failed to load module: %s', module_name) + + +async def main(): + logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[logging.FileHandler('moonlogs.txt'), logging.StreamHandler()], + level=logging.INFO, + ) + DeleteAccount.__new__ = None + + try: + await app.start() + except sqlite3.OperationalError as e: + if str(e) == 'database is locked' and os.name == 'posix': + logging.warning('Session file is locked. Trying to kill blocking process...') + subprocess.run(['fuser', '-k', 'my_account.session'], check=True) # noqa: S607 + restart() + raise + except (errors.NotAcceptable, errors.Unauthorized) as e: + logging.error( + '%s: %s\nMoving session file to my_account.session-old...', + e.__class__.__name__, + e, + ) + os.rename('./my_account.session', './my_account.session-old') + restart() + + load_missing_modules() + module_manager = ModuleManager.get_instance() + await module_manager.load_modules(app) + + if info := db.get('core.updater', 'restart_info'): + text = { + 'restart': 'Restart completed!', + 'update': 'Update process completed!', + }[info['type']] + with contextlib.suppress(errors.RPCError): + await app.edit_message_text(info['chat_id'], info['message_id'], text) + db.remove('core.updater', 'restart_info') + + # required for sessionkiller module + if db.get('core.sessionkiller', 'enabled', False): + db.set( + 'core.sessionkiller', + 'auths_hashes', + [auth.hash for auth in (await app.invoke(GetAuthorizations())).authorizations], + ) + + logging.info('Moon-Userbot started!') + + app.loop.create_task(rentry_cleanup_job()) + + await idle() + + await app.stop() + + +if __name__ == '__main__': + app.run(main()) diff --git a/metube/compose.yaml b/metube/compose.yaml deleted file mode 100644 index e288b14..0000000 --- a/metube/compose.yaml +++ /dev/null @@ -1,36 +0,0 @@ -services: - metube: - image: ghcr.io/alexta69/metube - container_name: metube - restart: unless-stopped - # ports: - # - "8081:8081" - environment: - - DOWNLOAD_MODE=limited - - MAX_CONCURRENT_DOWNLOADS=3 - - DELETE_FILE_ON_TRASHCAN=true - - DEFAULT_OPTION_PLAYLIST_STRICT_MODE=true - volumes: - - ./MeTube_downloads:/downloads - labels: - - "traefik.enable=true" - - "traefik.http.services.metube.loadbalancer.server.port=8081" - - # Prod Router - - "traefik.http.routers.metube.rule=Host(`metube.forust.xyz`)" - - "traefik.http.routers.metube.entrypoints=websecure" - - "traefik.http.routers.metube.middlewares=security-chain@file" - - "traefik.http.routers.metube.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.metube-local.rule=Host(`metube.workstation.internal`)" - - "traefik.http.routers.metube-local.entrypoints=websecure" - - "traefik.http.routers.metube-local.tls=true" - # Dev Router - - "traefik.http.routers.metube-dev.rule=Host(`metube.gigaforust.internal`)" - - "traefik.http.routers.metube-dev.entrypoints=websecure" - - "traefik.http.routers.metube-dev.tls=true" - networks: - - proxy -networks: - proxy: - external: true diff --git a/metube/k8s/config.yaml b/metube/k8s/config.yaml deleted file mode 100644 index 6fdd603..0000000 --- a/metube/k8s/config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: metube-config - namespace: metube -data: - DOWNLOAD_MODE: "limited" - MAX_CONCURRENT_DOWNLOADS: "3" - DELETE_FILE_ON_TRASHCAN: "true" - DEFAULT_OPTION_PLAYLIST_STRICT_MODE: "true" - CLEAR_COMPLETED_AFTER: "600" # 10 min diff --git a/metube/k8s/ingress.yaml b/metube/k8s/ingress.yaml deleted file mode 100644 index ffd0ac0..0000000 --- a/metube/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: metube-prod - namespace: metube -spec: - entryPoints: - - websecure - routes: - - match: Host(`metube.forust.xyz`) - kind: Rule - middlewares: - - name: security-headers@file - services: - - name: metube-service - port: 8081 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: metube-local - namespace: metube -spec: - entryPoints: - - websecure - routes: - - match: Host(`metube.workstation.internal`) || Host(`metube.gigaforust.internal`) - kind: Rule - services: - - name: metube-service - port: 8081 diff --git a/metube/k8s/metube.yaml b/metube/k8s/metube.yaml deleted file mode 100644 index 99ec7aa..0000000 --- a/metube/k8s/metube.yaml +++ /dev/null @@ -1,49 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: metube-service - namespace: metube -spec: - selector: - app: metube - ports: - - port: 8081 - targetPort: 8081 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: metube-deployment - namespace: metube -spec: - replicas: 1 - selector: - matchLabels: - app: metube - template: - metadata: - labels: - app: metube - spec: - containers: - - name: metube - image: ghcr.io/alexta69/metube - envFrom: - - configMapRef: - name: metube-config - ports: - - containerPort: 8081 - volumeMounts: - - name: downloads - mountPath: /downloads - resources: - requests: - memory: "600Mi" - cpu: "400m" - limits: - memory: "2Gi" - cpu: "1700m" - volumes: - - name: downloads - emptyDir: - sizeLimit: 20Gi diff --git a/metube/k8s/namespace.yaml b/metube/k8s/namespace.yaml deleted file mode 100644 index 6bafd26..0000000 --- a/metube/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: metube diff --git a/modules/1000-7.py b/modules/1000-7.py new file mode 100644 index 0000000..d77f3c3 --- /dev/null +++ b/modules/1000-7.py @@ -0,0 +1,32 @@ +from asyncio import sleep + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +digits = {str(i): el for i, el in enumerate(['0️⃣', '1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣'])} + + +def prettify(val: int) -> str: + return ''.join(digits[i] for i in str(val)) + + +@Client.on_message(filters.command('ghoul', prefix) & filters.me) +async def ghoul_counter(_, message: Message): + await message.delete() + + counter = int(message.command[1]) if len(message.command) > 1 and message.command[1].isdigit() else 1000 + + msg = await message.reply(prettify(counter), quote=False) + + await sleep(1) + + while counter // 7: + counter -= 7 + await msg.edit(prettify(counter)) + await sleep(1) + + await msg.edit('🤡 GHOUL 🤡') + + +modules_help['1000-7'] = {'ghoul [count_from]': 'counting from 1000 (or given [count_from] to 0 as a ghoul'} diff --git a/modules/admintool.py b/modules/admintool.py new file mode 100644 index 0000000..5ccbe8e --- /dev/null +++ b/modules/admintool.py @@ -0,0 +1,342 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from contextlib import suppress + +from pyrogram import Client, ContinuePropagation, filters +from pyrogram.enums import ChatType +from pyrogram.errors import ( + ChatAdminRequired, + RPCError, + UserAdminInvalid, +) +from pyrogram.raw import functions +from pyrogram.types import ChatPermissions, Message +from utils.db import db +from utils.handlers import ( + AntiChannelsHandler, + AntiRaidHandler, + BanHandler, + DeleteHistoryHandler, + DemoteHandler, + KickDeletedAccountsHandler, + KickHandler, + MuteHandler, + PromoteHandler, + TimeMuteHandler, + TimeMuteUsersHandler, + TimeUnmuteHandler, + UnbanHandler, + UnmuteHandler, +) +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, with_reply + +db_cache: dict = db.get_collection('core.ats') + + +def update_cache(): + db_cache.clear() + db_cache.update(db.get_collection('core.ats')) + + +@Client.on_message(filters.group & ~filters.me) +async def admintool_handler(_, message: Message): + if message.sender_chat and ( + message.sender_chat.type == 'supergroup' + or message.sender_chat.id == db_cache.get(f'linked{message.chat.id}', 0) + ): + raise ContinuePropagation + + if message.sender_chat and db_cache.get(f'antich{message.chat.id}', False): + with suppress(RPCError): + await message.delete() + await message.chat.ban_member(message.sender_chat.id) + + tmuted_users = db_cache.get(f'c{message.chat.id}', []) + if ( + message.from_user + and message.from_user.id in tmuted_users + or message.sender_chat + and message.sender_chat.id in tmuted_users + ): + with suppress(RPCError): + await message.delete() + + if db_cache.get(f'antiraid{message.chat.id}', False): + with suppress(RPCError): + await message.delete() + if message.from_user: + await message.chat.ban_member(message.from_user.id) + elif message.sender_chat: + await message.chat.ban_member(message.sender_chat.id) + + if message.new_chat_members and db_cache.get(f'welcome_enabled{message.chat.id}', False): + await message.reply( + db_cache.get(f'welcome_text{message.chat.id}'), + disable_web_page_preview=True, + ) + + raise ContinuePropagation + + +async def get_user_and_name(message): + if message.reply_to_message.from_user: + return ( + message.reply_to_message.from_user.id, + message.reply_to_message.from_user.first_name, + ) + if message.reply_to_message.sender_chat: + return ( + message.reply_to_message.sender_chat.id, + message.reply_to_message.sender_chat.title, + ) + + +@Client.on_message(filters.command(['ban'], prefix) & filters.me) +async def ban_command(client: Client, message: Message): + handler = BanHandler(client, message) + await handler.handle_ban() + + +@Client.on_message(filters.command(['unban'], prefix) & filters.me) +async def unban_command(client: Client, message: Message): + handler = UnbanHandler(client, message) + await handler.handle_unban() + + +@Client.on_message(filters.command(['kick'], prefix) & filters.me) +async def kick_command(client: Client, message: Message): + handler = KickHandler(client, message) + await handler.handle_kick() + + +@Client.on_message(filters.command(['kickdel'], prefix) & filters.me) +async def kickdel_cmd(client: Client, message: Message): + handler = KickDeletedAccountsHandler(client, message) + await handler.kick_deleted_accounts() + + +@Client.on_message(filters.command(['tmute'], prefix) & filters.me) +async def tmute_command(client: Client, message: Message): + handler = TimeMuteHandler(client, message) + await handler.handle_tmute() + update_cache() + + +@Client.on_message(filters.command(['tunmute'], prefix) & filters.me) +async def tunmute_command(client: Client, message: Message): + handler = TimeUnmuteHandler(client, message) + await handler.handle_tunmute() + update_cache() + + +@Client.on_message(filters.command(['tmute_users'], prefix) & filters.me) +async def tunmute_users_command(client: Client, message: Message): + handler = TimeMuteUsersHandler(client, message) + await handler.list_tmuted_users() + + +@Client.on_message(filters.command(['unmute'], prefix) & filters.me) +async def unmute_command(client: Client, message: Message): + handler = UnmuteHandler(client, message) + await handler.handle_unmute() + + +@Client.on_message(filters.command(['mute'], prefix) & filters.me) +async def mute_command(client: Client, message: Message): + handler = MuteHandler(client, message) + await handler.handle_mute() + + +@Client.on_message(filters.command(['demote'], prefix) & filters.me) +async def demote_command(client: Client, message: Message): + handler = DemoteHandler(client, message) + await handler.handle_demote() + + +@Client.on_message(filters.command(['promote'], prefix) & filters.me) +async def promote_command(client: Client, message: Message): + handler = PromoteHandler(client, message) + await handler.handle_promote() + + +@Client.on_message(filters.command(['antich'], prefix)) +async def anti_channels(client: Client, message: Message): + handler = AntiChannelsHandler(client, message) + await handler.handle_anti_channels() + update_cache() + + +@Client.on_message(filters.command(['delete_history', 'dh'], prefix)) +async def delete_history(client: Client, message: Message): + handler = DeleteHistoryHandler(client, message) + await handler.handle_delete_history() + + +@Client.on_message(filters.command(['report_spam', 'rs'], prefix)) +@with_reply +async def report_spam(client: Client, message: Message): + try: + channel = await client.resolve_peer(message.chat.id) + + user_id, name = await get_user_and_name(message) + peer = await client.resolve_peer(user_id) + await client.invoke( + functions.channels.ReportSpam( + channel=channel, + participant=peer, + id=[message.reply_to_message.id], + ) + ) + except Exception as e: + await message.edit(format_exc(e)) + else: + await message.edit(f'Message from {name} was reported') + + +@Client.on_message(filters.command('pin', prefix) & filters.me) +@with_reply +async def pin(_, message: Message): + try: + await message.reply_to_message.pin() + await message.edit('Pinned!') + except Exception as e: + await message.edit(format_exc(e)) + + +@Client.on_message(filters.command('unpin', prefix) & filters.me) +@with_reply +async def unpin(_, message: Message): + try: + await message.reply_to_message.unpin() + await message.edit('Unpinned!') + except Exception as e: + await message.edit(format_exc(e)) + + +@Client.on_message(filters.command('ro', prefix) & filters.me) +async def ro(client: Client, message: Message): + if message.chat.type not in [ChatType.GROUP, ChatType.SUPERGROUP]: + await message.edit('Invalid chat type') + return + + try: + perms = message.chat.permissions + perms_list = [ + perms.can_send_messages, + perms.can_send_media_messages, + perms.can_send_polls, + perms.can_add_web_page_previews, + perms.can_change_info, + perms.can_invite_users, + perms.can_pin_messages, + ] + db.set('core.ats', f'ro{message.chat.id}', perms_list) + + try: + await client.set_chat_permissions(message.chat.id, ChatPermissions()) + except (UserAdminInvalid, ChatAdminRequired): + await message.edit('No rights') + else: + await message.edit(f'Read-only mode activated!\nTurn off with:{prefix}unro') + except Exception as e: + await message.edit(format_exc(e)) + + +@Client.on_message(filters.command('unro', prefix) & filters.me) +async def unro(client: Client, message: Message): + if message.chat.type not in [ChatType.GROUP, ChatType.SUPERGROUP]: + await message.edit('Invalid chat type') + return + + try: + perms_list = db.get( + 'core.ats', + f'ro{message.chat.id}', + [True, True, False, False, False, False, False], + ) + + common_perms = { + 'can_send_messages': perms_list[0], + 'can_send_media_messages': perms_list[1], + 'can_send_polls': perms_list[2], + 'can_add_web_page_previews': perms_list[3], + 'can_change_info': perms_list[4], + 'can_invite_users': perms_list[5], + 'can_pin_messages': perms_list[6], + } + + perms = ChatPermissions(**common_perms) + + try: + await client.set_chat_permissions(message.chat.id, perms) + except (UserAdminInvalid, ChatAdminRequired): + await message.edit('No rights') + else: + await message.edit('Read-only mode disabled!') + except Exception as e: + await message.edit(format_exc(e)) + + +@Client.on_message(filters.command('antiraid', prefix) & filters.me) +async def antiraid(client: Client, message: Message): + handler = AntiRaidHandler(client, message) + await handler.handle_antiraid() + update_cache() + + +@Client.on_message(filters.command(['welcome', 'wc'], prefix) & filters.me) +async def welcome(_, message: Message): + if message.chat.type not in [ChatType.GROUP, ChatType.SUPERGROUP]: + return await message.edit('Unsupported chat type') + + if len(message.command) > 1: + text = message.text.split(maxsplit=1)[1] + db.set('core.ats', f'welcome_enabled{message.chat.id}', True) + db.set('core.ats', f'welcome_text{message.chat.id}', text) + + await message.edit(f'Welcome enabled in this chat\nText: {text}') + else: + db.set('core.ats', f'welcome_enabled{message.chat.id}', False) + await message.edit('Welcome disabled in this chat') + + update_cache() + + +modules_help['admintool'] = { + 'ban [reply]/[username/id]* [reason] [report_spam] [delete_history]': 'ban user in chat', + 'unban [reply]/[username/id]* [reason]': 'unban user in chat', + 'kick [reply]/[userid]* [reason] [report_spam] [delete_history]': 'kick user out of chat', + 'mute [reply]/[userid]* [reason] [1m]/[1h]/[1d]/[1w]': 'mute user in chat', + 'unmute [reply]/[userid]* [reason]': 'unmute user in chat', + 'promote [reply]/[userid]* [prefix]': 'promote user in chat', + 'demote [reply]/[userid]* [reason]': 'demote user in chat', + 'tmute [reply]/[username/id]* [reason]': 'delete all new messages from user in chat', + 'tunmute [reply]/[username/id]* [reason]': 'stop deleting all messages from user in chat', + 'tmute_users': 'list of tmuted (.tmute) users', + 'antich [enable/disable]': 'turn on/off blocking channels in this chat', + 'delete_history [reply]/[username/id]* [reason]': 'delete history from member in chat', + 'report_spam [reply]*': 'report spam message in chat', + 'pin [reply]*': 'Pin replied message', + 'unpin [reply]*': 'Unpin replied message', + 'ro': 'enable read-only mode', + 'unro': 'disable read-only mode', + 'antiraid [on|off]': 'when enabled, anyone who writes message will be blocked. Useful in raids. ' + 'Running without arguments equals to toggling state', + 'welcome [text]*': 'enable auto-welcome to new users in groups. Running without text equals to disable', + 'kickdel': 'Kick all deleted accounts', +} diff --git a/modules/admlist.py b/modules/admlist.py new file mode 100644 index 0000000..6aa8175 --- /dev/null +++ b/modules/admlist.py @@ -0,0 +1,360 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from collections.abc import AsyncGenerator +from time import perf_counter +from typing import Optional + +from pyrogram import Client, enums, filters, raw, types, utils +from pyrogram.types.object import Object +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + + +class Chat(Object): + def __init__( + self, + *, + client: 'Client' = None, + id: id, # noqa: A002 + type: type, # noqa: A002 + is_verified: bool = None, + is_restricted: bool = None, + is_creator: bool = None, + is_scam: bool = None, + is_fake: bool = None, + is_support: bool = None, + title: str = None, + username: str = None, + first_name: str = None, + last_name: str = None, + photo: 'types.ChatPhoto' = None, + bio: str = None, + description: str = None, + dc_id: int = None, + has_protected_content: bool = None, + invite_link: str = None, + pinned_message=None, + sticker_set_name: str = None, + can_set_sticker_set: bool = None, + members_count: int = None, + restrictions: list['types.Restriction'] = None, + permissions: 'types.ChatPermissions' = None, + distance: int = None, + linked_chat: 'types.Chat' = None, + send_as_chat: 'types.Chat' = None, + available_reactions: Optional['types.ChatReactions'] = None, + is_admin: bool = False, + deactivated: bool = False, + ): + super().__init__(client) + + self.id = id + self.type = type + self.is_verified = is_verified + self.is_restricted = is_restricted + self.is_creator = is_creator + self.is_scam = is_scam + self.is_fake = is_fake + self.is_support = is_support + self.title = title + self.username = username + self.first_name = first_name + self.last_name = last_name + self.photo = photo + self.bio = bio + self.description = description + self.dc_id = dc_id + self.has_protected_content = has_protected_content + self.invite_link = invite_link + self.pinned_message = pinned_message + self.sticker_set_name = sticker_set_name + self.can_set_sticker_set = can_set_sticker_set + self.members_count = members_count + self.restrictions = restrictions + self.permissions = permissions + self.distance = distance + self.linked_chat = linked_chat + self.send_as_chat = send_as_chat + self.available_reactions = available_reactions + self.is_admin = is_admin + self.deactivated = deactivated + + @staticmethod + def _parse_user_chat(client, user: raw.types.User) -> 'Chat': + peer_id = user.id + + return Chat( + id=peer_id, + type=enums.ChatType.BOT if user.bot else enums.ChatType.PRIVATE, + is_verified=getattr(user, 'verified', None), + is_restricted=getattr(user, 'restricted', None), + is_scam=getattr(user, 'scam', None), + is_fake=getattr(user, 'fake', None), + is_support=getattr(user, 'support', None), + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + photo=types.ChatPhoto._parse(client, user.photo, peer_id, user.access_hash), + restrictions=types.List([types.Restriction._parse(r) for r in user.restriction_reason]) or None, + dc_id=getattr(getattr(user, 'photo', None), 'dc_id', None), + client=client, + ) + + @staticmethod + def _parse_chat_chat(client, chat: raw.types.Chat) -> 'Chat': + peer_id = -chat.id + return Chat( + id=peer_id, + type=enums.ChatType.GROUP, + title=chat.title, + is_creator=getattr(chat, 'creator', None), + photo=types.ChatPhoto._parse(client, getattr(chat, 'photo', None), peer_id, 0), + permissions=types.ChatPermissions._parse(getattr(chat, 'default_banned_rights', None)), + members_count=getattr(chat, 'participants_count', None), + dc_id=getattr(getattr(chat, 'photo', None), 'dc_id', None), + has_protected_content=getattr(chat, 'noforwards', None), + client=client, + is_admin=bool(getattr(chat, 'admin_rights', False)), + deactivated=chat.deactivated, + ) + + @staticmethod + def _parse_channel_chat(client, channel: raw.types.Channel) -> 'Chat': + peer_id = utils.get_channel_id(channel.id) + restriction_reason = getattr(channel, 'restriction_reason', []) + + return Chat( + id=peer_id, + type=(enums.ChatType.SUPERGROUP if getattr(channel, 'megagroup', None) else enums.ChatType.CHANNEL), + is_verified=getattr(channel, 'verified', None), + is_restricted=getattr(channel, 'restricted', None), + is_creator=getattr(channel, 'creator', None), + is_scam=getattr(channel, 'scam', None), + is_fake=getattr(channel, 'fake', None), + title=channel.title, + username=getattr(channel, 'username', None), + photo=types.ChatPhoto._parse( + client, + getattr(channel, 'photo', None), + peer_id, + getattr(channel, 'access_hash', 0), + ), + restrictions=types.List([types.Restriction._parse(r) for r in restriction_reason]) or None, + permissions=types.ChatPermissions._parse(getattr(channel, 'default_banned_rights', None)), + members_count=getattr(channel, 'participants_count', None), + dc_id=getattr(getattr(channel, 'photo', None), 'dc_id', None), + has_protected_content=getattr(channel, 'noforwards', None), + is_admin=bool(getattr(channel, 'admin_rights', False)), + client=client, + ) + + @staticmethod + def _parse( + client, + message: raw.types.Message | raw.types.MessageService, + users: dict, + chats: dict, + is_chat: bool, + ) -> 'Chat': + from_id = utils.get_raw_peer_id(message.from_id) + peer_id = utils.get_raw_peer_id(message.peer_id) + chat_id = (peer_id or from_id) if is_chat else (from_id or peer_id) + + if isinstance(message.peer_id, raw.types.PeerUser): + return Chat._parse_user_chat(client, users[chat_id]) + + if isinstance(message.peer_id, raw.types.PeerChat): + return Chat._parse_chat_chat(client, chats[chat_id]) + + return Chat._parse_channel_chat(client, chats[chat_id]) + + @staticmethod + def _parse_dialog(client, peer, users: dict, chats: dict): + if isinstance(peer, raw.types.PeerUser): + return Chat._parse_user_chat(client, users[peer.user_id]) + if isinstance(peer, raw.types.PeerChat): + return Chat._parse_chat_chat(client, chats[peer.chat_id]) + return Chat._parse_channel_chat(client, chats[peer.channel_id]) + + +class Dialog(Object): + def __init__( + self, + *, + client: 'Client' = None, + chat: 'types.Chat', + top_message: 'types.Message', + unread_messages_count: int, + unread_mentions_count: int, + unread_mark: bool, + is_pinned: bool, + ): + super().__init__(client) + + self.chat = chat + self.top_message = top_message + self.unread_messages_count = unread_messages_count + self.unread_mentions_count = unread_mentions_count + self.unread_mark = unread_mark + self.is_pinned = is_pinned + + @staticmethod + def _parse(client, dialog: 'raw.types.Dialog', messages, users, chats) -> 'Dialog': + return Dialog( + chat=Chat._parse_dialog(client, dialog.peer, users, chats), + top_message=messages.get(utils.get_peer_id(dialog.peer)), + unread_messages_count=dialog.unread_count, + unread_mentions_count=dialog.unread_mentions_count, + unread_mark=dialog.unread_mark, + is_pinned=dialog.pinned, + client=client, + ) + + +async def get_dialogs(self: 'Client', limit: int = 0) -> AsyncGenerator['types.Dialog', None] | None: + current = 0 + total = limit or (1 << 31) - 1 + limit = min(100, total) + offset_date = 0 + offset_id = 0 + offset_peer = raw.types.InputPeerEmpty() + while True: + r = await self.invoke( + raw.functions.messages.GetDialogs( + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + limit=limit, + hash=0, + ), + sleep_threshold=60, + ) + users = {i.id: i for i in r.users} + chats = {i.id: i for i in r.chats} + messages = {} + for message in r.messages: + if isinstance(message, raw.types.MessageEmpty): + continue + chat_id = utils.get_peer_id(message.peer_id) + messages[chat_id] = await types.Message._parse(self, message, users, chats) + dialogs = [] + for dialog in r.dialogs: + if not isinstance(dialog, raw.types.Dialog): + continue + dialogs.append(Dialog._parse(self, dialog, messages, users, chats)) + if not dialogs: + return + last = dialogs[-1] + offset_id = last.top_message.id + offset_date = utils.datetime_to_timestamp(last.top_message.date) + offset_peer = await self.resolve_peer(last.chat.id) + for dialog in dialogs: + yield dialog + current += 1 + if current >= total: + return + + +@Client.on_message(filters.command('admlist', prefix) & filters.me) +async def admlist(client: Client, message: types.Message): + await message.edit("Retrieving information... (it'll take some time)") + + start = perf_counter() + try: + adminned_chats = [] + owned_chats = [] + owned_usernamed_chats = [] + async for dialog in get_dialogs(client): + chat = dialog.chat + if getattr(chat, 'deactivated', False): + continue + if getattr(chat, 'is_creator', False) and getattr(chat, 'username', None): + owned_usernamed_chats.append(chat) + elif getattr(chat, 'is_creator', False): + owned_chats.append(chat) + elif getattr(chat, 'is_admin', False): + adminned_chats.append(chat) + + text = 'Adminned chats:\n' + for index, chat in enumerate(adminned_chats): + cid = str(chat.id).replace('-100', '') + text += f'{index + 1}. {chat.title}\n' + + text += '\nOwned chats:\n' + for index, chat in enumerate(owned_chats): + cid = str(chat.id).replace('-100', '') + text += f'{index + 1}. {chat.title}\n' + + text += '\nOwned chats with username:\n' + for index, chat in enumerate(owned_usernamed_chats): + cid = str(chat.id).replace('-100', '') + text += f'{index + 1}. {chat.title}\n' + + stop = perf_counter() + total_count = len(adminned_chats) + len(owned_chats) + len(owned_usernamed_chats) + await message.edit( + text + '\n' + f'Total: {total_count}' + f'\nAdminned chats: {len(adminned_chats)}\n' + f'Owned chats: {len(owned_chats)}\n' + f'Owned chats with username: {len(owned_usernamed_chats)}\n\n' + f'Done in {round(stop - start, 3)} seconds.', + ) + except Exception as e: + await message.edit(format_exc(e)) + return + + +@Client.on_message(filters.command('admcount', prefix) & filters.me) +async def admcount(client: Client, message: types.Message): + await message.edit("Retrieving information... (it'll take some time)") + + start = perf_counter() + try: + adminned_chats = 0 + owned_chats = 0 + owned_usernamed_chats = 0 + async for dialog in get_dialogs(client): + chat = dialog.chat + if getattr(chat, 'deactivated', False): + continue + if getattr(chat, 'is_creator', False) and getattr(chat, 'username', None): + owned_usernamed_chats += 1 + elif getattr(chat, 'is_creator', False): + owned_chats += 1 + elif getattr(chat, 'is_admin', False): + adminned_chats += 1 + + stop = perf_counter() + total_count = adminned_chats + owned_chats + owned_usernamed_chats + await message.edit( + f'Total: {total_count}' + f'\nAdminned chats: {adminned_chats}\n' + f'Owned chats: {owned_chats}\n' + f'Owned chats with username: {owned_usernamed_chats}\n\n' + f'Done in {round(stop - start, 3)} seconds.\n\n' + f'Get full list: {prefix}admlist', + ) + except Exception as e: + await message.edit(format_exc(e)) + return + + +modules_help['admlist'] = { + 'admcount': 'Get count of adminned and owned chats', + 'admlist': 'Get list of adminned and owned chats', +} diff --git a/modules/afk.py b/modules/afk.py new file mode 100644 index 0000000..536a69d --- /dev/null +++ b/modules/afk.py @@ -0,0 +1,75 @@ +# Dragon-Userbot - telegram userbot +# Copyright (C) 2020-present Dragon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime + +from pyrogram import Client, filters, types +from utils.db import db +from utils.misc import modules_help, prefix + +# avoid using global variables +afk_info = db.get( + 'core.afk', + 'afk_info', + { + 'start': 0, + 'is_afk': False, + 'reason': '', + }, +) + +is_afk = filters.create(lambda _, __, ___: afk_info['is_afk']) +is_support = filters.create(lambda _, __, message: message.chat.is_support) + + +@Client.on_message( + is_afk & (filters.private | filters.mentioned) & ~filters.channel & ~filters.me & ~filters.bot & ~is_support +) +async def afk_handler(_, message: types.Message): + start = datetime.datetime.fromtimestamp(afk_info['start']) + end = datetime.datetime.now().replace(microsecond=0) + afk_time = end - start + await message.reply(f"I'm AFK {afk_time}\nReason: {afk_info['reason']}") + + +@Client.on_message(filters.command('afk', prefix) & filters.me) +async def afk(_, message): + reason = message.text.split(' ', maxsplit=1)[1] if len(message.text.split()) >= 2 else 'None' + + afk_info['start'] = int(datetime.datetime.now().timestamp()) + afk_info['is_afk'] = True + afk_info['reason'] = reason + + await message.edit(f"I'm going AFK.\nReason: {reason}") + + db.set('core.afk', 'afk_info', afk_info) + + +@Client.on_message(filters.command('unafk', prefix) & filters.me) +async def unafk(_, message): + if afk_info['is_afk']: + start = datetime.datetime.fromtimestamp(afk_info['start']) + end = datetime.datetime.now().replace(microsecond=0) + afk_time = end - start + await message.edit(f"I'm not AFK anymore.\nI was afk {afk_time}") + afk_info['is_afk'] = False + else: + await message.edit("You weren't afk") + + db.set('core.afk', 'afk_info', afk_info) + + +modules_help['afk'] = {'afk [reason]': 'Go to afk', 'unafk': 'Get out of AFK'} diff --git a/modules/aimage.py b/modules/aimage.py new file mode 100644 index 0000000..9c0678d --- /dev/null +++ b/modules/aimage.py @@ -0,0 +1,116 @@ +import os + +from PIL import Image +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, import_library + +genai = import_library('google.generativeai', 'google-generativeai') + +from utils.config import gemini_key # noqa: E402 + +genai.configure(api_key=gemini_key) + +generation_config_cook = { + 'temperature': 0.35, + 'top_p': 0.95, + 'top_k': 40, + 'max_output_tokens': 1024, +} + +model = genai.GenerativeModel('gemini-1.5-flash-latest') +model_cook = genai.GenerativeModel(model_name='gemini-1.5-flash-latest', generation_config=generation_config_cook) + + +@Client.on_message(filters.command('getai', prefix) & filters.me) +async def getai(_, message: Message): + try: + await message.edit_text('Please Wait...') + try: + base_img = await message.reply_to_message.download() + except AttributeError: + return await message.edit_text('Please reply to an image...') + + img = Image.open(base_img) + prompt = 'Get details of given image, be as accurate as possible.' + + response = model.generate_content([prompt, img]) + + await message.edit_text(f'**Detail Of Image:** {response.text}', parse_mode=enums.ParseMode.MARKDOWN) + os.remove(base_img) + return + except Exception as e: + await message.edit_text(f'An error occurred: {format_exc(e)}') + + +@Client.on_message(filters.command('aicook', prefix) & filters.me) +async def aicook(_, message: Message): + if message.reply_to_message: + try: + await message.edit_text('Cooking...') + + try: + base_img = await message.reply_to_message.download() + except AttributeError: + return await message.edit_text('Please reply to an image...') + + img = Image.open(base_img) + cook_img = [ + 'Accurately identify the baked good in the image and provide an appropriate and recipe consistent with your analysis. ', + img, + ] + + response = model_cook.generate_content(cook_img) + + await message.edit_text(f'{response.text}', parse_mode=enums.ParseMode.MARKDOWN) + os.remove(base_img) + return + except Exception as e: + await message.edit_text(str(e)) + return await message.edit_text('Please reply to an image...') + + +@Client.on_message(filters.command('aiseller', prefix) & filters.me) +async def aiseller(_, message: Message): + if message.reply_to_message: + try: + await message.edit_text('Generating...') + if len(message.command) > 1: + taud = message.text.split(maxsplit=1)[1] + else: + return await message.edit_text( + f'Usage: {prefix}aiseller [target audience] [reply to product image]' + ) + + try: + base_img = await message.reply_to_message.download() + except AttributeError: + return await message.edit_text('Please reply to an image...') + + img = Image.open(base_img) + sell_img = [ + 'Given an image of a product and its target audience, write an engaging marketing description', + 'Product Image: ', + img, + 'Target Audience: ', + taud, + ] + + response = model.generate_content(sell_img) + + await message.edit_text(f'{response.text}', parse_mode=enums.ParseMode.MARKDOWN) + os.remove(base_img) + return + except Exception: + await message.edit_text( + f'Usage: {prefix}aiseller [target audience] [reply to product image]' + ) + return await message.edit_text('Please reply to an image...') + + +modules_help['aimage'] = { + 'getai [reply to image]*': 'Get details of image with Ai', + 'aicook [reply to image]*': 'Generate Cooking instrunctions of the given food image', + 'aiseller [target audience] [reply to product image]*': 'Generate a promotional message for the given image product for the given target audience', +} diff --git a/modules/amogus.py b/modules/amogus.py new file mode 100644 index 0000000..d986926 --- /dev/null +++ b/modules/amogus.py @@ -0,0 +1,50 @@ +from io import BytesIO +from random import randint +from textwrap import wrap + +from PIL import Image, ImageDraw, ImageFont +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from requests import get +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('amogus', prefix) & filters.me) +async def amogus(client: Client, message: Message): + text = ' '.join(message.command[1:]) + + await message.edit( + 'amgus, tun tun tun tun tun tun tun tudududn tun tun...', + parse_mode=enums.ParseMode.HTML, + ) + + clr = randint(1, 12) # noqa: S311 + + url = 'https://raw.githubusercontent.com/The-MoonTg-project/AmongUs/master/' + font = ImageFont.truetype(BytesIO(get(url + 'bold.ttf', timeout=10).content), 60) + imposter = Image.open(BytesIO(get(f'{url}{clr}.png', timeout=10).content)) + + text_ = '\n'.join(['\n'.join(wrap(part, 30)) for part in text.split('\n')]) + bbox = ImageDraw.Draw(Image.new('RGB', (1, 1))).multiline_textbbox((0, 0), text_, font, stroke_width=2) + # w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + w, h = bbox[2], bbox[3] + text = Image.new('RGBA', (w + 30, h + 30)) + ImageDraw.Draw(text).multiline_text((15, 15), text_, '#FFF', font, stroke_width=2, stroke_fill='#000') + w = imposter.width + text.width + 10 + h = max(imposter.height, text.height) + + image = Image.new('RGBA', (w, h)) + image.paste(imposter, (0, h - imposter.height), imposter) + image.paste(text, (w - text.width, 0), text) + image.thumbnail((512, 512)) + + output = BytesIO() + output.name = 'imposter.webp' + image.save(output) + output.seek(0) + + await message.delete() + await client.send_sticker(message.chat.id, output) + + +modules_help['amogus'] = {'amogus [text]': 'amgus, tun tun tun tun tun tun tun tudududn tun tun'} diff --git a/modules/amongus.py b/modules/amongus.py new file mode 100644 index 0000000..95851ed --- /dev/null +++ b/modules/amongus.py @@ -0,0 +1,195 @@ +# From CatUB +import asyncio +import re +from io import BytesIO +from random import choice, randint +from textwrap import wrap + +import requests +from PIL import Image, ImageDraw, ImageFont +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import edit_or_reply +from utils.scripts import reply_check as ReplyCheck + + +async def amongus_gen(text: str, clr: int) -> str: + url = 'https://github.com/TgCatUB/CatUserbot-Resources/raw/master/Resources/Amongus/' + font = ImageFont.truetype( + BytesIO( + requests.get('https://github.com/TgCatUB/CatUserbot-Resources/raw/master/Resources/fonts/bold.ttf', timeout=10).content + ), + 60, + ) + imposter = Image.open(BytesIO(requests.get(f'{url}{clr}.png', timeout=10).content)) + text_ = '\n'.join('\n'.join(wrap(part, 30)) for part in text.split('\n')) + bbox = ImageDraw.Draw(Image.new('RGB', (1, 1))).multiline_textbbox((0, 0), text_, font, stroke_width=2) + w, h = bbox[2], bbox[3] + text = Image.new('RGBA', (w + 30, h + 30)) + ImageDraw.Draw(text).multiline_text((15, 15), text_, '#FFF', font, stroke_width=2, stroke_fill='#000') + w = imposter.width + text.width + 10 + h = max(imposter.height, text.height) + image = Image.new('RGBA', (w, h)) + image.paste(imposter, (0, h - imposter.height), imposter) + image.paste(text, (w - text.width, 0), text) + image.thumbnail((512, 512)) + output = BytesIO() + output.name = 'imposter.webp' + image.save(output, 'WebP') + output.seek(0) + return output + + +async def get_imposter_img(text: str) -> BytesIO: + background = requests.get( + f'https://github.com/TgCatUB/CatUserbot-Resources/raw/master/Resources/imposter/impostor{randint(1, 22)}.png', # noqa: S311 + timeout=10, + ).content + font = requests.get( + 'https://github.com/TgCatUB/CatUserbot-Resources/raw/master/Resources/fonts/roboto_regular.ttf', + timeout=10, + ).content + font = BytesIO(font) + font = ImageFont.truetype(font, 30) + image = Image.new('RGBA', (1, 1), (0, 0, 0, 0)) + draw = ImageDraw.Draw(image) + bbox = ImageDraw.Draw(Image.new('RGB', (1, 1))).multiline_textbbox((0, 0), text, font, stroke_width=2) + w, h = bbox[2], bbox[3] + image = Image.open(BytesIO(background)) + x, y = image.size + draw = ImageDraw.Draw(image) + draw.multiline_text(((x - w) // 2, (y - h) // 2), text=text, font=font, fill='white', align='center') + output = BytesIO() + output.name = 'impostor.png' + image.save(output, 'PNG') + output.seek(0) + return output + + +@Client.on_message(filters.command('amongus', prefix) & filters.me) +async def amongus_cmd(client: Client, message: Message): + text = ' '.join(message.command[1:]) if len(message.command) > 1 else '' + reply = message.reply_to_message + await message.edit('tun tun tun...') + if not text and reply: + text = reply.text or reply.caption or '' + + clr = re.findall(r'-c\d+', text) + try: + clr = clr[0] + clr = clr.replace('-c', '') + text = text.replace(f'-c{clr}', '') + clr = int(clr) + if clr > 12 or clr < 1: + clr = randint(1, 12) # noqa: S311 + except IndexError: + clr = randint(1, 12) # noqa: S311 + + if not text: + if not reply: + text = f'{message.from_user.first_name} Was a traitor!' + else: + text = f'{reply.from_user.first_name} Was a traitor!' + + imposter_file = await amongus_gen(text, clr) + await message.delete() + await client.send_sticker( + message.chat.id, + imposter_file, + reply_to_message_id=ReplyCheck(message), + ) + + +@Client.on_message(filters.command('imposter', prefix) & filters.me) +async def imposter_cmd(client: Client, message: Message): + remain = randint(1, 2) # noqa: S311 + imps = ["wasn't the impostor", 'was the impostor'] + + if message.reply_to_message: + user = message.reply_to_message.from_user + text = f'{user.first_name} {choice(imps)}.' # noqa: S311 + else: + args = message.text.split()[1:] + text = ' '.join(args) if args else f'{message.from_user.first_name} {choice(imps)}.' # noqa: S311 + + text += f'\n{remain} impostor(s) remain.' + imposter_file = await get_imposter_img(text) + await message.delete() + await client.send_photo( + message.chat.id, + imposter_file, + reply_to_message_id=ReplyCheck(message), + ) + + +@Client.on_message(filters.command(['imp', 'impn'], prefix) & filters.me) +async def imp_animation(client: Client, message: Message): + name = ' '.join(message.command[1:]) if len(message.command) > 1 else '' + if not name: + reply = message.reply_to_message + name = reply.from_user.first_name if reply else message.from_user.first_name + cmd = message.command[0].lower() + + text1 = await edit_or_reply(message, 'Uhmm... Something is wrong here!!') + await asyncio.sleep(2) + await text1.delete() + + stcr1 = await client.send_sticker(message.chat.id, 'CAADAQADRwADnjOcH98isYD5RJTwAg') + text2 = await message.reply(f'{message.from_user.first_name}: I have to call discussion') + await asyncio.sleep(3) + await stcr1.delete() + await text2.delete() + + stcr2 = await client.send_sticker(message.chat.id, 'CAADAQADRgADnjOcH9odHIXtfgmvAg') + text3 = await message.reply(f'{message.from_user.first_name}: We have to eject the imposter or will lose') + await asyncio.sleep(3) + await stcr2.delete() + await text3.delete() + + stcr3 = await client.send_sticker(message.chat.id, 'CAADAQADOwADnjOcH77v3Ap51R7gAg') + text4 = await message.reply('Others: Where???') + await asyncio.sleep(2) + await text4.edit('Others: Who??') + await asyncio.sleep(2) + await text4.edit(f'{message.from_user.first_name}: Its {name}, I saw {name} using vent') + await asyncio.sleep(3) + await text4.edit(f'Others: Okay.. Vote {name}') + await asyncio.sleep(2) + await stcr3.delete() + await text4.delete() + + stcr4 = await client.send_sticker(message.chat.id, 'CAADAQADLwADnjOcH-wxu-ehy6NRAg') + event = await message.reply(f'{name} is ejected.......') + + # Ejection animation + for _ in range(9): + await asyncio.sleep(0.5) + curr_pos = _ + 1 + spaces_before = 'ㅤ' * curr_pos + await event.edit(f'{spaces_before}ඞ{"ㅤ" * (9 - curr_pos)}') + + await asyncio.sleep(0.5) + await event.edit('ㅤㅤㅤㅤㅤㅤㅤㅤㅤ') + await asyncio.sleep(0.2) + await stcr4.delete() + + if cmd == 'imp': + text = f".    。    •   ゚  。   .\n .      .     。   。 .  \n\n .    。   ඞ 。 .    •     •\n\n ゚{name} was an Imposter. 。 .     。 . 。 . \n  . 。   . \n ' 0 Impostor remains   。 .   . 。 . 。   . 。   . . . , 。\n  ゚   .  . ,   。   .   . 。" + sticker_id = 'CAADAQADLQADnjOcH39IqwyR6Q_0Ag' + else: + text = f".    。    •   ゚  。   .\n .      .     。   。 .  \n\n .    。   ඞ 。 .    •     •\n\n ゚{name} was not an Imposter. 。 .     。 . 。 . \n  . 。   . \n ' 1 Impostor remains   。 .   . 。 . 。   . 。   . . . , 。\n  ゚   .  . ,   。   .   . 。" + sticker_id = 'CAADAQADQAADnjOcH-WOkB8DEctJAg' + + await event.edit(text) + await asyncio.sleep(4) + await event.delete() + await client.send_sticker(message.chat.id, sticker_id) + + +modules_help['amongus'] = { + 'amongus': 'Create Among Us themed sticker [text/reply] [-c1 to -c12 for colors]', + 'imposter': 'Create Among Us imposter image [username/reply]', + 'imp': 'Create Among Us ejection animation (imposter)', + 'impn': 'Create Among Us ejection animation (not imposter)', +} diff --git a/modules/animations.py b/modules/animations.py new file mode 100644 index 0000000..9fdafce --- /dev/null +++ b/modules/animations.py @@ -0,0 +1,465 @@ +import asyncio +from collections import deque + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +mention = 'Moon' + + +@Client.on_message(filters.command('stupid', prefix) & filters.me) +async def stupid(_, message: Message): + animation_interval = 0.5 + animation_ttl = range(0, 14) + await message.edit_text('stupid boy') + animation_chars = [ + 'YOUR BRAIN ➡️ 🧠\n\n🧠 (^_^)🗑', + 'YOUR BRAIN ➡️ 🧠\n\n🧠 (^_^) 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n🧠 (^_^) 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n🧠 (^_^) 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n🧠 (^_^) 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n🧠< (^_^ <) 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n(> ^_^)>🧠 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n (> ^_^)>🧠 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n (> ^_^)>🧠 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n (> ^_^)>🧠 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n (> ^_^)>🧠 🗑', + 'YOUR BRAIN ➡️ 🧠\n\n (> ^_^)>🧠🗑', + 'YOUR BRAIN ➡️ 🧠\n\n (> ^_^)>🗑', + 'YOUR BRAIN ➡️ 🧠\n\n < (^_^ <)🗑', + ] + + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 14], parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command('bombs', prefix) & filters.me) +async def bombs(_, message: Message): + + await message.edit_text('▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n') + await asyncio.sleep(0.5) + await message.edit_text('💣💣💣💣 \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n') + await asyncio.sleep(0.5) + await message.edit_text('▪️▪️▪️▪️ \n💣💣💣💣 \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n') + await asyncio.sleep(0.5) + await message.edit_text('▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n💣💣💣💣 \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n') + await asyncio.sleep(0.5) + await message.edit_text('▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n💣💣💣💣 \n▪️▪️▪️▪️ \n') + await asyncio.sleep(0.5) + await message.edit_text('▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n💣💣💣💣 \n') + await asyncio.sleep(1) + await message.edit_text('▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n💥💥💥💥 \n') + await asyncio.sleep(0.5) + await message.edit_text('▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n💥💥💥💥 \n💥💥💥💥 \n') + await asyncio.sleep(0.5) + await message.edit_text('▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n▪️▪️▪️▪️ \n😵😵😵😵 \n') + await asyncio.sleep(0.5) + await message.edit_text('RIP PLOXXX......') + await asyncio.sleep(2) + + +@Client.on_message(filters.command('call', prefix) & filters.me) +async def cell(_, message: Message): + + animation_interval = 3 + animation_ttl = range(0, 18) + await message.edit_text('Calling Pavel Durov (ceo of telegram)......') + animation_chars = [ + 'Connecting To Telegram Headquarters...', + 'Call Connected.', + 'Telegram: Hello This is Telegram HQ. Who is this?', + 'Me: Yo this is Moon ,Please Connect me to my lil bro,Pavel Durov ', + 'User Authorised.', + 'Calling Shivamani At +916969696969', + 'Private Call Connected...', + 'Me: Hello Sir, Please Ban This Telegram Account.', + 'Shivamani : May I Know Who Is This?', + 'Me: Yo Brah, I Am Moon', + "Shivamani : OMG!!! Long time no see, Wassup cat...\nI'll Make Sure That Guy Account Will Get Blocked Within 24Hrs.", + 'Me: Thanks, See You Later Brah.', + "Shivamani : Please Don't Thank Brah, Telegram Is Our's. Just Gimme A Call When You Become Free.", + 'Me: Is There Any Issue/Emergency???', + 'Shivamani : Yes Sur, There Is A Bug In Telegram v69.6.9.\nI Am Not Able To Fix It. If Possible, Please Help Fix The Bug.', + 'Me: Send Me The App On My Telegram Account, I Will Fix The Bug & Send You.', + 'Shivamani : Sure Sur \nTC Bye Bye :)', + 'Private Call Disconnected.', + ] + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 18]) + + +@Client.on_message(filters.command('wtf', prefix) & filters.me) +async def wtf(_, message: Message): + + animation_interval = 0.8 + animation_ttl = range(0, 5) + await message.edit_text('wtf') + animation_chars = [ + 'What', + 'What The', + 'What The F', + 'What The F Brah', + 'What The F Brah\nhttps://telegra.ph//file/f3b760e4a99340d331f9b.jpg', + ] + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 5], parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command('ding', prefix) & filters.me) +async def ding(_, message: Message): + animation_interval = 0.3 + animation_ttl = range(0, 30) + animation_chars = [ + '🔴⬛⬛⬜⬜\n⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜', + '⬜⬜⬛⬜⬜\n⬜⬛⬜⬜⬜\n🔴⬜⬜⬜⬜', + '⬜⬜⬛⬜⬜\n⬜⬜⬛⬜⬜\n⬜⬜🔴⬜⬜', + '⬜⬜⬛⬜⬜\n⬜⬜⬜⬛⬜\n⬜⬜⬜⬜🔴', + '⬜⬜⬛⬛🔴\n⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜', + '⬜⬜⬛⬜⬜\n⬜⬜⬜⬛⬜\n⬜⬜⬜⬜🔴', + '⬜⬜⬛⬜⬜\n⬜⬜⬛⬜⬜\n⬜⬜🔴⬜⬜', + '⬜⬜⬛⬜⬜\n⬜⬛⬜⬜⬜\n🔴⬜⬜⬜⬜', + '🔴⬛⬛⬜⬜\n⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜', + '⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜\n⬜ Moon IS BEST ⬜\n⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜', + ] + + await message.edit_text('ding..dong..ding..dong ...') + await asyncio.sleep(4) + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 10], parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command('hypo', prefix) & filters.me) +async def hypo(_, message: Message): + animation_interval = 0.3 + animation_ttl = range(0, 15) + await message.edit_text('hypo....') + animation_chars = [ + '⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜', + '⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬛⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜', + '⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬛⬛⬛⬜⬜\n⬜⬜⬛⬜⬛⬜⬜\n⬜⬜⬛⬛⬛⬜⬜\n⬜⬜⬜⬜⬜⬜⬜\n⬜⬜⬜⬜⬜⬜⬜', + '⬜⬜⬜⬜⬜⬜⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬜⬜⬜⬜⬜⬜', + '⬛⬛⬛⬛⬛⬛⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬛⬛⬛⬛⬛⬛', + '⬛⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜⬛', + '⬜⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛⬜', + '⬜⬜⬜⬜⬜⬜⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬜⬜⬜⬜⬜⬜', + '⬛⬛⬛⬛⬛⬛⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬜⬛⬛⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬛⬛⬜⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬛⬛⬛⬛⬛⬛', + '⬜⬜⬜⬜⬜⬜⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬜⬜⬜⬜⬜⬜', + '⬛⬛⬛⬛⬛⬛⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬜⬛⬛⬛⬜⬛\n⬛⬜⬛⬜⬛⬜⬛\n⬛⬜⬛⬛⬛⬜⬛\n⬛⬜⬜⬜⬜⬜⬛\n⬛⬛⬛⬛⬛⬛⬛', + '⬜⬜⬜⬜⬜⬜⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬜⬛⬜⬛⬜\n⬜⬛⬜⬜⬜⬛⬜\n⬜⬛⬛⬛⬛⬛⬜\n⬜⬜⬜⬜⬜⬜⬜', + '⬛⬛⬛⬛⬛\n⬛⬜⬜⬜⬛\n⬛⬜⬛⬜⬛\n⬛⬜⬜⬜⬛\n⬛⬛⬛⬛⬛', + '⬜⬜⬜\n⬜⬛⬜\n⬜⬜⬜', + '[👉🔴👈])', + ] + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 15]) + + +@Client.on_message(filters.command('gangster', prefix) & filters.me) +async def gangster(_, message: Message): + await message.edit_text('EVERyBOdy') + await asyncio.sleep(0.3) + await message.edit_text('iZ') + await asyncio.sleep(0.2) + await message.edit_text('GangSTur') + await asyncio.sleep(0.5) + await message.edit_text('UNtIL ') + await asyncio.sleep(0.2) + await message.edit_text('I') + await asyncio.sleep(0.3) + await message.edit_text('ArRivE') + await asyncio.sleep(0.3) + await message.edit_text('🔥🔥🔥') + await asyncio.sleep(0.3) + await message.edit_text('EVERyBOdy iZ GangSTur UNtIL I ArRivE 🔥🔥🔥') + + +@Client.on_message(filters.command('charge', prefix) & filters.me) +async def timer_blankx(_, message: Message): + txt = ( + message.text[10:] + + '\n\nTesla Wireless Charging (beta) Started...\nDevice Detected: Apple iPad 13\nBattery Percentage: ' + ) + j = 10 + k = j + for _j in range(j): + await message.edit_text(txt + str(k), parse_mode=enums.ParseMode.HTML) + k = k + 10 + await asyncio.sleep(1) + await asyncio.sleep(1) + await message.edit_text( + 'Tesla Wireless Charging (beta) Completed...\nDevice Detected: Apple iPad 13 (Space Grey Varient)\nBattery Percentage: [100%](https://telegra.ph/file/a45aa7450c8eefed599d9.mp4) ', + link_preview=True, + parse_mode=enums.ParseMode.HTML, + ) + + +@Client.on_message(filters.command('kill', prefix) & filters.me) +async def kill(_, message: Message): + animation_interval = 0.3 + animation_ttl = range(0, 103) + animation_chars = [ + 'Fiiiiire', + '( ・ิω・ิ)︻デ═一-->', + '---->____________⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠', + '------>__________⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠', + '-------->⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠_________', + '---------->⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠_______', + '------------>⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠_____', + '-------------->⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠⁠____', + '------------------>', + '------>;(^。^)ノ', + '( ̄ー ̄) DED', + 'Target killed successfully (°̥̥̥̥̥̥̥̥•̀.̫•́°̥̥̥̥̥̥̥)', + ] + + await message.edit_text("You're goonnaa diieeeee!") + await asyncio.sleep(3) + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 103], parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command('earth', prefix) & filters.me) +async def earth(_, message: Message): + deq = deque(list('🌏🌍🌎🌎🌍🌏🌍🌎')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('think', prefix) & filters.me) +async def think(_, message: Message): + deq = deque(list('🤔🧐🤔🧐🤔🧐')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('lmao', prefix) & filters.me) +async def lmao(_, message: Message): + deq = deque(list('😂🤣😂🤣😂🤣')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('clock', prefix) & filters.me) +async def clock(_, message: Message): + deq = deque(list('🕙🕘🕗🕖🕕🕔🕓🕒🕑🕐🕛')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('heart', prefix) & filters.me) +async def heart(_, message: Message): + deq = deque(list('❤️🧡💛💚💙💜🖤')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('gym', prefix) & filters.me) +async def gym(_, message: Message): + deq = deque(list('🏃‍🏋‍🤸‍🏃‍🏋‍🤸‍🏃‍🏋‍🤸‍')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('moon', prefix) & filters.me) +async def moon(_, message: Message): + deq = deque(list('🌗🌘🌑🌒🌓🌔🌕🌖‍')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('stars', prefix) & filters.me) +async def stars(_, message: Message): + deq = deque(list('🦋✨🦋✨🦋✨🦋✨‍')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('boxs', prefix) & filters.me) +async def boxs(_, message: Message): + deq = deque(list('🟥🟧🟨🟩🟦🟪🟫⬛⬜')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('rain', prefix) & filters.me) +async def rain(_, message: Message): + deq = deque(list('🌬☁️🌩🌨🌧🌦🌥⛅🌤')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('bird', prefix) & filters.me) +async def bird(_, message: Message): + deq = deque(list('𓅰𓅬𓅭𓅮𓅯')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('fish', prefix) & filters.me) +async def fish(_, message: Message): + deq = deque(list('𓆝𓆟𓆞𓆝𓆟')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('goat', prefix) & filters.me) +async def goat(_, message: Message): + deq = deque(list('𓃖𓃗𓃘𓃙𓃚𓃛𓃜')) + for _ in range(48): + await asyncio.sleep(0.1) + await message.edit_text(''.join(deq)) + deq.rotate(1) + + +@Client.on_message(filters.command('smoon', prefix) & filters.me) +async def smoon(_, message: Message): + animation_interval = 0.2 + animation_ttl = range(101) + animation_chars = [ + '🌗🌗🌗🌗🌗\n🌓🌓🌓🌓🌓\n🌗🌗🌗🌗🌗\n🌓🌓🌓🌓🌓\n🌗🌗🌗🌗🌗', + '🌘🌘🌘🌘🌘\n🌔🌔🌔🌔🌔\n🌘🌘🌘🌘🌘\n🌔🌔🌔🌔🌔\n🌘🌘🌘🌘🌘', + '🌑🌑🌑🌑🌑\n🌕🌕🌕🌕🌕\n🌑🌑🌑🌑🌑\n🌕🌕🌕🌕🌕\n🌑🌑🌑🌑🌑', + '🌒🌒🌒🌒🌒\n🌖🌖🌖🌖🌖\n🌒🌒🌒🌒🌒\n🌖🌖🌖🌖🌖\n🌒🌒🌒🌒🌒', + '🌓🌓🌓🌓🌓\n🌗🌗🌗🌗🌗\n🌓🌓🌓🌓🌓\n🌗🌗🌗🌗🌗\n🌓🌓🌓🌓🌓', + '🌔🌔🌔🌔🌔\n🌘🌘🌘🌘🌘\n🌔🌔🌔🌔🌔\n🌘🌘🌘🌘🌘\n🌔🌔🌔🌔🌔', + '🌕🌕🌕🌕🌕\n🌑🌑🌑🌑🌑\n🌕🌕🌕🌕🌕\n🌑🌑🌑🌑🌑\n🌕🌕🌕🌕🌕', + '🌖🌖🌖🌖🌖\n🌒🌒🌒🌒🌒\n🌖🌖🌖🌖🌖\n🌒🌒🌒🌒🌒\n🌖🌖🌖🌖🌖', + ] + + await message.edit_text('smoon...') + await asyncio.sleep(3) + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 8]) + + +@Client.on_message(filters.command('deploy', prefix) & filters.me) +async def deploy(_, message: Message): + animation_interval = 3 + animation_ttl = range(101) + animation_chars = [ + 'Heroku Connecting To Latest Github Build ', + f'Build started by user {mention}', + f'Deploy 535a74f0 by user {mention}', + 'Restarting Heroku Server...', + 'State changed from up to starting', + 'Stopping all processes with SIGTERM', + 'Process exited with status 143', + 'Starting process with command python3 -m userbot', + 'State changed from starting to up', + 'INFO:Userbot:Logged in as 557667062', + 'INFO:Userbot:Successfully loaded all plugins', + 'Build Succeeded', + ] + + await message.edit_text('Deploying...', parse_mode=enums.ParseMode.HTML) + await asyncio.sleep(3) + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 12], parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command('tmoon', prefix) & filters.me) +async def tmoon(_, message: Message): + animation_interval = 0.2 + animation_ttl = range(96) + animation_chars = [ + '🌗', + '🌘', + '🌑', + '🌒', + '🌓', + '🌔', + '🌕', + '🌖', + '🌗', + '🌘', + '🌑', + '🌒', + '🌓', + '🌔', + '🌕', + '🌖', + '🌗', + '🌘', + '🌑', + '🌒', + '🌓', + '🌔', + '🌕', + '🌖', + '🌗', + '🌘', + '🌑', + '🌒', + '🌓', + '🌔', + '🌕', + '🌖', + ] + + await message.edit_text('tmoon...') + await asyncio.sleep(3) + for i in animation_ttl: + await asyncio.sleep(animation_interval) + await message.edit_text(animation_chars[i % 32]) + + +modules_help['animations'] = { + 'stupid': ' stupid animation', + 'gangster': 'YO! Gangster animation', + 'hypo': 'checkout yourself ~_+', + 'charge': 'Apple iPad charging animation', + 'ding': 'ding.dong.ding animation', + 'wtf': 'wtf animation must use if you want tell wtf', + 'call': 'call to durov', + 'bombs': 'bombing animation', + 'kill': 'fireee animation = target killed ~_- ', + 'deploy': 'Fake heroku deploy animation', + 'gym': 'Fun animation try yourself to know more', + 'boxs': 'Fun animation try yourself to know more', + 'stars': 'Fun animation try yourself to know more', + 'goat': 'Fun animation try yourself to know more', + 'bird': 'Fun animation try yourself to know more', + 'rain': 'Fun animation try yourself to know more', + 'fish': 'Fun animation try yourself to know more', + 'heart': 'Fun animation try yourself to know more', + 'clock': 'Fun animation try yourself to know more', + 'lmao': 'Fun animation try yourself to know more', + 'think': 'Fun animation try yourself to know more', + 'earth': 'Fun animation try yourself to know more', + 'moon': 'Fun animation try yourself to know more', + 'smoon': 'Fun animation try yourself to know more', + 'tmoon': 'Fun animation try yourself to know more', +} diff --git a/modules/anime/anilist.py b/modules/anime/anilist.py new file mode 100644 index 0000000..b0ea92a --- /dev/null +++ b/modules/anime/anilist.py @@ -0,0 +1,230 @@ +import os + +import aiofiles +import requests +from pyrogram import Client, enums, filters +from pyrogram.errors import MediaCaptionTooLong +from pyrogram.types import InputMediaPhoto, Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + +url = 'https://api.safone.co' + +headers = { + 'Accept-Language': 'en-US,en;q=0.9', + 'Connection': 'keep-alive', + 'DNT': '1', + 'Referer': 'https://api.safone.co/docs', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-origin', + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'accept': 'application/json', + 'sec-ch-ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Linux"', +} + + +@Client.on_message(filters.command('anime_search', prefix) & filters.me) +async def anime_search(client: Client, message: Message): + try: + chat_id = message.chat.id + await message.edit_text('Processing...') + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + message.edit_text("What should i search? You didn't provided me with any value to search") + + response = requests.get(url=f'{url}/anime/search?query={query}', headers=headers, timeout=5) + if response.status_code != 200: + await message.edit_text('Something went wrong') + return + + result = response.json() + + averageScore = result['averageScore'] + try: + coverImage_url = result['imageUrl'] + coverImage = requests.get(url=coverImage_url, timeout=10).content + async with aiofiles.open('coverImage.jpg', mode='wb') as f: + await f.write(coverImage) + + except Exception: + coverImage = None + + title = result['title']['english'] + trailer = result['trailer']['id'] + description = result['description'] + episodes = result['episodes'] + genres = ', '.join(result['genres']) + isAdult = result['isAdult'] + status = result['status'] + studios = ', '.join(result['studios']) + + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f"Title: {title}\nAverage Score: {averageScore}\nStatus: {status}\nGenres: {genres}\nEpisodes: {episodes}\nIs Adult: {isAdult}\nStudios: {studios}\nDescription: {description}\nTrailer: Click Here", + ) + ], + ) + + except MediaCaptionTooLong: + description = description[:850] + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f"Title: {title}\nAverage Score: {averageScore}\nStatus: {status}\nGenres: {genres}\nEpisodes: {episodes}\nIs Adult: {isAdult}\nStudios: {studios}\nDescription: {description}\nTrailer: Click Here", + ) + ], + ) + except Exception as e: + await message.edit_text(format_exc(e)) + finally: + if os.path.exists('coverImage.jpg'): + os.remove('coverImage.jpg') + + +@Client.on_message(filters.command('manga_search', prefix) & filters.me) +async def manga_search(client: Client, message: Message): + try: + chat_id = message.chat.id + await message.edit_text('Processing...') + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + message.edit_text("What should i search? You didn't provided me with any value to search") + + response = requests.get(url=f'{url}/anime/manga?query={query}', headers=headers, timeout=5) + if response.status_code != 200: + await message.edit_text('Something went wrong') + return + + result = response.json() + + averageScore = result['averageScore'] + try: + coverImage_url = result['imageUrl'] + coverImage = requests.get(url=coverImage_url, timeout=10).content + async with aiofiles.open('coverImage.jpg', mode='wb') as f: + await f.write(coverImage) + + except Exception: + coverImage = None + + title = result['title']['english'] + trailer = result['trailer']['id'] + description = result['description'] + chapters = result['chapters'] + genres = ', '.join(result['genres']) + isAdult = result['isAdult'] + status = result['status'] + studios = ', '.join(result['studios']) + + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f"Title: {title}\nAverage Score: {averageScore}\nStatus: {status}\nGenres: {genres}\nChapters: {chapters}\nIs Adult: {isAdult}\nStudios: {studios}\nDescription: {description}\nTrailer: Click Here", + ) + ], + ) + + except MediaCaptionTooLong: + description = description[:850] + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f"Title: {title}\nAverage Score: {averageScore}\nStatus: {status}\nGenres: {genres}\nChapters: {chapters}\nIs Adult: {isAdult}\nStudios: {studios}\nDescription: {description}\nTrailer: Click Here", + ) + ], + ) + except Exception as e: + await message.edit_text(format_exc(e)) + finally: + if os.path.exists('coverImage.jpg'): + os.remove('coverImage.jpg') + + +@Client.on_message(filters.command('character', prefix) & filters.me) +async def character(client: Client, message: Message): + try: + chat_id = message.chat.id + await message.edit_text('Processing...') + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + message.edit_text("What should i search? You didn't provided me with any value to search") + + response = requests.get(url=f'{url}/anime/character?query={query}', headers=headers, timeout=5) + if response.status_code != 200: + await message.edit_text('Something went wrong') + return + + result = response.json() + + try: + coverImage_url = result['image']['large'] + coverImage = requests.get(url=coverImage_url, timeout=10).content + async with aiofiles.open('coverImage.jpg', mode='wb') as f: + await f.write(coverImage) + + except Exception: + coverImage = None + + age = result['age'] + description = result['description'] + height = result['height'] + name = result['name']['full'] + native_name = result['name']['native'] + read_more = result['siteUrl'] + + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f'**Name:** `{name}`\n**Native Name:** `{native_name}`\n**Age:** `{age}`\n**Height:** {height}\n**Description:** `{description}`[read more...]({read_more})', + parse_mode=enums.ParseMode.MARKDOWN, + ) + ], + ) + + except MediaCaptionTooLong: + description = description[:850] + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f'**Name:** `{name}`\n**Native Name:** `{native_name}`\n**Age:** `{age}`\n**Height:** {height}\n**Description:** `{description}`[read more...]({read_more})', + parse_mode=enums.ParseMode.MARKDOWN, + ) + ], + ) + except Exception as e: + await message.edit_text(format_exc(e)) + finally: + if os.path.exists('coverImage.jpg'): + os.remove('coverImage.jpg') + + +modules_help['anilist'] = { + 'anime_search': 'Search for anime on Anilist', + 'manga_search': 'Search for manga on Anilist', + 'character': 'Search for character on Anilist', +} diff --git a/modules/anime/anime.py b/modules/anime/anime.py new file mode 100644 index 0000000..0b6554a --- /dev/null +++ b/modules/anime/anime.py @@ -0,0 +1,70 @@ +from io import BytesIO + +from aiohttp import ClientSession +from pyrogram import Client, enums, filters +from pyrogram.types import Message + +# noinspection PyUnresolvedReferences +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + +session = ClientSession() + + +class Post: + def __init__(self, source: dict, session: ClientSession): + self._json = source + self.session = session + + @property + async def image(self): + return ( + self.file_url + if self.file_url + else ( + self.large_file_url + if self.large_file_url + else ( + self.source + if self.source and 'pximg' not in self.source + else await self.pximg + if self.source + else None + ) + ) + ) + + @property + async def pximg(self): + async with self.session.get(self.source) as response: + return BytesIO(await response.read()) + + def __getattr__(self, item): + return self._json.get(item) + + +async def random(): + async with session.get(url='https://danbooru.donmai.us/posts/random.json') as response: + return Post(await response.json(encoding='utf-8'), session) + + +@Client.on_message(filters.command(['arnd', 'arandom'], prefix) & filters.me) +async def anime_handler(client: Client, message: Message): + try: + await message.edit('Searching art', parse_mode=enums.ParseMode.HTML) + ra = await random() + img = await ra.image + await message.reply_photo( + photo=img, + caption=f'{ra.tag_string_general if ra.tag_string_general else "Untitled"}', + parse_mode=enums.ParseMode.HTML, + ) + return await message.delete() + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +modules_help['anime'] = { + 'arnd': 'Random anime art (May get caught 18+)', + 'arandom': 'Random anime art (May get caught 18+)', +} diff --git a/modules/anime/neko.py b/modules/anime/neko.py new file mode 100644 index 0000000..95593e1 --- /dev/null +++ b/modules/anime/neko.py @@ -0,0 +1,70 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio + +import requests +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + + +def get_neko_media(query): + return requests.get(f'https://nekos.life/api/v2/img/{query}', timeout=10).json()['url'] + + +@Client.on_message(filters.command('neko', prefix) & filters.me) +async def neko(_, message: Message): + if len(message.command) == 1: + await message.edit( + f"Neko type isn't provided\nYou can get available neko types with {prefix}neko_types" + ) + + query = message.command[1] + await message.edit('Loading...') + try: + await message.edit(f'{get_neko_media(query)}', disable_web_page_preview=False) + except Exception as e: + await message.edit(format_exc(e)) + + +@Client.on_message(filters.command(['nekotypes', 'neko_types'], prefix) & filters.me) +async def neko_types_func(_, message: Message): + neko_types = """hug kiss tickle lewd neko pat lizard 8ball cat chat fact smug woof gasm goose cuddle avatar slap gecg feed fox_girl meow wallpaper spank waifu ngif name owoify spoiler why""" + await message.edit(' '.join(f'{n}' for n in neko_types.split())) + + +@Client.on_message(filters.command(['nekospam', 'neko_spam'], prefix) & filters.me) +async def neko_spam(client: Client, message: Message): + query = message.command[1] + amount = int(message.command[2]) + + await message.delete() + + for _ in range(amount): + if message.reply_to_message: + await message.reply_to_message.reply(get_neko_media(query)) + else: + await client.send_message(message.chat.id, get_neko_media(query)) + await asyncio.sleep(0.1) + + +modules_help['neko'] = { + 'neko [type]*': 'Get neko media', + 'neko_types': 'Available neko types', + 'neko_spam [type]* [amount]*': 'Start spam with neko media', +} diff --git a/modules/aniquotes.py b/modules/aniquotes.py new file mode 100644 index 0000000..a15feee --- /dev/null +++ b/modules/aniquotes.py @@ -0,0 +1,40 @@ +from random import randint + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + + +@Client.on_message(filters.command(['aniq', 'aq'], prefix) & filters.me) +async def aniquotes_handler(client: Client, message: Message): + if message.reply_to_message and message.reply_to_message.text: + query = message.reply_to_message.text[:512] + elif message.reply_to_message and message.reply_to_message.caption: + query = message.reply_to_message.caption[:512] + elif len(message.command) > 1: + query = message.text.split(maxsplit=1)[1][:512] + else: + return await message.edit( + '[💮 Aniquotes] Please enter text to create sticker.', + parse_mode=enums.ParseMode.HTML, + ) + + try: + await message.delete() + result = await client.get_inline_bot_results('@quotafbot', query) + return await message.reply_inline_bot_result( + query_id=result.query_id, + result_id=result.results[randint(1, 2)].id, # noqa: S311 + reply_to_message_id=(message.reply_to_message.id if message.reply_to_message else None), + ) + except Exception as e: + return await message.reply( + f'[💮 Aniquotes]\n{format_exc(e)}', + parse_mode=enums.ParseMode.HTML, + ) + + +modules_help['aniquotes'] = { + 'aq [text]': 'Create animated sticker with text', +} diff --git a/modules/antipm.py b/modules/antipm.py new file mode 100644 index 0000000..97e9bda --- /dev/null +++ b/modules/antipm.py @@ -0,0 +1,222 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from pyrogram import Client, filters +from pyrogram.raw import functions +from pyrogram.types import Message +from utils.config import pm_limit +from utils.db import db +from utils.misc import modules_help, prefix + +anti_pm_enabled = filters.create(lambda _, __, ___: db.get('core.antipm', 'status', False)) + +in_contact_list = filters.create(lambda _, __, message: message.from_user.is_contact) + +is_support = filters.create(lambda _, __, message: message.chat.is_support) + +USER_WARNINGS = {} + + +@Client.on_message(filters.private & ~filters.me & ~filters.bot & ~in_contact_list & ~is_support & anti_pm_enabled) +async def anti_pm_handler(client: Client, message: Message): + user_id = message.from_user.id + ids = message.chat.id + b_f = await client.get_me() + u_n = b_f.first_name + user = await client.get_users(ids) + u_f = user.first_name + default_text = db.get('core.antipm', 'antipm_msg', None) + if default_text is None: + default_text = f"""Hello, {u_f}! +This is the Assistant Of {u_n}. +My Boss is away or busy as of now, You can wait for him to respond. +Do not spam further messages else I may have to block you! + +This is an automated message by the assistant. +Currently You Have {USER_WARNINGS.get(user_id, 0)} Warnings. + """ + else: + default_text = default_text.format(user=u_f, my_name=u_n, warns=USER_WARNINGS.get(user_id, 0)) + + if db.get('core.antipm', 'spamrep', False): + user_info = await client.resolve_peer(ids) + await client.invoke(functions.messages.ReportSpam(peer=user_info)) + + if db.get('core.antipm', 'block', False): + await client.block_user(user_id) + + if db.get('core.antipm', f'disallowusers{ids}') == user_id != db.get('core.antipm', f'allowusers{ids}') or db.get( + 'core.antipm', f'disallowusers{ids}' + ) != user_id != db.get('core.antipm', f'allowusers{ids}'): + default_pic = db.get('core.antipm', 'antipm_pic', None) + if default_pic and os.path.exists(default_pic): + await client.send_photo(message.chat.id, default_pic, caption=default_text) + else: + await client.send_message(message.chat.id, default_text) + + if user_id in USER_WARNINGS: + USER_WARNINGS[user_id] += 1 + else: + USER_WARNINGS[user_id] = 1 + + if USER_WARNINGS[user_id] > pm_limit: + await client.send_message( + message.chat.id, + 'Ehm...! That was your Last warn, Bye Bye see you L0L', + ) + await client.block_user(user_id) + del USER_WARNINGS[user_id] + + +@Client.on_message(filters.command(['antipm', 'anti_pm'], prefix) & filters.me) +async def anti_pm(_, message: Message): + if len(message.command) == 1: + if db.get('core.antipm', 'status', False): + await message.edit(f'Anti-PM status: enabled\nDisable with: {prefix}antipm disable') + else: + await message.edit(f'Anti-PM status: disabled\nEnable with: {prefix}antipm enable') + elif message.command[1] in ['enable', 'on', '1', 'yes', 'true']: + db.set('core.antipm', 'status', True) + await message.edit('Anti-PM enabled!') + elif message.command[1] in ['disable', 'off', '0', 'no', 'false']: + db.set('core.antipm', 'status', False) + await message.edit('Anti-PM disabled!') + else: + await message.edit(f'Usage: {prefix}antipm [enable|disable]') + + +@Client.on_message(filters.command(['antipm_report'], prefix) & filters.me) +async def antipm_report(_, message: Message): + if len(message.command) == 1: + if db.get('core.antipm', 'spamrep', False): + await message.edit( + f'Spam-reporting enabled.\nDisable with: {prefix}antipm_report disable' + ) + else: + await message.edit( + f'Spam-reporting disabled.\nEnable with: {prefix}antipm_report enable' + ) + elif message.command[1] in ['enable', 'on', '1', 'yes', 'true']: + db.set('core.antipm', 'spamrep', True) + await message.edit('Spam-reporting enabled!') + elif message.command[1] in ['disable', 'off', '0', 'no', 'false']: + db.set('core.antipm', 'spamrep', False) + await message.edit('Spam-reporting disabled!') + else: + await message.edit(f'Usage: {prefix}antipm_report [enable|disable]') + + +@Client.on_message(filters.command(['antipm_block'], prefix) & filters.me) +async def antipm_block(_, message: Message): + if len(message.command) == 1: + if db.get('core.antipm', 'block', False): + await message.edit( + f'Blocking users enabled.\nDisable with: {prefix}antipm_block disable' + ) + else: + await message.edit( + f'Blocking users disabled.\nEnable with: {prefix}antipm_block enable' + ) + elif message.command[1] in ['enable', 'on', '1', 'yes', 'true']: + db.set('core.antipm', 'block', True) + await message.edit('Blocking users enabled!') + elif message.command[1] in ['disable', 'off', '0', 'no', 'false']: + db.set('core.antipm', 'block', False) + await message.edit('Blocking users disabled!') + else: + await message.edit(f'Usage: {prefix}antipm_block [enable|disable]') + + +@Client.on_message(filters.command(['a'], prefix) & filters.me) +async def add_contact(_, message: Message): + ids = message.chat.id + + db.set('core.antipm', f'allowusers{ids}', ids) + if ids in USER_WARNINGS: + del USER_WARNINGS[ids] + await message.edit('User Approved!') + + +@Client.on_message(filters.command(['d'], prefix) & filters.me) +async def del_contact(_, message: Message): + ids = message.chat.id + + db.set('core.antipm', f'disallowusers{ids}', ids) + db.remove('core.antipm', f'allowusers{ids}') + await message.edit('User DisApproved!') + + +@Client.on_message(filters.command(['setantipmmsg', 'sam'], prefix) & filters.me) +async def set_antipm_msg(_, message: Message): + if not message.reply_to_message: + db.set('core.antipm', 'antipm_msg', None) + await message.edit('antipm message set to default.') + return + + msg = message.reply_to_message + afk_msg = msg.text or msg.caption + + if not afk_msg: + return await message.edit('Reply to a text or caption message to set it as your antipm message.') + + if len(afk_msg) > 200: + return await message.edit('antipm message is too long. It should be less than 200 characters.') + + if '{user}' not in afk_msg: + return await message.edit('antipm message must contain {user} to mention the user.') + if '{my_name}' not in afk_msg: + return await message.edit('antipm message must contain {my_name} to mention your name.') + if '{warns}' not in afk_msg: + return await message.edit('antipm message must contain {warns} to mention the warns count.') + + old_afk_msg = db.get('core.antipm', 'antipm_msg', None) + if old_afk_msg: + db.remove('core.antipm', 'antipm_msg') + db.set('core.antipm', 'antipm_msg', afk_msg) + await message.edit(f'antipm message set to:\n\n{afk_msg}') + + +@Client.on_message(filters.command(['setantipmpic', 'sap'], prefix) & filters.me) +async def set_antipm_pic(_, message: Message): + if not message.reply_to_message or not message.reply_to_message.photo: + db.set('core.antipm', 'antipm_pic', None) + await message.edit('antipm picture set to default.') + return + + await message.edit('Setting antipm picture...') + + photo = await message.reply_to_message.download('antipm_pic.jpg') + + old_antipm_pic = db.get('core.antipm', 'antipm_pic', None) + if old_antipm_pic: + db.remove('core.antipm', 'antipm_pic') + db.set('core.antipm', 'antipm_pic', photo) + await message.edit('antipm picture set successfully.') + + +modules_help['antipm'] = { + 'antipm [enable|disable]*': 'Enable Pm permit', + 'antipm_report [enable|disable]*': 'Enable spam reporting', + 'antipm_block [enable|disable]*': 'Enable user blocking', + 'setantipmmsg [reply to message]*': 'Set antipm message. Use {user} to mention the user and {my_name} to mention your name and {warns} to mention the warns count.', + 'sam [reply to message]*': 'Set antipm message. Use {user} to mention the user and {my_name} to mention your name and {warns} to mention the warns count.', + 'setantipmpic [reply to photo]*': 'Set antipm picture.', + 'sap [reply to photo]*': 'Set antipm picture.', + 'a': 'Approve User', + 'd': 'DisApprove User', +} diff --git a/modules/blackbox.py b/modules/blackbox.py new file mode 100644 index 0000000..0a6bbf0 --- /dev/null +++ b/modules/blackbox.py @@ -0,0 +1,121 @@ +import re +import uuid + +from aiohttp import ClientSession, FormData +from pyrogram import Client, filters +from utils.misc import modules_help, prefix + + +def id_generator() -> str: + return str(uuid.uuid4()) + + +@Client.on_message(filters.command(['bbox', 'blackbox'], prefix) & filters.me) +async def blackbox(client, message): + m = message + msg = await m.edit_text('🔍') + + if len(m.text.split()) == 1: + return await msg.edit_text( + f'Type some query buddy 🐼\n{prefix}blackbox text with reply to the photo or just text' + ) + else: + try: + session = ClientSession() + prompt = m.text.split(maxsplit=1)[1] + user_id = id_generator() + image = None + + if m.reply_to_message and ( + m.reply_to_message.photo or (m.reply_to_message.sticker and not m.reply_to_message.sticker.is_video) + ): + file_name = f'blackbox_{m.chat.id}.jpeg' + file_path = await m.reply_to_message.download(file_name=file_name) + with open(file_path, 'rb') as file: + image = file.read() + + if image: + data = FormData() + data.add_field('fileName', file_name) + data.add_field('userId', user_id) + data.add_field('image', image, filename=file_name, content_type='image/jpeg') + api_url = 'https://www.blackbox.ai/api/upload' + try: + async with session.post(api_url, data=data) as response: + response_json = await response.json() + except Exception as e: + return await msg.edit(f'❌ Error: {str(e)}') + + messages = [ + { + 'role': 'user', + 'content': response_json['response'] + '\n#\n' + prompt, + } + ] + data = { + 'messages': messages, + 'user_id': user_id, + 'codeModelMode': True, + 'agentMode': {}, + 'trendingAgentMode': {}, + } + headers = {'Content-Type': 'application/json'} + url = 'https://www.blackbox.ai/api/chat' + try: + async with session.post(url, headers=headers, json=data) as response: + response_text = await response.text() + except Exception as e: + return await msg.edit(f'❌ Error: {str(e)}') + + cleaned_response_text = re.sub( + r'^\$?@?\$?v=undefined-rv\d+@?\$?|\$?@?\$?v=v\d+\.\d+-rv\d+@?\$?', + '', + response_text, + ) + text = cleaned_response_text.strip()[2:] + if '$~~~$' in text: + text = re.sub(r'\$~~~\$.*?\$~~~\$', '', text, flags=re.DOTALL) + rdata = {'reply': text} + + return await msg.edit_text(text=rdata['reply']) + else: + reply = m.reply_to_message + if reply and reply.text: + prompt = f'Old conversation:\n{reply.text}\n\nQuestion:\n{prompt}' + messages = [{'role': 'user', 'content': prompt}] + data = { + 'messages': messages, + 'user_id': user_id, + 'codeModelMode': True, + 'agentMode': {}, + 'trendingAgentMode': {}, + } + headers = {'Content-Type': 'application/json'} + url = 'https://www.blackbox.ai/api/chat' + try: + async with session.post(url, headers=headers, json=data) as response: + response_text = await response.text() + except Exception as e: + return await msg.edit(f'❌ Error: {str(e)}') + + cleaned_response_text = re.sub( + r'^\$?@?\$?v=undefined-rv\d+@?\$?|\$?@?\$?v=v\d+\.\d+-rv\d+@?\$?', + '', + response_text, + ) + text = cleaned_response_text.strip()[2:] + if '$~~~$' in text: + text = re.sub(r'\$~~~\$.*?\$~~~\$', '', text, flags=re.DOTALL) + rdata = {'reply': text} + + return await msg.edit_text(text=rdata['reply']) + except Exception as e: + return await msg.edit(f'�� Error: {str(e)}') + finally: + await session.close() + + +modules_help['blackbox'] = { + 'blackbox [query]*': 'Ask anything to Blackbox', + 'bbox [query]*': 'Ask anything to Blackbox', +} diff --git a/modules/calculator.py b/modules/calculator.py new file mode 100644 index 0000000..08e7249 --- /dev/null +++ b/modules/calculator.py @@ -0,0 +1,39 @@ +import asyncio + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('calc', prefix) & filters.me) +async def calc(_, message: Message): + if len(message.command) <= 1: + return + args = ' '.join(message.command[1:]) + try: + result = str(eval(args)) # noqa: S307 + + if len(result) > 4096: + for i, x in enumerate(range(0, len(result), 4096)): + if i == 0: + await message.edit( + f'{args}={result[x : x + 4000]}', + parse_mode='HTML', + ) + else: + await message.reply(f'{result[x : x + 4096]}', parse_mode='HTML') + await asyncio.sleep(0.18) + else: + await message.edit(f'{args}={result}', parse_mode='HTML') + except Exception as e: + await message.edit(f'{args}=={e}', parse_mode='HTML') + + +modules_help['calculator'] = { + 'calc [expression]*': 'solve a math problem\n' + '+ – addition\n' + '– – subtraction\n' + '* – multiplication\n' + '/ – division\n' + '** – degree' +} diff --git a/modules/cdxl.py b/modules/cdxl.py new file mode 100644 index 0000000..902ad69 --- /dev/null +++ b/modules/cdxl.py @@ -0,0 +1,52 @@ +import os + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, import_library + +clarifai = import_library('clarifai') + +from clarifai.client.model import Model # noqa: E402 + + +@Client.on_message(filters.command('cdxl', prefix) & filters.me) +async def cdxl(c: Client, message: Message): + try: + chat_id = message.chat.id + await message.edit_text('Please Wait...') + + if len(message.command) > 1: + prompt = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + prompt = message.reply_to_message.text + else: + await message.edit_text(f'Usage: {prefix}vdxl [prompt/reply to prompt]') + return + + inference_params = {'width': 1024, 'height': 1024, 'steps': 50, 'cfg_scale': 9.0} + + model_prediction = Model( + 'https://clarifai.com/stability-ai/stable-diffusion-2/models/stable-diffusion-xl' + ).predict_by_bytes(prompt.encode(), input_type='text', inference_params=inference_params) + + output_base64 = model_prediction.outputs[0].data.image.base64 + + with open('sdxl_out.png', 'wb') as f: + f.write(output_base64) + + await message.delete() + await c.send_photo( + chat_id, + photo='sdxl_out.png', + caption=f'Prompt:{prompt}', + ) + os.remove('sdxl_out.png') + + except Exception as e: + await message.edit_text(f'An error occurred: {format_exc(e)}') + + +modules_help['cdxl'] = { + 'cdxl [prompt/reply to prompt]*': 'Text to Image with SDXL model', +} diff --git a/modules/chatbot.py b/modules/chatbot.py new file mode 100644 index 0000000..fb49eeb --- /dev/null +++ b/modules/chatbot.py @@ -0,0 +1,100 @@ +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.config import cohere_key +from utils.db import db +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, import_library, restart + +cohere = import_library('cohere') + +co = cohere.Client(cohere_key) + +chatai_users = db.getaiusers() + + +@Client.on_message(filters.command('addai', prefix) & filters.me) +async def adduser(_, message: Message): + if len(message.command) > 1: + user_id = message.text.split(maxsplit=1)[1] + if user_id.isdigit(): + user_id = int(user_id) + db.addaiuser(user_id) + await message.edit_text('User ID Added') + restart() + else: + await message.edit_text('User ID is invalid.') + return + else: + await message.edit_text(f'Usage: {prefix}addai [user_id]') + return + + +@Client.on_message(filters.command('remai', prefix) & filters.me) +async def remuser(_, message: Message): + if len(message.command) > 1: + user_id = message.text.split(maxsplit=1)[1] + if user_id.isdigit(): + user_id = int(user_id) + db.remaiuser(user_id) + await message.edit_text('User ID Removed') + restart() + else: + await message.edit_text('User ID is invalid.') + return + else: + await message.edit_text(f'Usage: {prefix}remai [user_id]') + return + + +@Client.on_message(filters.user(users=chatai_users) & filters.text) +async def chatbot(_, message: Message): + user_id = message.chat.id + + if user_id in chatai_users: + pass + else: + return + try: + await message.reply_chat_action(enums.ChatAction.TYPING) + + chat_history = db.get_chat_history(user_id) + + prompt = message.text + + db.add_chat_history(user_id, {'role': 'USER', 'message': prompt}) + + response = co.chat( + chat_history=chat_history, + model='command-r-plus', + message=prompt, + temperature=0.3, + connectors=[{'id': 'web-search', 'options': {'site': 'wikipedia.com'}}], + prompt_truncation='AUTO', + ) + + db.add_chat_history(user_id, {'role': 'CHATBOT', 'message': response.text}) + + await message.reply_text(f'{response.text}', parse_mode=enums.ParseMode.MARKDOWN) + + except Exception as e: + await message.reply_text(f'An error occurred: {format_exc(e)}') + + +@Client.on_message(filters.command('chatoff', prefix) & filters.me) +async def chatoff(_, message: Message): + db.remove('core.chatbot', 'chatai_users') + await message.reply_text('ChatBot is off now') + restart() + + +@Client.on_message(filters.command('listai', prefix) & filters.me) +async def listai(_, message: Message): + await message.edit_text(f"User ID's Currently in AI ChatBot List:\n {chatai_users}") + + +modules_help['chatbot'] = { + 'addai [user_id]*': 'Add A user to AI ChatBot List', + 'remai [user_id]*': 'Remove A user from AI ChatBot List', + 'listai': 'List A user from AI ChatBot List', + 'chatoff': 'Turn off AI ChatBot', +} diff --git a/modules/circle.py b/modules/circle.py new file mode 100644 index 0000000..fb45b48 --- /dev/null +++ b/modules/circle.py @@ -0,0 +1,133 @@ +import asyncio +import os +from io import BytesIO + +from PIL import Image, ImageDraw, ImageFilter, ImageOps +from pyrogram import Client, enums, filters +from pyrogram.types import Message + +# noinspection PyUnresolvedReferences +from utils.misc import modules_help, prefix + +# noinspection PyUnresolvedReferences +from utils.scripts import format_exc, import_library + +VideoFileClip = import_library('moviepy', 'moviepy==2.2.1').VideoFileClip + +im = None + + +def process_img(filename): + global im + im = Image.open(f'downloads/{filename}') + w, h = im.size + img = Image.new('RGBA', (w, h), (0, 0, 0, 0)) + img.paste(im, (0, 0)) + m = min(w, h) + img = img.crop(((w - m) // 2, (h - m) // 2, (w + m) // 2, (h + m) // 2)) + w, h = img.size + mask = Image.new('L', (w, h), 0) + draw = ImageDraw.Draw(mask) + draw.ellipse((10, 10, w - 10, h - 10), fill=255) + mask = mask.filter(ImageFilter.GaussianBlur(2)) + img = ImageOps.fit(img, (w, h)) + img.putalpha(mask) + im = BytesIO() + im.name = 'img.webp' + img.save(im) + im.seek(0) + + +video = None + + +def process_vid(filename): + global video + video = VideoFileClip(f'downloads/{filename}') + w, h = video.size + m = min(w, h) + box = { + 'x1': (w - m) // 2, + 'y1': (h - m) // 2, + 'x2': (w + m) // 2, + 'y2': (h + m) // 2, + } + video = video.cropped(**box) + + +@Client.on_message(filters.command(['circle', 'round'], prefix) & filters.me) +async def circle(_, message: Message): + try: + if not message.reply_to_message: + return await message.reply( + 'Reply is required for this command', + parse_mode=enums.ParseMode.HTML, + ) + if message.reply_to_message.photo: + filename = 'circle.jpg' + typ = 'photo' + elif message.reply_to_message.sticker: + if message.reply_to_message.sticker.is_video: + return await message.reply( + 'Video stickers is not supported', + parse_mode=enums.ParseMode.HTML, + ) + filename = 'circle.webp' + typ = 'photo' + elif message.reply_to_message.video: + filename = 'circle.mp4' + typ = 'video' + elif message.reply_to_message.document: + _filename = message.reply_to_message.document.file_name.casefold() + if _filename.endswith('.png'): + filename = 'circle.png' + typ = 'photo' + elif _filename.endswith('.jpg'): + filename = 'circle.jpg' + typ = 'photo' + elif _filename.endswith('.jpeg'): + filename = 'circle.jpeg' + typ = 'photo' + elif _filename.endswith('.webp'): + filename = 'circle.webp' + typ = 'photo' + elif _filename.endswith('.mp4'): + filename = 'circle.mp4' + typ = 'video' + else: + return await message.reply('Invalid file type', parse_mode=enums.ParseMode.HTML) + else: + return await message.reply('Invalid file type', parse_mode=enums.ParseMode.HTML) + + if typ == 'photo': + await message.edit('Processing image📷', parse_mode=enums.ParseMode.HTML) + await message.reply_to_message.download(f'downloads/{filename}') + await asyncio.get_event_loop().run_in_executor(None, process_img, filename) + await message.delete() + return await message.reply_sticker(sticker=im, reply_to_message_id=message.reply_to_message.id) + else: + await message.edit('Processing video🎥', parse_mode=enums.ParseMode.HTML) + await message.reply_to_message.download(f'downloads/{filename}') + await asyncio.get_event_loop().run_in_executor(None, process_vid, filename) + + await message.edit('Saving video📼', parse_mode=enums.ParseMode.HTML) + await asyncio.get_event_loop().run_in_executor(None, video.write_videofile, 'downloads/result.mp4') + + await message.delete() + await message.reply_video_note( + video_note='downloads/result.mp4', + duration=int(video.duration), + reply_to_message_id=message.reply_to_message.id, + ) + if isinstance(video, VideoFileClip): + video.close() + os.remove(f'downloads/{filename}') + os.remove('downloads/result.mp4') + except Exception as e: + await message.reply(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +modules_help['circle'] = { + 'round': 'Round a photo or video.', + 'circle': 'Circle a photo or video.', +} diff --git a/modules/clear_notifs.py b/modules/clear_notifs.py new file mode 100644 index 0000000..37677d0 --- /dev/null +++ b/modules/clear_notifs.py @@ -0,0 +1,70 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, filters +from pyrogram.raw import functions +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['clear_@'], prefix) & filters.me) +async def solo_mention_clear(client: Client, message: Message): + await message.delete() + peer = await client.resolve_peer(message.chat.id) + request = functions.messages.ReadMentions(peer=peer) + await client.invoke(request) + + +@Client.on_message(filters.command(['clear_all_@'], prefix) & filters.me) +async def global_mention_clear(client: Client, message: Message): + counter: int = 0 + await message.edit_text(f'Clearing all mentions...\n\nCleared: {counter} chats') + async for dialog in client.get_dialogs(): + peer = await client.resolve_peer(dialog.chat.id) + request = functions.messages.ReadMentions(peer=peer) + await client.invoke(request) + counter += 1 + await message.edit_text(f'Clearing all mentions...\n\nCleared: {counter} chats') + await message.delete() + + +@Client.on_message(filters.command(['clear_reacts'], prefix) & filters.me) +async def solo_reaction_clear(client: Client, message: Message): + await message.delete() + peer = await client.resolve_peer(message.chat.id) + request = functions.messages.ReadReactions(peer=peer) + await client.invoke(request) + + +@Client.on_message(filters.command(['clear_all_reacts'], prefix) & filters.me) +async def global_reaction_clear(client: Client, message: Message): + counter: int = 0 + await message.edit_text(f'Clearing all reactions...\n\nCleared: {counter} chats') + async for dialog in client.get_dialogs(): + peer = await client.resolve_peer(dialog.chat.id) + request = functions.messages.ReadReactions(peer=peer) + await client.invoke(request) + counter += 1 + await message.edit_text(f'Clearing all reactions...\n\nCleared: {counter} chats') + await message.delete() + + +modules_help['clear_notifs'] = { + 'clear_@': 'clear all mentions in this chat', + 'clear_all_@': 'clear all mentions in all chats', + 'clear_reacts': 'clear all reactions in this chat', + 'clear_all_reacts': 'clear all reactions in all chats (except private chats)', +} diff --git a/modules/cohere.py b/modules/cohere.py new file mode 100644 index 0000000..4470739 --- /dev/null +++ b/modules/cohere.py @@ -0,0 +1,122 @@ +import asyncio + +from utils.config import cohere_key +from utils.scripts import import_library + +cohere = import_library('cohere') + +import cohere # noqa: F811 + +co = cohere.Client(cohere_key) + +from pyrogram import Client, enums, filters # noqa: E402 +from pyrogram.errors import MessageTooLong # noqa: E402 +from pyrogram.types import Message # noqa: E402 +from utils.db import db # noqa: E402 +from utils.misc import modules_help, prefix # noqa: E402 +from utils.rentry import paste as rentry_paste # noqa: E402 +from utils.scripts import format_exc # noqa: E402 + + +@Client.on_message(filters.command('cohere', prefix) & filters.me) +async def cohere(c: Client, message: Message): + try: + user_id = message.from_user.id + chat_history = db.get_chat_history(user_id) + + if len(message.command) > 1: + prompt = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + prompt = message.reply_to_message.text + else: + await message.edit_text(f'Usage: {prefix}cohere [prompt/reply to message]') + return + + db.add_chat_history(user_id, {'role': 'USER', 'message': prompt}) + + await message.edit_text('Umm, lemme think...') + + response = co.chat_stream( + chat_history=chat_history, + model='command-r-plus', + message=prompt, + temperature=0.8, + tools=[{'name': 'internet_search'}], + connectors=[], + prompt_truncation='OFF', + ) + output = '' + tool_message = '' + data = [] + for event in response: + if event.event_type == 'tool-calls-chunk': + if event.tool_call_delta and event.tool_call_delta.text is None: + tool_message += '' + else: + tool_message += event.text + if event.event_type == 'search-results': + data.append(event.documents) + if event.event_type == 'text-generation': + output += event.text + if output == '': + output = "I can't seem to find an answer to that" + + db.add_chat_history(user_id, {'role': 'CHATBOT', 'message': output}) + + await message.edit_text(f'{tool_message}') + + await asyncio.sleep(5) + + try: + data = data[0] + references = '' + reference_dict = {} + for item in data: + title = item['title'] + url = item['url'] + if title not in reference_dict: + reference_dict[title] = url + + i = 1 + for title, url in reference_dict.items(): + references += f'**{i}.** [{title}]({url})\n' + i += 1 + + await message.edit_text( + f'**Question:**`{prompt}`\n**Answer:** {output}\n\n**References:**\n{references}', + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + ) + + except IndexError: + references = '' + await message.edit_text( + f'**Question:**`{prompt}`\n**Answer:** {output}\n', + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + ) + except MessageTooLong: + await message.edit_text('Output is too long... Pasting to rentry...') + try: + output = output + '\n\n' + references if references else output + rentry_url, edit_code = await rentry_paste(text=output, return_edit=True) + except RuntimeError: + await message.edit_text('Error: Failed to paste to rentry') + return + await c.send_message( + 'me', + f"Here's your edit code for Url: {rentry_url}\nEdit code: {edit_code}", + disable_web_page_preview=True, + ) + await message.edit_text( + f'Output: {rentry_url}\nNote: Edit Code has been sent to your saved messages', + disable_web_page_preview=True, + ) + + except Exception as e: + await message.edit_text(f'An error occurred: {format_exc(e)}') + + +modules_help['cohere'] = { + 'cohere': 'Chat with cohere ai' + '\nSupports Chat History\n' + 'Supports real time internet search' +} diff --git a/modules/demotivator.py b/modules/demotivator.py new file mode 100644 index 0000000..c1b5d68 --- /dev/null +++ b/modules/demotivator.py @@ -0,0 +1,64 @@ +import random +from io import BytesIO + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import import_library + +requests = import_library('requests') +PIL = import_library('PIL', 'pillow') + +from PIL import Image, ImageDraw, ImageFont # noqa: E402 + + +@Client.on_message(filters.command(['dem'], prefix) & filters.me) +async def demotivator(client: Client, message: Message): + await message.edit('Process of demotivation...', parse_mode=enums.ParseMode.HTML) + font = requests.get('https://github.com/The-MoonTg-project/files/blob/main/Times%20New%20Roman.ttf?raw=true', timeout=10) + f = font.content + template_dem = requests.get('https://raw.githubusercontent.com/files/main/demotivator.png', timeout=10) + if message.reply_to_message: + words = ['random', 'text', 'typing', 'fuck'] + if message.reply_to_message.photo: + donwloads = await client.download_media(message.reply_to_message.photo.file_id) + photo = Image.open(f'{donwloads}') + resize_photo = photo.resize((469, 312)) + text = message.text.split(' ', maxsplit=1)[1] if len(message.text.split()) > 1 else random.choice(words) # noqa: S311 + im = Image.open(BytesIO(template_dem.content)) + im.paste(resize_photo, (65, 48)) + text_font = ImageFont.truetype(BytesIO(f), 22) + text_draw = ImageDraw.Draw(im) + text_draw.multiline_text((299, 412), text, font=text_font, fill=(255, 255, 255), anchor='ms') + im.save(f'downloads/{message.id}.png') + await message.reply_to_message.reply_photo(f'downloads/{message.id}.png') + await message.delete() + elif message.reply_to_message.sticker: + if not message.reply_to_message.sticker.is_animated: + donwloads = await client.download_media(message.reply_to_message.sticker.file_id) + photo = Image.open(f'{donwloads}') + resize_photo = photo.resize((469, 312)) + text = message.text.split(' ', maxsplit=1)[1] if len(message.text.split()) > 1 else random.choice(words) # noqa: S311 + im = Image.open(BytesIO(template_dem.content)) + im.paste(resize_photo, (65, 48)) + text_font = ImageFont.truetype(BytesIO(f), 22) + text_draw = ImageDraw.Draw(im) + text_draw.multiline_text((299, 412), text, font=text_font, fill=(255, 255, 255), anchor='ms') + im.save(f'downloads/{message.id}.png') + await message.reply_to_message.reply_photo(f'downloads/{message.id}.png') + await message.delete() + else: + await message.edit( + 'Animated stickers are not supported', + parse_mode=enums.ParseMode.HTML, + ) + else: + await message.edit( + 'Need to answer the photo/sticker', + parse_mode=enums.ParseMode.HTML, + ) + else: + await message.edit('Need to answer the photo/sticker', parse_mode=enums.ParseMode.HTML) + + +modules_help['demotivator'] = {'dem [text]*': 'Reply to the picture to make a demotivator out of it'} diff --git a/modules/destroy.py b/modules/destroy.py new file mode 100644 index 0000000..8c23595 --- /dev/null +++ b/modules/destroy.py @@ -0,0 +1,78 @@ +import os + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import import_library + +lottie = import_library('lottie') +from lottie.exporters import exporters # noqa: E402 +from lottie.importers import importers # noqa: E402 + + +@Client.on_message(filters.command('destroy', prefix) & filters.me) +async def destroy_sticker(client: Client, message: Message): + """Destroy animated stickers by modifying their animation properties""" + try: + reply = message.reply_to_message + if not reply or not reply.sticker or not reply.sticker.is_animated: + return await message.edit( + '**Please reply to an animated sticker!**', + parse_mode=enums.ParseMode.MARKDOWN, + ) + + edit_msg = await message.edit('**🔄 Destroying sticker...**', parse_mode=enums.ParseMode.MARKDOWN) + + # Download sticker + tgs_path = await reply.download() + if not tgs_path or not os.path.exists(tgs_path): + return await edit_msg.edit('**❌ Download failed!**', parse_mode=enums.ParseMode.MARKDOWN) + + # Conversion process + json_path = 'temp.json' + output_path = 'MoonUB.tgs' + + importer = importers.get_from_filename(tgs_path) + if not importer: + return await edit_msg.edit('**❌ JSON conversion failed!**', parse_mode=enums.ParseMode.MARKDOWN) + + animation = importer.process(tgs_path) + exporter = exporters.get_from_filename(json_path) + exporter.process(animation, json_path) + + # Modify JSON data + with open(json_path, 'r+') as f: + content = f.read() + modified = ( + content.replace('[1]', '[2]') + .replace('[2]', '[3]') + .replace('[3]', '[4]') + .replace('[4]', '[5]') + .replace('[5]', '[6]') + ) + f.seek(0) + f.write(modified) + f.truncate() + + importer = importers.get_from_filename(json_path) + animation = importer.process(json_path) + exporter = exporters.get_from_filename(output_path) + exporter.process(animation, output_path) + + # Send result + await message.reply_document(output_path, reply_to_message_id=reply.id) + await edit_msg.delete() + + except Exception as e: + await message.edit(f'**❌ Error:** `{e}`', parse_mode=enums.ParseMode.MARKDOWN) + finally: + # Cleanup temporary files + for file_path in [tgs_path, json_path, output_path]: + if file_path and os.path.exists(file_path): + try: + os.remove(file_path) + except Exception as clean_error: + print(f'Cleanup error: {clean_error}') + + +modules_help['destroy'] = {'destroy [reply]': 'Modify and destroy animated stickers'} diff --git a/modules/dice.py b/modules/dice.py new file mode 100644 index 0000000..44bebf7 --- /dev/null +++ b/modules/dice.py @@ -0,0 +1,26 @@ +import asyncio + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + + +@Client.on_message(filters.command('dice', prefix) & filters.me) +async def dice_text(client: Client, message: Message): + try: + value = int(message.command[1]) + if value not in range(1, 7): + raise AssertionError + except (ValueError, IndexError, AssertionError): + return await message.edit('Invalid value', parse_mode=enums.ParseMode.HTML) + + try: + message.dice = type('bruh', (), {'value': 0})() + while message.dice.value != value: + message = (await asyncio.gather(message.delete(), client.send_dice(message.chat.id)))[1] + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +modules_help['dice'] = {'dice [1-6]*': 'Generate dice with specified value. Works only in groups'} diff --git a/modules/direct.py b/modules/direct.py new file mode 100644 index 0000000..498a444 --- /dev/null +++ b/modules/direct.py @@ -0,0 +1,304 @@ +# Copyright (C) 2019 The Raphielscape Company LLC. +# +# Licensed under the Raphielscape Public License, Version 1.d (the "License"); +# you may not use this file except in compliance with the License. +# +"""Userbot module containing various sites direct links generators""" + +import json +import re +import urllib.parse +from random import choice +from subprocess import PIPE, Popen + +import requests +from bs4 import BeautifulSoup +from humanize import naturalsize +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +def subprocess_run(cmd): + reply = '' + cmd_args = cmd.split() + subproc = Popen( # noqa: S603 + cmd_args, + stdout=PIPE, + stderr=PIPE, + universal_newlines=True, + executable='bash', + ) + talk = subproc.communicate() + exitCode = subproc.returncode + if exitCode != 0: + reply += ( + '```An error was detected while running the subprocess:\n' + f'exit code: {exitCode}\n' + f'stdout: {talk[0]}\n' + f'stderr: {talk[1]}```' + ) + return reply + return talk + + +@Client.on_message(filters.command('direct', prefix) & filters.me) +async def direct_link_generator(_, m: Message): + if len(m.command) > 1: + message = m.text.split(maxsplit=1)[1] + elif m.reply_to_message: + message = m.reply_to_message.text + else: + await m.edit(f'Usage: {prefix}direct [url]') + return + reply = '' + links = re.findall(r'\bhttps?://.*\.\S+', message) + if not links: + reply = '`No links found!`' + await m.edit(reply, parse_mode=enums.ParseMode.MARKDOWN) + for link in links: + if 'drive.google.com' in link: + reply += gdrive(link) + elif 'yadi.sk' in link: + reply += yandex_disk(link) + elif 'cloud.mail.ru' in link: + reply += cm_ru(link) + elif 'mediafire.com' in link: + reply += mediafire(link) + elif 'sourceforge.net' in link: + reply += sourceforge(link) + elif 'osdn.net' in link: + reply += osdn(link) + elif 'androidfilehost.com' in link: + reply += androidfilehost(link) + else: + reply += re.findall(r'\bhttps?://(.*?[^/]+)', link)[0] + ' is not supported' + await m.edit(reply, parse_mode=enums.ParseMode.MARKDOWN) + + +def gdrive(url: str) -> str: + """GDrive direct links generator""" + drive = 'https://drive.google.com' + try: + link = re.findall(r'\bhttps?://drive\.google\.com\S+', url)[0] + except IndexError: + reply = '`No Google drive links found`\n' + return reply + file_id = '' + reply = '' + if link.find('view') != -1: + file_id = link.split('/')[-2] + elif link.find('open?id=') != -1: + file_id = link.split('open?id=')[1].strip() + elif link.find('uc?id=') != -1: + file_id = link.split('uc?id=')[1].strip() + url = f'{drive}/uc?export=download&id={file_id}' + download = requests.get(url, stream=True, allow_redirects=False, timeout=10) + cookies = download.cookies + try: + # In case of small file size, Google downloads directly + dl_url = download.headers['location'] + page = BeautifulSoup(download.content, 'html.parser') + if 'accounts.google.com' in dl_url: # non-public file + reply += '`Link is not public!`\n' + return reply + name = 'Direct Download Link' + except KeyError: + # In case of download warning page + page = BeautifulSoup(download.content, 'html.parser') + if download.headers is not None: + dl_url = download.headers.get('location') + page_element = page.find('a', {'id': 'uc-download-link'}) + if page_element is not None: + export = drive + page_element.get('href') + name = page.find('span', {'class': 'uc-name-size'}).text + response = requests.get(export, stream=True, allow_redirects=False, cookies=cookies, timeout=10) + dl_url = response.headers['location'] + if 'accounts.google.com' in dl_url: + name = page.find('span', {'class': 'uc-name-size'}).text + reply += 'Link is not public!' + return reply + if '=sharing' in dl_url: + name = page.find('span', {'class': 'uc-name-size'}).text + reply += '```Provide GDrive Link not directc sharing of GDrive!```' + return reply + + reply += f'[{name}]({dl_url})\n' + return reply + + +def yandex_disk(url: str) -> str: + """Yandex.Disk direct links generator + Based on https://github.com/wldhx/yadisk-direct""" + reply = '' + try: + link = re.findall(r'\bhttps?://.*yadi\.sk\S+', url)[0] + except IndexError: + reply = '`No Yandex.Disk links found`\n' + return reply + api = 'https://cloud-api.yandex.net/v1/disk/public/resources/download?public_key={}' + try: + dl_url = requests.get(api.format(link), timeout=10).json()['href'] + name = dl_url.split('filename=')[1].split('&disposition')[0] + reply += f'[{name}]({dl_url})\n' + except KeyError: + reply += '`Error: File not found / Download limit reached`\n' + return reply + return reply + + +def cm_ru(url: str) -> str: + """cloud.mail.ru direct links generator + Using https://github.com/JrMasterModelBuilder/cmrudl.py""" + reply = '' + try: + link = re.findall(r'\bhttps?://.*cloud\.mail\.ru\S+', url)[0] + except IndexError: + reply = '`No cloud.mail.ru links found`\n' + return reply + cmd = f'bin/cmrudl -s {link}' + result = subprocess_run(cmd) + try: + result = result[0].splitlines()[-1] + data = json.loads(result) + except json.decoder.JSONDecodeError: + reply += "`Error: Can't extract the link`\n" + return reply + except IndexError: + return reply + dl_url = data['download'] + name = data['file_name'] + size = naturalsize(int(data['file_size'])) + reply += f'[{name} ({size})]({dl_url})\n' + return reply + + +def mediafire(url: str) -> str: + """MediaFire direct links generator""" + try: + link = re.findall(r'\bhttps?://.*mediafire\.com\S+', url)[0] + except IndexError: + reply = '`No MediaFire links found`\n' + return reply + reply = '' + page = BeautifulSoup(requests.get(link, timeout=10).content, 'lxml') + info = page.find('a', {'aria-label': 'Download file'}) + dl_url = info.get('href') + size = re.findall(r'\(.*\)', info.text)[0] + name = page.find('div', {'class': 'filename'}).text + reply += f'[{name} {size}]({dl_url})\n' + return reply + + +def sourceforge(url: str) -> str: + """SourceForge direct links generator""" + try: + link = re.findall(r'\bhttps?://.*sourceforge\.net\S+', url)[0] + except IndexError: + reply = '`No SourceForge links found`\n' + return reply + file_path = re.findall(r'files(.*)/download', link)[0] + reply = f'Mirrors for __{file_path.split("/")[-1]}__\n' + project = re.findall(r'projects?/(.*?)/files', link)[0] + mirrors = f'https://sourceforge.net/settings/mirror_choices?projectname={project}&filename={file_path}' + page = BeautifulSoup(requests.get(mirrors, timeout=10).content, 'html.parser') + info = page.find('ul', {'id': 'mirrorList'}).findAll('li') + for mirror in info[1:]: + name = re.findall(r'\((.*)\)', mirror.text.strip())[0] + dl_url = f'https://{mirror["id"]}.dl.sourceforge.net/project/{project}/{file_path}' + reply += f'[{name}]({dl_url}) ' + return reply + + +def osdn(url: str) -> str: + """OSDN direct links generator""" + osdn_link = 'https://osdn.net' + try: + link = re.findall(r'\bhttps?://.*osdn\.net\S+', url)[0] + except IndexError: + reply = '`No OSDN links found`\n' + return reply + page = BeautifulSoup(requests.get(link, allow_redirects=True, timeout=10).content, 'lxml') + info = page.find('a', {'class': 'mirror_link'}) + link = urllib.parse.unquote(osdn_link + info['href']) + reply = f'Mirrors for __{link.split("/")[-1]}__\n' + mirrors = page.find('form', {'id': 'mirror-select-form'}).findAll('tr') + for data in mirrors[1:]: + mirror = data.find('input')['value'] + name = re.findall(r'\((.*)\)', data.findAll('td')[-1].text.strip())[0] + dl_url = re.sub(r'm=(.*)&f', f'm={mirror}&f', link) + reply += f'[{name}]({dl_url}) ' + return reply + + +def androidfilehost(url: str) -> str: + """AFH direct links generator""" + try: + link = re.findall(r'\bhttps?://.*androidfilehost.*fid.*\S+', url)[0] + except IndexError: + reply = '`No AFH links found`\n' + return reply + fid = re.findall(r'\?fid=(.*)', link)[0] + session = requests.Session() + user_agent = useragent() + headers = {'user-agent': user_agent} + res = session.get(link, headers=headers, allow_redirects=True) + headers = { + 'origin': 'https://androidfilehost.com', + 'accept-encoding': 'gzip, deflate, br', + 'accept-language': 'en-US,en;q=0.9', + 'user-agent': user_agent, + 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', + 'x-mod-sbb-ctype': 'xhr', + 'accept': '*/*', + 'referer': f'https://androidfilehost.com/?fid={fid}', + 'authority': 'androidfilehost.com', + 'x-requested-with': 'XMLHttpRequest', + } + data = {'submit': 'submit', 'action': 'getdownloadmirrors', 'fid': f'{fid}'} + mirrors = None + reply = '' + error = "`Error: Can't find Mirrors for the link`\n" + try: + req = session.post( + 'https://androidfilehost.com/libs/otf/mirrors.otf.php', + headers=headers, + data=data, + cookies=res.cookies, + ) + mirrors = req.json()['MIRRORS'] + except (json.decoder.JSONDecodeError, TypeError): + reply += error + if not mirrors: + reply += error + return reply + for item in mirrors: + name = item['name'] + dl_url = item['url'] + reply += f'[{name}]({dl_url}) ' + return reply + + +def useragent(): + """ + useragent random setter + """ + useragents = BeautifulSoup( + requests.get( + 'https://developers.whatismybrowser.com/useragents/explore/operating_system_name/android/', + timeout=10, + ).content, + 'lxml', + ).findAll('td', {'class': 'useragent'}) + if not useragents: + return 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3' + user_agent = choice(useragents) # noqa: S311 + return user_agent.text + + +modules_help['direct'] = { + 'direct': 'Url/reply to Url\ +\n\nSyntax : .direct [url/reply] \ +\nUsage : Generates direct download link from supported URL(s)\ +\n\nSupported websites : Google Drive - MEGA.nz - Cloud Mail - Yandex.Disk - AFH - MediaFire - SourceForge - OSDN' +} diff --git a/modules/duckduckgo.py b/modules/duckduckgo.py new file mode 100644 index 0000000..1308dfc --- /dev/null +++ b/modules/duckduckgo.py @@ -0,0 +1,14 @@ +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import prefix + + +@Client.on_message(filters.command('duck', prefix) & filters.me) +async def duckgo(client: Client, message: Message): + input_str = ' '.join(message.command[1:]) + sample_url = 'https://duckduckgo.com/?q={}'.format(input_str.replace(' ', '+')) + if sample_url: + link = sample_url.rstrip() + await message.edit_text(f'Let me 🦆 DuckDuckGo that for you:\n🔎 [{input_str}]({link})') + else: + await message.edit_text('something is wrong. please try again later.') diff --git a/modules/durov.py b/modules/durov.py new file mode 100644 index 0000000..d091512 --- /dev/null +++ b/modules/durov.py @@ -0,0 +1,16 @@ +from random import randint + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('durov', prefix) & filters.me) +async def durov(_, message: Message): + await message.edit( + f'Random post from channel: https://t.me/durov/{randint(21, 36500)}', # noqa: S311 + parse_mode=enums.ParseMode.HTML, + ) + + +modules_help['durov'] = {'durov': 'Send random post from durov channel'} diff --git a/modules/example.py b/modules/example.py new file mode 100644 index 0000000..ca3da04 --- /dev/null +++ b/modules/example.py @@ -0,0 +1,57 @@ +#  Moon-Userbot - telegram userbot +#  Copyright (C) 2020-present Moon Userbot Organization +# +#  This program is free software: you can redistribute it and/or modify +#  it under the terms of the GNU General Public License as published by +#  the Free Software Foundation, either version 3 of the License, or +#  (at your option) any later version. + +#  This program is distributed in the hope that it will be useful, +#  but WITHOUT ANY WARRANTY; without even the implied warranty of +#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#  GNU General Public License for more details. + +#  You should have received a copy of the GNU General Public License +#  along with this program.  If not, see . + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +# if your module has packages from PyPi + +# from utils.scripts import import_library +# example_1 = import_library("example_1") +# example_2 = import_library("example_2") + +# import_library() will automatically install required library +# if it isn't installed + + +@Client.on_message(filters.command('example_edit', prefix) & filters.me) +async def example_edit(client: Client, message: Message): + try: + await message.edit('This is an example module') + except Exception as e: + await message.edit(f'[{e.error_code}: {enums.MessageType.TEXT}] - {e.error_details}') + + +@Client.on_message(filters.command('example_send', prefix) & filters.me) +async def example_send(client: Client, message: Message): + try: + await client.send_message(message.chat.id, 'This is an example module') + except Exception as e: + await message.edit(f'[{e.error_code}: {enums.MessageType.TEXT}] - {e.error_details}') + + +# This adds instructions for your module +modules_help['example'] = { + 'example_send': 'example send', + 'example_edit': 'example edit', +} + +# modules_help["example"] = { "example_send [text]": "example send" } +# | | | | +# | | | └─ command description +# module_name command_name └─ optional command arguments +# (only snake_case) (only snake_case too) diff --git a/modules/f.py b/modules/f.py new file mode 100644 index 0000000..fc38c58 --- /dev/null +++ b/modules/f.py @@ -0,0 +1,52 @@ +import os +from random import randint + +import aiohttp +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +async def download_sticker(url, filename): + headers = { + '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;q=0.8', + 'cache-control': 'no-cache', + 'dnt': '1', + 'pragma': 'no-cache', + 'priority': 'u=0, i', + 'sec-ch-ua': '"Not)A;Brand";v="8", "Chromium";v="138", "Microsoft Edge";v="138"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Windows"', + 'sec-fetch-dest': 'document', + 'sec-fetch-mode': 'navigate', + 'sec-fetch-site': 'none', + 'sec-fetch-user': '?1', + 'upgrade-insecure-requests': '1', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36 Edg/138.0.0.0', + } + cookies = {'country': 'US', 'lang': 'en'} + + async with aiohttp.ClientSession() as session, session.get(url, headers=headers, cookies=cookies) as response: + if response.status == 200: + with open(filename, 'wb') as f: + f.write(await response.read()) + + +@Client.on_message(filters.command(['f'], prefix) & filters.me) +async def random_stiker(client: Client, message: Message): + await message.delete() + random = randint(1, 63) # noqa: S311 + index = f'00{random}' if random < 10 else f'0{random}' + sticker = f'https://www.chpic.su/_data/stickers/f/FforRespect/FforRespect_{index}.webp' + await download_sticker(sticker, 'f.webp') + if os.path.exists('f.webp'): + await client.send_document( + message.chat.id, + 'f.webp', + reply_to_message_id=message.reply_to_message.id if message.reply_to_message else None, + ) + os.remove('f.webp') + + +modules_help['f'] = {'f': 'Send f to pay respect'} diff --git a/modules/fakeactions.py b/modules/fakeactions.py new file mode 100644 index 0000000..dc19a2c --- /dev/null +++ b/modules/fakeactions.py @@ -0,0 +1,80 @@ +from asyncio import sleep + +from pyrogram import Client, enums, filters +from pyrogram.raw import functions +from pyrogram.types import InputReplyToMessage, Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + +commands = { + 'ftype': enums.ChatAction.TYPING, + 'faudio': enums.ChatAction.UPLOAD_AUDIO, + 'fvideo': enums.ChatAction.UPLOAD_VIDEO, + 'fphoto': enums.ChatAction.UPLOAD_PHOTO, + 'fdocument': enums.ChatAction.UPLOAD_DOCUMENT, + 'flocation': enums.ChatAction.FIND_LOCATION, + 'frvideo': enums.ChatAction.RECORD_VIDEO, + 'frvoice': enums.ChatAction.RECORD_AUDIO, + 'frvideor': enums.ChatAction.RECORD_VIDEO_NOTE, + 'fvideor': enums.ChatAction.UPLOAD_VIDEO_NOTE, + 'fgame': enums.ChatAction.PLAYING, + 'fcontact': enums.ChatAction.CHOOSE_CONTACT, + 'fstop': enums.ChatAction.CANCEL, + 'fscrn': 'screenshot', +} + + +# noinspection PyUnusedLocal +@Client.on_message(filters.command(list(commands), prefix) & filters.me) +async def fakeactions_handler(client: Client, message: Message): + cmd = message.command[0] + try: + sec = int(message.command[1]) + if sec > 60: + sec = 60 + except Exception: + sec = None + await message.delete() + + action = commands[cmd] + + try: + if action != 'screenshot': + if sec and action != enums.ChatAction.CANCEL: + while sec > 0: + await client.send_chat_action(chat_id=message.chat.id, action=action) + await sleep(1) + sec -= 1 + return await client.send_chat_action(chat_id=message.chat.id, action=action) + else: + for _ in range(sec if sec else 1): + await client.invoke( + functions.messages.SendScreenshotNotification( + peer=await client.resolve_peer(message.chat.id), + reply_to=InputReplyToMessage(reply_to_message_id=message.reply_to_message.id), + random_id=client.rnd_id(), + ) + ) + await sleep(0.1) + except AttributeError: + return await client.send_message('me', 'Error in fakeactionsreply to message is required') + except Exception as e: + return await client.send_message('me', 'Error in fakeactions module:\n' + format_exc(e)) + + +modules_help['fakeactions'] = { + 'ftype [sec]': 'Typing... action', + 'faudio [sec]': 'Sending voice... action', + 'fvideo [sec]': 'Sending video... action', + 'fphoto [sec]': 'Sending photo... action', + 'fdocument [sec]': 'Sending document... action', + 'flocation [sec]': 'Find location... action', + 'fcontact [sec]': 'Sending contact... action', + 'frvideo [sec]': 'Recording video... action', + 'frvoice [sec]': 'Recording voice... action', + 'frvideor [sec]': 'Recording round video... action', + 'fvideor [sec]': 'Uploading round video... action', + 'fgame [sec]': 'Playing game... action', + 'fstop': 'Stop actions', + 'fscrn [amount] [reply_to_message]*': 'Make screenshot action', +} diff --git a/modules/filters.py b/modules/filters.py new file mode 100644 index 0000000..bda36d6 --- /dev/null +++ b/modules/filters.py @@ -0,0 +1,225 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, ContinuePropagation, errors, filters +from pyrogram.types import ( + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + Message, +) +from utils.db import db +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + + +def get_filters_chat(chat_id): + return db.get('core.filters', f'{chat_id}', {}) + + +def set_filters_chat(chat_id, filters_): + return db.set('core.filters', f'{chat_id}', filters_) + + +async def contains_filter(_, __, m): + return m.text and m.text.lower() in get_filters_chat(m.chat.id) + + +contains = filters.create(contains_filter) + + +# noinspection PyTypeChecker +@Client.on_message(contains) +async def filters_main_handler(client: Client, message: Message): + value = get_filters_chat(message.chat.id)[message.text.lower()] + try: + await client.get_messages(int(value['CHAT_ID']), int(value['MESSAGE_ID'])) + except errors.RPCError as exc: + raise ContinuePropagation from exc + + if value.get('MEDIA_GROUP'): + messages_grouped = await client.get_media_group(int(value['CHAT_ID']), int(value['MESSAGE_ID'])) + media_grouped_list = [] + for _ in messages_grouped: + if _.photo: + if _.caption: + media_grouped_list.append(InputMediaPhoto(_.photo.file_id, _.caption.HTML)) + else: + media_grouped_list.append(InputMediaPhoto(_.photo.file_id)) + elif _.video: + if _.caption: + if _.video.thumbs: + media_grouped_list.append( + InputMediaVideo( + _.video.file_id, + _.video.thumbs[0].file_id, + _.caption.HTML, + ) + ) + else: + media_grouped_list.append(InputMediaVideo(_.video.file_id, _.caption.HTML)) + elif _.video.thumbs: + media_grouped_list.append(InputMediaVideo(_.video.file_id, _.video.thumbs[0].file_id)) + else: + media_grouped_list.append(InputMediaVideo(_.video.file_id)) + elif _.audio: + if _.caption: + media_grouped_list.append(InputMediaAudio(_.audio.file_id, _.caption.HTML)) + else: + media_grouped_list.append(InputMediaAudio(_.audio.file_id)) + elif _.document: + if _.caption: + if _.document.thumbs: + media_grouped_list.append( + InputMediaDocument( + _.document.file_id, + _.document.thumbs[0].file_id, + _.caption.HTML, + ) + ) + else: + media_grouped_list.append(InputMediaDocument(_.document.file_id, _.caption.HTML)) + elif _.document.thumbs: + media_grouped_list.append(InputMediaDocument(_.document.file_id, _.document.thumbs[0].file_id)) + else: + media_grouped_list.append(InputMediaDocument(_.document.file_id)) + await client.send_media_group(message.chat.id, media_grouped_list, reply_to_message_id=message.id) + else: + await client.copy_message( + message.chat.id, + int(value['CHAT_ID']), + int(value['MESSAGE_ID']), + reply_to_message_id=message.id, + ) + raise ContinuePropagation + + +@Client.on_message(filters.command(['filter'], prefix) & filters.me) +async def filter_handler(client: Client, message: Message): + try: + if len(message.text.split()) < 2: + return await message.edit(f'Usage: {prefix}filter [name] (Reply required)') + name = message.text.split(maxsplit=1)[1].lower() + chat_filters = get_filters_chat(message.chat.id) + if name in chat_filters: + return await message.edit(f'Filter {name} already exists.') + if not message.reply_to_message: + return await message.edit('Reply to message please.') + + try: + chat = await client.get_chat(db.get('core.notes', 'chat_id', 0)) + except (errors.RPCError, ValueError, KeyError): + # group is not accessible or isn't created + chat = await client.create_supergroup('Userbot_Notes_Filters', "Don't touch this group, please") + db.set('core.notes', 'chat_id', chat.id) + + chat_id = chat.id + + if message.reply_to_message.media_group_id: + get_media_group = [_.id for _ in await client.get_media_group(message.chat.id, message.reply_to_message.id)] + try: + message_id = await client.forward_messages(chat_id, message.chat.id, get_media_group) + except errors.ChatForwardsRestricted: + await message.edit('Forwarding messages is restricted by chat admins') + return + filter_ = { + 'MESSAGE_ID': str(message_id[1].id), + 'MEDIA_GROUP': True, + 'CHAT_ID': str(chat_id), + } + else: + try: + message_id = await message.reply_to_message.forward(chat_id) + except errors.ChatForwardsRestricted: + message_id = await message.copy(chat_id) + filter_ = { + 'MEDIA_GROUP': False, + 'MESSAGE_ID': str(message_id.id), + 'CHAT_ID': str(chat_id), + } + + chat_filters.update({name: filter_}) + + set_filters_chat(message.chat.id, chat_filters) + return await message.edit( + f'Filter {name} has been added.', + ) + except Exception as e: + return await message.edit(format_exc(e)) + + +@Client.on_message(filters.command(['filters'], prefix) & filters.me) +async def filters_handler(_, message: Message): + try: + text = '' + for index, a in enumerate(get_filters_chat(message.chat.id).items(), start=1): + key, _ = a + key = key.replace('<', '').replace('>', '') + text += f'{index}. {key}\n' + text = f'Your filters in current chat:\n\n{text}' + text = text[:4096] + return await message.edit(text) + except Exception as e: + return await message.edit(format_exc(e)) + + +@Client.on_message(filters.command(['delfilter', 'filterdel', 'fdel'], prefix) & filters.me) +async def filter_del_handler(_, message: Message): + try: + if len(message.text.split()) < 2: + return await message.edit( + f'Usage: {prefix}fdel [name]', + ) + name = message.text.split(maxsplit=1)[1].lower() + chat_filters = get_filters_chat(message.chat.id) + if name not in chat_filters: + return await message.edit( + f"Filter {name} doesn't exists.", + ) + del chat_filters[name] + set_filters_chat(message.chat.id, chat_filters) + return await message.edit( + f'Filter {name} has been deleted.', + ) + except Exception as e: + return await message.edit(format_exc(e)) + + +@Client.on_message(filters.command(['fsearch'], prefix) & filters.me) +async def filter_search_handler(_, message: Message): + try: + if len(message.text.split()) < 2: + return await message.edit( + f'Usage: {prefix}fsearch [name]', + ) + name = message.text.split(maxsplit=1)[1].lower() + chat_filters = get_filters_chat(message.chat.id) + if name not in chat_filters: + return await message.edit( + f"Filter {name} doesn't exists.", + ) + return await message.edit(f'Trigger:\n{name}\nAnswer:\n{chat_filters[name]}') + except Exception as e: + return await message.edit(format_exc(e)) + + +modules_help['filters'] = { + 'filter [name]': 'Create filter (Reply required)', + 'filters': 'List of all triggers', + 'fdel [name]': 'Delete filter by name', + 'fsearch [name]': 'Info filter by name', +} diff --git a/modules/fliptext.py b/modules/fliptext.py new file mode 100644 index 0000000..b85f36a --- /dev/null +++ b/modules/fliptext.py @@ -0,0 +1,100 @@ +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +REPLACEMENT_MAP = { + 'a': 'ɐ', + 'b': 'q', + 'c': 'ɔ', + 'd': 'p', + 'e': 'ǝ', + 'f': 'ɟ', + 'g': 'ƃ', + 'h': 'ɥ', + 'i': 'ᴉ', + 'j': 'ɾ', + 'k': 'ʞ', + 'l': 'l', + 'm': 'ɯ', + 'n': 'u', + 'o': 'o', + 'p': 'd', + 'q': 'b', + 'r': 'ɹ', + 's': 's', + 't': 'ʇ', + 'u': 'n', + 'v': 'ʌ', + 'w': 'ʍ', + 'x': 'x', + 'y': 'ʎ', + 'z': 'z', + 'A': '∀', + 'B': 'B', + 'C': 'Ɔ', + 'D': 'D', + 'E': 'Ǝ', + 'F': 'Ⅎ', + 'G': 'פ', + 'H': 'H', + 'I': 'I', + 'J': 'ſ', + 'K': 'K', + 'L': '˥', + 'M': 'W', + 'N': 'N', + 'O': 'O', + 'P': 'Ԁ', + 'Q': 'Q', + 'R': 'R', + 'S': 'S', + 'T': '┴', + 'U': '∩', + 'V': 'Λ', + 'W': 'M', + 'X': 'X', + 'Y': '⅄', + 'Z': 'Z', + '0': '0', + '1': 'Ɩ', + '2': 'ᄅ', + '3': 'Ɛ', + '4': 'ㄣ', + '5': 'ϛ', + '6': '9', + '7': 'ㄥ', + '8': '8', + '9': '6', + ',': "'", + '.': '˙', + '?': '¿', + '!': '¡', + '"': ',,', + "'": ',', + '(': ')', + ')': '(', + '[': ']', + ']': '[', + '{': '}', + '}': '{', + '<': '>', + '>': '<', + '&': '⅋', + '_': '‾', +} + + +@Client.on_message(filters.command('flip', prefix) & filters.me) +async def flip(client: Client, message: Message): + text = ' '.join(message.command[1:]) + final_str = '' + for char in text: + new_char = REPLACEMENT_MAP.get(char, char) + final_str += new_char + if text != final_str: + await message.edit(final_str) + else: + await message.edit(text) + + +modules_help['fliptext'] = {'flip [amount]*': 'flip text upside down'} diff --git a/modules/flux.py b/modules/flux.py new file mode 100644 index 0000000..4244a15 --- /dev/null +++ b/modules/flux.py @@ -0,0 +1,51 @@ +import os +import time + +import requests +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, progress + + +def schellwithflux(args): + API_URL = 'https://randydev-ryuzaki-api.hf.space/api/v1/akeno/fluxai' + payload = {'user_id': 1191668125, 'args': args} # Please don't edit here + response = requests.post(API_URL, json=payload, timeout=10) + if response.status_code != 200: + print(f'Error status {response.status_code}') + return None + return response.content + + +@Client.on_message(filters.command('fluxai', prefix) & filters.me) +async def imgfluxai_(client: Client, message: Message): + question = message.text.split(' ', 1)[1] if len(message.command) > 1 else None + if not question: + return await message.reply_text('Please provide a question for Flux.') + try: + image_bytes = schellwithflux(question) + if image_bytes is None: + return await message.reply_text('Failed to generate an image.') + pro = await message.reply_text('Generating image, please wait...') + + # Write the image bytes directly to a file + with open('flux_gen.jpg', 'wb') as f: + f.write(image_bytes) + + ok = await pro.edit_text('Uploading image...') + await message.reply_photo( + 'flux_gen.jpg', + progress=progress, + progress_args=(ok, time.time(), 'Uploading image...'), + ) + await ok.delete() + if os.path.exists('flux_gen.jpg'): + os.remove('flux_gen.jpg') + except Exception as e: + await message.edit_text(format_exc(e)) + + +modules_help['fluxai'] = { + 'fluxai [prompt]*': 'text to image fluxai', +} diff --git a/modules/gemini.py b/modules/gemini.py new file mode 100644 index 0000000..5432f6e --- /dev/null +++ b/modules/gemini.py @@ -0,0 +1,62 @@ +# This scripts contains use cases for userbots +# This is used on my Moon-Userbot: https://github.com/The-MoonTg-project/Moon-Userbot +# YOu can check it out for uses example + +from pyrogram import Client, enums, filters +from pyrogram.errors import MessageTooLong +from pyrogram.types import Message +from utils.config import gemini_key +from utils.misc import modules_help, prefix +from utils.rentry import paste as rentry_paste +from utils.scripts import format_exc, import_library + +genai = import_library('google.generativeai', 'google-generativeai') + +genai.configure(api_key=gemini_key) + +model = genai.GenerativeModel('gemini-2.0-flash') + + +@Client.on_message(filters.command('gemini', prefix) & filters.me) +async def say(client: Client, message: Message): + try: + await message.edit_text('Please Wait...') + + if len(message.command) > 1: + prompt = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + prompt = message.reply_to_message.text + else: + await message.edit_text(f'Usage: {prefix}gemini [prompt/reply to message]') + return + + chat = model.start_chat() + response = chat.send_message(prompt) + + await message.edit_text( + f'**Question:**`{prompt}`\n**Answer:** {response.text}', + parse_mode=enums.ParseMode.MARKDOWN, + ) + except MessageTooLong: + await message.edit_text('Output is too long... Pasting to rentry...') + try: + rentry_url, edit_code = await rentry_paste(text=response.text, return_edit=True) + except RuntimeError: + await message.edit_text('Error: Failed to paste to rentry') + return + await client.send_message( + 'me', + f"Here's your edit code for Url: {rentry_url}\nEdit code: {edit_code}", + disable_web_page_preview=True, + ) + await message.edit_text( + f'Output: {rentry_url}\nNote: Edit Code has been sent to your saved messages', + disable_web_page_preview=True, + ) + except Exception as e: + await message.edit_text(f'An error occurred: {format_exc(e)}') + + +modules_help['gemini'] = { + 'gemini [prompt]*': 'Ask questions with Gemini Ai', +} diff --git a/modules/google.py b/modules/google.py new file mode 100644 index 0000000..3832822 --- /dev/null +++ b/modules/google.py @@ -0,0 +1,42 @@ +# Dragon-Userbot - telegram userbot +# Copyright (C) 2020-present Dragon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['google', 'g'], prefix) & filters.me) +async def webshot(_, message: Message): + user_request = ' '.join(message.command[1:]) + + if user_request == '': + if message.reply_to_message: + reply_user_request = message.reply_to_message.text + request = reply_user_request.replace(' ', '+') + full_request = f'https://lmgtfy.app/?s=g&iie=1&q={request}' + await message.edit( + f'{reply_user_request}', + disable_web_page_preview=True, + ) + + else: + request = user_request.replace(' ', '+') + full_request = f'https://lmgtfy.app/?s=g&iie=1&q={request}' + await message.edit(f'{user_request}', disable_web_page_preview=True) + + +modules_help['google'] = {'google [request]': "To teach the interlocutor to use Google. Request isn't required."} diff --git a/modules/hearts.py b/modules/hearts.py new file mode 100644 index 0000000..4b4f128 --- /dev/null +++ b/modules/hearts.py @@ -0,0 +1,91 @@ +import asyncio +import random + +from pyrogram import Client, filters +from pyrogram.errors.exceptions.flood_420 import FloodWait +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +R = '❤️' +W = '🤍' + +heart_list = [ + W * 9, + W * 2 + R * 2 + W + R * 2 + W * 2, + W + R * 7 + W, + W + R * 7 + W, + W + R * 7 + W, + W * 2 + R * 5 + W * 2, + W * 3 + R * 3 + W * 3, + W * 4 + R + W * 4, + W * 9, +] +joined_heart = '\n'.join(heart_list) + +heartlet_len = joined_heart.count(R) + +SLEEP = 0.1 + + +async def _wrap_edit(message: Message, text: str): + """Floodwait-safe utility wrapper for edit""" + try: + await message.edit(text) + except FloodWait as fl: + await asyncio.sleep(fl.x) + + +async def phase1(message: Message): + """Big scroll""" + BIG_SCROLL = '🧡💛💚💙💜🖤🤎' + await _wrap_edit(message, joined_heart) + for heart in BIG_SCROLL: + await _wrap_edit(message, joined_heart.replace(R, heart)) + await asyncio.sleep(SLEEP) + + +async def phase2(message: Message): + """Per-heart randomiser""" + ALL = ['❤️'] + list('🧡💛💚💙💜🤎🖤') # don't include white heart + + format_heart = joined_heart.replace(R, '{}') + for _ in range(5): + heart = format_heart.format(*random.choices(ALL, k=heartlet_len)) # noqa: S311 + await _wrap_edit(message, heart) + await asyncio.sleep(SLEEP) + + +async def phase3(message: Message): + """Fill up heartlet matrix""" + await _wrap_edit(message, joined_heart) + await asyncio.sleep(SLEEP * 2) + repl = joined_heart + for _ in range(joined_heart.count(W)): + repl = repl.replace(W, R, 1) + await _wrap_edit(message, repl) + await asyncio.sleep(SLEEP) + + +async def phase4(message: Message): + """Matrix shrinking""" + for i in range(7, 0, -1): + heart_matrix = '\n'.join([R * i] * i) + await _wrap_edit(message, heart_matrix) + await asyncio.sleep(SLEEP) + + +@Client.on_message(filters.command('hearts', prefix) & filters.me) +async def hearts(client: Client, message: Message): + await phase1(message) + await phase2(message) + await phase3(message) + await phase4(message) + await asyncio.sleep(SLEEP * 3) + + final_caption = ' '.join(message.command[1:]) + if not final_caption: + final_caption = '💕 by @moonuserbot' + await message.edit(final_caption) + + +modules_help['hearts'] = {'hearts': 'Heart animation. May cause floodwaits, use at your own risk!'} diff --git a/modules/help.py b/modules/help.py new file mode 100644 index 0000000..01aea3a --- /dev/null +++ b/modules/help.py @@ -0,0 +1,81 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.module import ModuleManager +from utils.scripts import format_module_help, with_reply + +module_manager = ModuleManager.get_instance() + + +@Client.on_message(filters.command(['help', 'h'], prefix) & filters.me) +async def help_cmd(_, message: Message): + if not module_manager.help_navigator: + await message.edit('Help system is not initialized yet. Please wait...') + return + + if len(message.command) == 1: + await module_manager.help_navigator.send_page(message) + elif message.command[1].lower() in modules_help: + await message.edit(format_module_help(message.command[1].lower(), prefix)) + else: + command_name = message.command[1].lower() + module_found = False + for module_name, commands in modules_help.items(): + for command in commands: + if command.split()[0] == command_name: + cmd = command.split(maxsplit=1) + cmd_desc = commands[command] + module_found = True + return await message.edit( + f'Help for command {prefix}{command_name}\n' + f'Module: {module_name} ({prefix}help {module_name})\n\n' + f'{prefix}{cmd[0]}' + f'{" " + cmd[1] + "" if len(cmd) > 1 else ""}' + f' — {cmd_desc}', + ) + if not module_found: + await message.edit(f'Module or command {command_name} not found') + + +@Client.on_message(filters.command(['pn', 'pp', 'pq'], prefix) & filters.me) +@with_reply +async def handle_navigation(_, message: Message): + if not module_manager.help_navigator: + await message.edit('Help system is not initialized yet. Please wait...') + return + + reply_message = message.reply_to_message + if reply_message and 'Help Page No:' in message.reply_to_message.text: + cmd = message.command[0].lower() + if cmd == 'pn': + if module_manager.help_navigator.next_page(): + await module_manager.help_navigator.send_page(reply_message) + return await message.delete() + await message.edit('No more pages available.') + elif cmd == 'pp': + if module_manager.help_navigator.prev_page(): + await module_manager.help_navigator.send_page(reply_message) + return await message.delete() + return await message.edit('This is the first page.') + elif cmd == 'pq': + await reply_message.delete() + return await message.edit('Help closed.') + + +modules_help['help'] = { + 'help [module/command name]': 'Get common/module/command help', + 'pn/pp/pq': 'Navigate through help pages' + ' (pn: next page, pp: previous page, pq: quit help)', +} diff --git a/modules/huggingface.py b/modules/huggingface.py new file mode 100644 index 0000000..e93b071 --- /dev/null +++ b/modules/huggingface.py @@ -0,0 +1,190 @@ +import asyncio +import io +import logging +import os +import time +from concurrent.futures import ThreadPoolExecutor + +import aiohttp +from PIL import Image +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.db import db +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +async def query_huggingface(payload): + api_key = db.get('custom.hf', 'api_key', None) + model = db.get('custom.hf', 'current_model', None) + + if not api_key: + raise ValueError(f'API key not set. Use {prefix}set_hf api to set it.') + if not model: + raise ValueError(f'Model not set. Use {prefix}set_hf model to set it.') + + api_url = f'https://api-inference.huggingface.co/models/{model}' + headers = {'Authorization': f'Bearer {api_key}'} + timeout = aiohttp.ClientTimeout(total=120) + start_time = time.time() + retries = 3 + + for attempt in range(1, retries + 1): + try: + async with ( + aiohttp.ClientSession(timeout=timeout) as session, + session.post(api_url, headers=headers, json=payload) as response, + ): + fetch_time = int((time.time() - start_time) * 1000) + if response.status != 200: + error_text = await response.text() + logger.error(f'API Error {response.status}: {error_text}') + return None, fetch_time + return await response.read(), fetch_time + except TimeoutError: + logger.error(f'TimeoutError: Attempt {attempt}/{retries} timed out.') + if attempt == retries: + raise + except asyncio.CancelledError: + logger.error('Request was cancelled. Ensure the task is not being forcefully terminated.') + raise + except aiohttp.ClientError as e: + logger.error(f'Network Error: {e}') + if attempt == retries: + raise + finally: + await asyncio.sleep(2) + + +async def save_image(image_bytes, path): + loop = asyncio.get_event_loop() + with ThreadPoolExecutor() as pool: + await loop.run_in_executor(pool, lambda: Image.open(io.BytesIO(image_bytes)).save(path)) + + +@Client.on_message(filters.command(['set_hf'], prefix) & filters.me) +async def manage_huggingface(_, message: Message): + """Manage Hugging Face API key and models.""" + subcommand = message.command[1].lower() if len(message.command) > 1 else None + arg = message.command[2] if len(message.command) > 2 else None + + if subcommand == 'api': + if arg: + db.set('custom.hf', 'api_key', arg) + return await message.edit_text(f'Hugging Face API key set successfully.\nAPI Key: {arg}') + return await message.edit_text(f'Usage: {prefix}hf api ') + + if subcommand == 'model': + if arg: + models = db.get('custom.hf', 'models', []) + if arg not in models: + models.append(arg) + db.set('custom.hf', 'models', models) + db.set('custom.hf', 'current_model', arg) + return await message.edit_text(f"Model '{arg}' added and set as the current model.") + return await message.edit_text(f'Usage: {prefix}hf model ') + + if subcommand == 'select': + models = db.get('custom.hf', 'models', []) + if arg and arg.lower() == 'all': + db.set('custom.hf', 'current_model', 'all') + model_list = '\n'.join([f'*{i + 1}. {m}' for i, m in enumerate(models)]) + return await message.edit_text( + f'All models selected:\n{model_list}\n\nImages will be generated from all models.' + ) + if arg: + try: + index = int(arg) - 1 + if 0 <= index < len(models): + db.set('custom.hf', 'current_model', models[index]) + return await message.edit_text(f"Model set to '{models[index]}'.") + return await message.edit_text('Invalid model number.') + except ValueError: + return await message.edit_text('Invalid model number. Use a valid integer.') + return await message.edit_text(f'Usage: {prefix}hf select ') + + if subcommand == 'delete' and arg: + try: + index = int(arg) - 1 + models = db.get('custom.hf', 'models', []) + if 0 <= index < len(models): + removed_model = models.pop(index) + db.set('custom.hf', 'models', models) + if db.get('custom.hf', 'current_model') == removed_model: + db.set('custom.hf', 'current_model', models[0] if models else 'None') + return await message.edit_text(f"Model '{removed_model}' deleted.") + return await message.edit_text('Invalid model number.') + except ValueError: + return await message.edit_text('Invalid model number. Use a valid integer.') + + api_key = db.get('custom.hf', 'api_key', None) + models = db.get('custom.hf', 'models', []) + current_model = db.get('custom.hf', 'current_model', 'Not set') + model_list = '\n'.join( + [f'{"*" if m == current_model or current_model == "all" else ""}{i + 1}. {m}' for i, m in enumerate(models)] + ) + settings = ( + f'Hugging Face settings:\n' + f'API Key:\n{api_key if api_key else "Not set"}\n\n' + f'Available Models:\n{model_list}' + ) + usage_message = ( + f'{settings}\n\nUsage:\n' + f'{prefix}set_hf api, model, select, delete, select all' + ) + await message.edit_text(usage_message) + + +@Client.on_message(filters.command(['hf', 'hface', 'huggingface'], prefix)) +async def imgflux_(_, message: Message): + prompt = message.text.split(' ', 1)[1] if len(message.command) > 1 else None + if not prompt: + usage_message = f'Usage: {prefix}{message.command[0]} [custom prompt]' + return await (message.edit_text if message.from_user.is_self else message.reply_text)(usage_message) + + processing_message = await (message.edit_text if message.from_user.is_self else message.reply_text)('Processing...') + + try: + current_model = db.get('custom.hf', 'current_model', None) + models = db.get('custom.hf', 'models', []) + models_to_use = models if current_model == 'all' else [current_model] + + generated_images = [] + + for model in models_to_use: + db.set('custom.hf', 'current_model', model) + payload = {'inputs': prompt} + image_bytes, fetch_time = await query_huggingface(payload) + if not image_bytes: + logger.warning(f'Failed to fetch image for model: {model}') + continue + + image_path = f'hf_flux_gen_{model.replace("/", "_")}.jpg' + await save_image(image_bytes, image_path) + generated_images.append((image_path, model, fetch_time)) + + if not generated_images: + return await processing_message.edit_text('Failed to generate an image for all models.') + + for image_path, model_name, fetch_time in generated_images: + caption = f'**Model:**\n> {model_name}\n**Prompt used:**\n> {prompt}\n\n**Fetching Time:** {fetch_time} ms' + await message.reply_photo(image_path, caption=caption, parse_mode=enums.ParseMode.MARKDOWN) + os.remove(image_path) + + except Exception as e: + logger.error(f'Unexpected Error: {e}') + await processing_message.edit_text(format_exc(e)) + finally: + await processing_message.delete() + + +modules_help['huggingface'] = { + 'hf [prompt]*': 'Generate an AI image using Hugging Face model(s).', + 'set_hf *': 'Set the Hugging Face API key.', + 'set_hf model *': 'Add and set a Hugging Face model.', + 'set_hf select *': 'Select a specific model or all models for use.', + 'set_hf delete *': 'Delete a model from the list.', +} diff --git a/modules/id.py b/modules/id.py new file mode 100644 index 0000000..be07263 --- /dev/null +++ b/modules/id.py @@ -0,0 +1,87 @@ +#  Moon-Userbot - telegram userbot +#  Copyright (C) 2020-present Moon Userbot Organization +# +#  This program is free software: you can redistribute it and/or modify +#  it under the terms of the GNU General Public License as published by +#  the Free Software Foundation, either version 3 of the License, or +#  (at your option) any later version. + +#  This program is distributed in the hope that it will be useful, +#  but WITHOUT ANY WARRANTY; without even the implied warranty of +#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#  GNU General Public License for more details. + +#  You should have received a copy of the GNU General Public License +#  along with this program.  If not, see . + +from pyrogram import Client, enums, filters +from pyrogram.types import Message, MessageOriginHiddenUser +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('id', prefix) & filters.me) +async def ids(_, message: Message): + text = '\n'.join( + [ + f'Chat ID: `{message.chat.id}`', + f'Chat DC ID: `{message.chat.dc_id}`\n', + f'Message ID: `{message.id}`', + ( + f'Your ID: `{message.from_user.id}`' + if message.from_user + else f'Channel/Group ID: `{message.sender_chat.id}`' + ), + ( + f'Your DC ID: `{message.from_user.dc_id}`' + if message.from_user + else f'Channel/Group ID: `{message.sender_chat.id}`' + ), + ] + ) + + if rtm := message.reply_to_message: + # print(rtm) + text += f'\n\nReplied Message ID: `{rtm.id}`' + + if user := rtm.from_user: + text = '\n'.join( + [ + text, + f'Replied User ID: `{user.id}`', + f'Replied User DC ID: `{user.dc_id}`', + ] + ) + + else: + text = '\n'.join( + [ + text, + f'Replied Chat ID: `{rtm.sender_chat.id}`', + f'Replied Chat DC ID: `{rtm.sender_chat.dc_id}`', + ] + ) + + if rtm.forward_origin and rtm.forward_origin.date: + if isinstance(rtm.forward_origin, MessageOriginHiddenUser): + text = '\n'.join( + [ + text, + '\nForwarded from a hidden user.', + ] + ) + elif ffc := rtm.forward_origin.sender_user: + text = '\n'.join( + [ + text, + f'\nForwarded Message ID: `{getattr(rtm.forward_origin, "message_id", None)}`', + f'Forwarded from Chat ID: `{ffc.id}`', + f'Forwarded from Chat DC ID: `{ffc.dc_id}`', + ] + ) + + await message.edit('**__' + text + '__**', parse_mode=enums.ParseMode.MARKDOWN) + + +modules_help['id'] = { + 'id': 'simply run or reply to message', +} diff --git a/modules/joindate.py b/modules/joindate.py new file mode 100644 index 0000000..df87dfd --- /dev/null +++ b/modules/joindate.py @@ -0,0 +1,36 @@ +import os +from datetime import datetime + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('joindate', prefix) & filters.me) +async def joindate(client: Client, message: Message): + await message.edit('One moment...') + members = [] + cgetmsg = await client.get_messages(message.chat.id, 1) + async for m in client.iter_chat_members(message.chat.id): + members.append( + ( + m.user.first_name, + m.joined_date or cgetmsg.date, + ) + ) + + members.sort(key=lambda member: member[1]) + + with open('joined_date.txt', 'w', encoding='utf8') as f: + f.write('Join Date First Name\n') + for member in members: + f.write(str(datetime.fromtimestamp(member[1]).strftime('%y-%m-%d %H:%M')) + f' {member[0]}\n') + + await message.delete() + await client.send_document(message.chat.id, 'joined_date.txt') + os.remove('joined_date.txt') + + +modules_help['joindate'] = { + 'joindate': 'Get a list of all chat members and sort them by the date they joined the group' +} diff --git a/modules/kokodrilo_explodando.py b/modules/kokodrilo_explodando.py new file mode 100644 index 0000000..0399614 --- /dev/null +++ b/modules/kokodrilo_explodando.py @@ -0,0 +1,23 @@ +import asyncio +import random + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('kokodrilo', prefix) & filters.me) +async def kokodrilo_explodando(_, message: Message): + amount = 1 + if len(message.command) > 1: + amount = int(message.command[1]) + for _ in range(amount): + await message.edit('🐊') + await asyncio.sleep(random.uniform(1, 2.5)) # noqa: S311 + await message.edit('💥') + await asyncio.sleep(1.8) + + +modules_help['kokodrilo_explodando'] = { + 'kokodrilo [number of explosions]': 'kOkOdRiLo ExPlOrAdO', +} diff --git a/modules/leave_chat.py b/modules/leave_chat.py new file mode 100644 index 0000000..aa473d8 --- /dev/null +++ b/modules/leave_chat.py @@ -0,0 +1,36 @@ +# Dragon-Userbot - telegram userbot +# Copyright (C) 2020-present Dragon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['leave_chat', 'lc'], prefix) & filters.me) +async def leave_chat(_, message: Message): + if message.chat.type != 'private': + await message.edit('Goodbye...') + await asyncio.sleep(3) + await message.chat.leave() + else: + await message.edit('Not supported in private chats') + + +modules_help['leave_chat'] = { + 'leave_chat': 'Quit chat', +} diff --git a/modules/loader.py b/modules/loader.py new file mode 100644 index 0000000..7816d25 --- /dev/null +++ b/modules/loader.py @@ -0,0 +1,318 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import hashlib +import os +import shutil +import subprocess +import sys + +import requests +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.db import db +from utils.misc import modules_help, prefix +from utils.scripts import restart + +BASE_PATH = os.path.abspath(os.getcwd()) +CATEGORIES = [ + 'ai', + 'dl', + 'admin', + 'anime', + 'fun', + 'images', + 'info', + 'misc', + 'music', + 'news', + 'paste', + 'rev', + 'tts', + 'utils', +] + + +@Client.on_message(filters.command(['modhash', 'mh'], prefix) & filters.me) +async def get_mod_hash(_, message: Message): + if len(message.command) == 1: + return + url = message.command[1].lower() + resp = requests.get(url, timeout=10) + if not resp.ok: + await message.edit(f'Troubleshooting with downloading module {url}') + return + + await message.edit( + f'Module hash: {hashlib.sha256(resp.content).hexdigest()}\n' + f'Link: {url}\nFile: {url.split("/")[-1]}', + ) + + +@Client.on_message(filters.command(['loadmod', 'lm'], prefix) & filters.me) +async def loadmod(_, message: Message): + if ( + not ( + message.reply_to_message + and message.reply_to_message.document + and message.reply_to_message.document.file_name.endswith('.py') + ) + and len(message.command) == 1 + ): + await message.edit('Specify module to download') + return + + if len(message.command) > 1: + await message.edit('Fetching module...') + url = message.command[1].lower() + + if url.startswith('https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/'): + module_name = url.split('/')[-1].split('.')[0] + elif '.' not in url: + module_name = url.lower() + try: + f = requests.get( + 'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/full.txt' + , timeout=10).text + except Exception: + return await message.edit('Failed to fetch custom modules list') + modules_dict = {line.split('/')[-1].split()[0]: line.strip() for line in f.splitlines()} + if module_name in modules_dict: + url = f'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/{modules_dict[module_name]}.py' + else: + await message.edit(f'Module {module_name} is not found') + return + else: + modules_hashes = requests.get( + 'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/modules_hashes.txt' + , timeout=10).text + resp = requests.get(url, timeout=10) + + if not resp.ok: + await message.edit( + f'Troubleshooting with downloading module {url}', + ) + return + + if hashlib.sha256(resp.content).hexdigest() not in modules_hashes: + return await message.edit( + 'Only ' + 'verified modules or from the official ' + '' + 'custom_modules repository are supported!', + disable_web_page_preview=True, + ) + + module_name = url.split('/')[-1].split('.')[0] + + resp = requests.get(url, timeout=10) + if not resp.ok: + await message.edit(f'Module {module_name} is not found') + return + + if not os.path.exists(f'{BASE_PATH}/modules/custom_modules'): + os.mkdir(f'{BASE_PATH}/modules/custom_modules') + + with open(f'./modules/custom_modules/{module_name}.py', 'wb') as f: + f.write(resp.content) + else: + file_name = await message.reply_to_message.download() + module_name = message.reply_to_message.document.file_name[:-3] + + with open(file_name, 'rb') as f: + content = f.read() + + modules_hashes = requests.get( + 'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/modules_hashes.txt' + , timeout=10).text + + if hashlib.sha256(content).hexdigest() not in modules_hashes: + os.remove(file_name) + return await message.edit( + 'Only ' + 'verified modules or from the official ' + '' + 'custom_modules repository are supported!', + disable_web_page_preview=True, + ) + os.rename(file_name, f'./modules/custom_modules/{module_name}.py') + + all_modules = db.get('custom.modules', 'allModules', []) + if module_name not in all_modules: + all_modules.append(module_name) + db.set('custom.modules', 'allModules', all_modules) + await message.edit(f'The module {module_name} is loaded!\nRestarting...') + db.set( + 'core.updater', + 'restart_info', + { + 'type': 'restart', + 'chat_id': message.chat.id, + 'message_id': message.id, + }, + ) + restart() + + +@Client.on_message(filters.command(['unloadmod', 'ulm'], prefix) & filters.me) +async def unload_mods(_, message: Message): + if len(message.command) <= 1: + return + + module_name = message.command[1].lower() + + if module_name.startswith('https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/'): + module_name = module_name.split('/')[-1].split('.')[0] + + if os.path.exists(f'{BASE_PATH}/modules/custom_modules/{module_name}.py'): + os.remove(f'{BASE_PATH}/modules/custom_modules/{module_name}.py') + if module_name == 'musicbot': + subprocess.run( + [sys.executable, '-m', 'pip', 'uninstall', '-y', 'requirements.txt'], + cwd=f'{BASE_PATH}/musicbot', + ) + shutil.rmtree(f'{BASE_PATH}/musicbot') + all_modules = db.get('custom.modules', 'allModules', []) + if module_name in all_modules: + all_modules.remove(module_name) + db.set('custom.modules', 'allModules', all_modules) + await message.edit(f'The module {module_name} removed!\nRestarting...') + db.set( + 'core.updater', + 'restart_info', + { + 'type': 'restart', + 'chat_id': message.chat.id, + 'message_id': message.id, + }, + ) + restart() + elif os.path.exists(f'{BASE_PATH}/modules/{module_name}.py'): + await message.edit('It is forbidden to remove built-in modules, it will disrupt the updater') + else: + await message.edit(f'Module {module_name} is not found') + + +@Client.on_message(filters.command(['loadallmods', 'lmall'], prefix) & filters.me) +async def load_all_mods(_, message: Message): + await message.edit('Fetching info...') + + if not os.path.exists(f'{BASE_PATH}/modules/custom_modules'): + os.mkdir(f'{BASE_PATH}/modules/custom_modules') + + try: + f = requests.get('https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/full.txt', timeout=10).text + except Exception: + return await message.edit('Failed to fetch custom modules list') + modules_list = f.splitlines() + + await message.edit('Loading modules...') + for module_name in modules_list: + url = f'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/{module_name}.py' + resp = requests.get(url, timeout=10) + if not resp.ok: + continue + with open(f'./modules/custom_modules/{module_name.split("/")[1]}.py', 'wb') as f: + f.write(resp.content) + + await message.edit( + f'Successfully loaded new modules: {len(modules_list)}\nRestarting...', + ) + db.set( + 'core.updater', + 'restart_info', + { + 'type': 'restart', + 'chat_id': message.chat.id, + 'message_id': message.id, + }, + ) + restart() + + +@Client.on_message(filters.command(['unloadallmods', 'ulmall'], prefix) & filters.me) +async def unload_all_mods(_, message: Message): + await message.edit('Fetching info...') + + if not os.path.exists(f'{BASE_PATH}/modules/custom_modules'): + return await message.edit("You don't have any modules installed") + shutil.rmtree(f'{BASE_PATH}/modules/custom_modules') + db.set('custom.modules', 'allModules', []) + await message.edit('Successfully unloaded all modules!\nRestarting...') + + db.set( + 'core.updater', + 'restart_info', + { + 'type': 'restart', + 'chat_id': message.chat.id, + 'message_id': message.id, + }, + ) + restart() + + +@Client.on_message(filters.command(['updateallmods'], prefix) & filters.me) +async def updateallmods(_, message: Message): + await message.edit('Updating modules...') + + if not os.path.exists(f'{BASE_PATH}/modules/custom_modules'): + os.mkdir(f'{BASE_PATH}/modules/custom_modules') + + modules_installed = list(os.walk('modules/custom_modules'))[0][2] + + if not modules_installed: + return await message.edit("You don't have any modules installed") + + for module_name in modules_installed: + if not module_name.endswith('.py'): + continue + try: + f = requests.get('https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/full.txt', timeout=10).text + except Exception: + return await message.edit('Failed to fetch custom modules list') + modules_dict = {line.split('/')[-1].split()[0]: line.strip() for line in f.splitlines()} + if module_name in modules_dict: + resp = requests.get( + f'https://raw.githubusercontent.com/The-MoonTg-project/custom_modules/main/{modules_dict[module_name]}.py' + , timeout=10) + if not resp.ok: + modules_installed.remove(module_name) + continue + + with open(f'./modules/custom_modules/{module_name}', 'wb') as f: + f.write(resp.content) + + await message.edit(f'Successfully updated {len(modules_installed)} modules') + + +modules_help['loader'] = { + 'loadmod [module_name]*': 'Download module.\n' + 'Only modules from the official custom_modules repository and proven ' + 'modules whose hashes are in modules_hashes.txt are supported', + 'unloadmod [module_name]*': 'Delete module', + 'modhash [link]*': 'Get module hash by link', + 'loadallmods': 'Load all custom modules (use it at your own risk)', + 'unloadallmods': 'Unload all custom modules', + 'updateallmods': 'Update all custom modules' + '\n\n* - required argument' + '\n short cmds:' + '\n loadmod - lm' + '\n unloadmod - ulm' + '\n modhash - mh' + '\n loadallmods - lmall' + '\n unloadallmods - ulmall', +} diff --git a/modules/markitdown.py b/modules/markitdown.py new file mode 100644 index 0000000..c7b272f --- /dev/null +++ b/modules/markitdown.py @@ -0,0 +1,49 @@ +#  Moon-Userbot - telegram userbot +#  Copyright (C) 2020-present Moon Userbot Organization +# +#  This program is free software: you can redistribute it and/or modify +#  it under the terms of the GNU General Public License as published by +#  the Free Software Foundation, either version 3 of the License, or +#  (at your option) any later version. + +#  This program is distributed in the hope that it will be useful, +#  but WITHOUT ANY WARRANTY; without even the implied warranty of +#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#  GNU General Public License for more details. + +#  You should have received a copy of the GNU General Public License +#  along with this program.  If not, see . + +import os + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help +from utils.scripts import import_library, prefix, with_reply + +import_library('markitdown') + +from markitdown import MarkItDown # noqa: E402 + + +@Client.on_message(filters.command(['markitdown', 'mkdn'], prefix) & filters.me) +@with_reply +async def markitdown(client: Client, message: Message): + if message.reply_to_message.document: + await message.edit('Converting to Markdown...') + file = await message.reply_to_message.download() + file_name = (message.reply_to_message.document.file_name).split('.')[0] + '.md' + markitdown = MarkItDown() + result = markitdown.convert(file) + with open(file_name, 'w') as f: + f.write(result.text_content) + await message.edit('Uploading...') + await client.send_document(message.chat.id, file_name, reply_to_message_id=message.reply_to_message.id) + os.remove(file) + os.remove(file_name) + await message.delete() + else: + await message.edit('Reply to a document to convert it to Markdown.') + + +modules_help['markitdown'] = {'markitdown': 'Convert a document to Markdown.'} diff --git a/modules/mention.py b/modules/mention.py new file mode 100644 index 0000000..b1d1719 --- /dev/null +++ b/modules/mention.py @@ -0,0 +1,65 @@ +#  Moon-Userbot - telegram userbot +#  Copyright (C) 2020-present Moon Userbot Organization +# +#  This program is free software: you can redistribute it and/or modify +#  it under the terms of the GNU General Public License as published by +#  the Free Software Foundation, either version 3 of the License, or +#  (at your option) any later version. + +#  This program is distributed in the hope that it will be useful, +#  but WITHOUT ANY WARRANTY; without even the implied warranty of +#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#  GNU General Public License for more details. + +#  You should have received a copy of the GNU General Public License +#  along with this program.  If not, see . + +from pyrogram import Client, filters +from pyrogram.types import Message +from pyrogram.types.user_and_chats.user import Link +from utils.misc import modules_help, prefix + + +def custom_mention(user, custom_text): + return Link( + f'tg://user?id={user.id}', + custom_text, + user._client.parse_mode, + ) + + +@Client.on_message(filters.command('mention', prefix) & filters.me) +async def example_edit(client: Client, message: Message): + chat_id = message.chat.id + if message.reply_to_message and not len(message.text.split()) > 1: + user = message.reply_to_message.from_user + custom_text = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else None + if custom_text: + await message.edit(custom_mention(user, custom_text)) + else: + await message.delete() + await client.send_message(chat_id, user.mention) + else: + if len(message.text.split()) > 1: + user_id = message.text.split()[1] + if user_id.isdigit(): + text = message.text.split(maxsplit=2)[2] if len(message.text.split()) > 2 else None + if text: + men = Link(f'tg://user?id={user_id}', text, client.parse_mode) + else: + men = (await client.get_users(user_id)).mention + await message.edit(men) + else: + await message.edit('Invalid user_id') + await message.delete() + else: + await message.edit('Reply to a message or provide a user_id') + await message.delete() + + +modules_help['mention'] = { + 'mention': 'Mention the user you replied to.', + 'mention [custom_text]': 'Mention the user you replied to with custom text.', + 'mention [user_id] [custom_text]': 'Mention a user by their user_id with custom text.', + 'mention [user_id]': 'Mention a user by their user_id.', +} diff --git a/modules/mirror_flip.py b/modules/mirror_flip.py new file mode 100644 index 0000000..65f1f78 --- /dev/null +++ b/modules/mirror_flip.py @@ -0,0 +1,57 @@ +# original module https://raw.githubusercontent.com/KeyZenD/modules/master/MirrorFlipV2.py | t.me/the_kzd +import os + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import import_library + +PIL = import_library('PIL', 'pillow') +from PIL import Image, ImageOps # noqa: E402 + + +async def make(client, message, o): + reply = message.reply_to_message + if reply.photo or reply.sticker: + if reply.photo: + downloads = await client.download_media(reply.photo.file_id) + else: + downloads = await client.download_media(reply.sticker.file_id) + path = f'{downloads}' + img = Image.open(path) + await message.delete() + w, h = img.size + if o in [1, 2]: + if o == 2: + img = ImageOps.mirror(img) + part = img.crop([0, 0, w // 2, h]) + img = ImageOps.mirror(img) + else: + if o == 4: + img = ImageOps.flip(img) + part = img.crop([0, 0, w, h // 2]) + img = ImageOps.flip(img) + img.paste(part, (0, 0)) + img.save(path) + if reply.photo: + return await reply.reply_photo(photo=path) + elif reply.sticker: + return await reply.reply_sticker(sticker=path) + os.remove(path) + + return await message.edit('Need to answer the photo/sticker', parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command(['ll', 'rr', 'dd', 'uu'], prefix) & filters.me) +async def mirror_flip(client: Client, message: Message): + await message.edit('Processing...', parse_mode=enums.ParseMode.HTML) + param = {'ll': 1, 'rr': 2, 'dd': 3, 'uu': 4}[message.command[0]] + await make(client, message, param) + + +modules_help['mirror_flip'] = { + 'll [reply on photo or sticker]*': 'reflects the left side', + 'rr [reply on photo or sticker]*': 'reflects the right side', + 'uu [reply on photo or sticker]*': 'reflects the top', + 'dd [reply on photo or sticker]*': 'reflects the bottom', +} diff --git a/modules/misc/autobackup.py b/modules/misc/autobackup.py new file mode 100644 index 0000000..9c44111 --- /dev/null +++ b/modules/misc/autobackup.py @@ -0,0 +1,57 @@ +from os import listdir + +from pyrogram import Client, enums, filters +from pyrogram.types import Message + +# noinspection PyUnresolvedReferences +from utils.misc import modules_help, prefix + +# noinspection PyUnresolvedReferences +from utils.scripts import format_exc + + +@Client.on_message(filters.command(['lback'], prefix) & filters.me) +async def backup_database_cmd(_: Client, message: Message): + """ + Backup the database. + """ + if len(message.command) == 1: + await message.edit("[😇] I think you didn't specify the name of the bot.") + return + await message.edit_text("I'm copying the database...", parse_mode=enums.ParseMode.HTML) + try: + name = message.command[1].lower() + folders = listdir('/root/') + if name not in folders: + await message.edit('[😇] There is no such bot in the root folder.') + return + folder = listdir('/root/' + name) + for file in folder: + if file.endswith(('.db', '.sqlite', '.sqlite3')): + await message.reply_document( + document='/root/' + name + '/' + file, + caption='Bot Database ' + name + '', + parse_mode=enums.ParseMode.HTML, + ) + return await message.delete() + folder = listdir('/root/' + name + '/assets') + for file in folder: + if file.endswith(('.db', '.sqlite', '.sqlite3')): + await message.reply_document( + document='/root/' + name + '/assets/' + file, + caption='Bot Database ' + name + '', + parse_mode=enums.ParseMode.HTML, + ) + return await message.delete() + await message.edit('[😇] Database not found.') + except Exception as ex: + await message.edit_text( + f'Failed to back up the database!\n\n{format_exc(ex)}', + parse_mode=enums.ParseMode.HTML, + ) + + +modules_help['autobackup'] = { + 'lback [name]*': 'Backup database from folder', + 'lbackall': 'Backup all databases', +} diff --git a/modules/misc/autofwd.py b/modules/misc/autofwd.py new file mode 100644 index 0000000..73b2285 --- /dev/null +++ b/modules/misc/autofwd.py @@ -0,0 +1,165 @@ +#  Moon-Userbot - telegram userbot +#  Copyright (C) 2020-present Moon Userbot Organization +# +#  This program is free software: you can redistribute it and/or modify +#  it under the terms of the GNU General Public License as published by +#  the Free Software Foundation, either version 3 of the License, or +#  (at your option) any later version. + +#  This program is distributed in the hope that it will be useful, +#  but WITHOUT ANY WARRANTY; without even the implied warranty of +#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#  GNU General Public License for more details. + +#  You should have received a copy of the GNU General Public License +#  along with this program.  If not, see . + +from pyrogram import Client, filters +from pyrogram.errors import MessageTooLong +from pyrogram.types import Message +from utils.db import db +from utils.misc import modules_help, prefix + + +def addtrg(channel_id): + channel_ids = db.get('custom.autofwd', 'chatto', default=[]) + if channel_id not in channel_ids: + channel_ids.append(channel_id) + db.set('custom.autofwd', 'chatto', channel_ids) + + +def rmtrg(channel_id): + channel_ids = db.get('custom.autofwd', 'chatto', default=[]) + if channel_id in channel_ids: + channel_ids.remove(channel_id) + db.set('custom.autofwd', 'chatto', channel_ids) + + +def addsrc(channel_id): + channel_ids = db.get('custom.autofwd', 'chatsrc', default=[]) + if channel_id not in channel_ids: + channel_ids.append(channel_id) + db.set('custom.autofwd', 'chatsrc', channel_ids) + + +def rmsrc(channel_id): + channel_ids = db.get('custom.autofwd', 'chatsrc', default=[]) + if channel_id in channel_ids: + channel_ids.remove(channel_id) + db.set('custom.autofwd', 'chatsrc', channel_ids) + + +def getfwd_data(): + source_chats = db.get('custom.autofwd', 'chatsrc') + target_chats = db.get('custom.autofwd', 'chatto') + return source_chats, target_chats + + +@Client.on_message(filters.command(['addfwd_src', 'addfwd_to'], prefix) & filters.me) +async def addfwd(_, message: Message): + if message.command[0] == 'addfwd_src': + if len(message.command) > 1: + channel_id = message.text.split(maxsplit=1)[1] + if not channel_id.startswith('-100'): + channel_id = '-100' + channel_id + # chat id should be integer + if not channel_id.isdigit(): + try: + channel_id = int(channel_id) + except Exception: + return await message.edit_text('Chat id should be in integer') + addsrc(channel_id=channel_id) + await message.edit_text(f'Auto Forwarding Enabled for Chat with id: {channel_id}') + else: + await message.edit_text('Chat id not provided!') + return + elif message.command[0] == 'addfwd_to': + if len(message.command) > 1: + channel_id = message.text.split(maxsplit=1)[1] + if not channel_id.startswith('-100'): + channel_id = '-100' + channel_id + # chat id should be integer + if not channel_id.isdigit(): + try: + channel_id = int(channel_id) + except Exception: + return await message.edit_text('Chat id should be in integer') + addtrg(channel_id=channel_id) + await message.edit_text(f'Auto Forwarding Enabled to Chat with id: {channel_id}') + else: + await message.edit_text('Chat id not provided!') + return + + +@Client.on_message(filters.command(['delfwd_src', 'delfwd_to'], prefix) & filters.me) +async def delfwd(_, message: Message): + if message.command[0] == 'delfwd_src': + if len(message.command) > 1: + channel_id = message.text.split(maxsplit=1)[1] + if not channel_id.startswith('-100'): + channel_id = '-100' + channel_id + # chat id should be integer + if not channel_id.isdigit(): + try: + channel_id = int(channel_id) + except Exception: + return await message.edit_text('Chat id should be in integer') + rmsrc(channel_id=channel_id) + await message.edit_text(f'Auto Forwarding Disabled for Chat with id: {channel_id}') + else: + await message.edit_text('Chat id not provided!') + return + elif message.command[0] == 'delfwd_to': + if len(message.command) > 1: + channel_id = message.text.split(maxsplit=1)[1] + if not channel_id.startswith('-100'): + channel_id = '-100' + channel_id + # chat id should be integer + if not channel_id.isdigit(): + try: + channel_id = int(channel_id) + except Exception: + return await message.edit_text('Chat id should be in integer') + rmtrg(channel_id=channel_id) + await message.edit_text(f'Auto Forwarding Disabled to Chat with id: {channel_id}') + else: + await message.edit_text('Chat id not provided!') + return + + +@Client.on_message(filters.command('autofwd', prefix) & filters.me) +async def autofwd(_, message: Message): + source_chats, target_chats = getfwd_data() + return await message.edit_text(f'Source Chats: {source_chats}\nTarget Chats: {target_chats}') + + +@Client.on_message(filters.channel | filters.group) +async def autofwd_main(client: Client, message: Message): + chat_id = message.chat.id + source_chats = db.get('custom.autofwd', 'chatsrc') + target_chats = db.get('custom.autofwd', 'chatto') + + if source_chats is not None and chat_id in source_chats and target_chats is not None: + for chat in target_chats: + try: + await message.copy(chat) + except Exception as e: + try: + await client.send_message( + 'me', + f'Auto Forwarding Failed for Chat with id: {chat_id} to {chat}\n\n{e}', + ) + except MessageTooLong: + await client.send_message( + 'me', + f'Auto Forwarding Failed for Chat with id: {chat_id} to {chat}, Please check logs!', + ) + + +modules_help['autofwd'] = { + 'autofwd': 'Retrieve Data of auto fwd', + 'addfwd_src [channel_id]*': 'Enable auto forwarding for a channel', + 'addfwd_to [channel_id]*': 'Enable auto forwarding to a channel', + 'delfwd_src [channel_id]*': 'Disable auto forwarding for a channel', + 'delfwd_to [channel_id]*': 'Disable auto forwarding to a channel', +} diff --git a/modules/misc/backup.py b/modules/misc/backup.py new file mode 100644 index 0000000..f6f33fc --- /dev/null +++ b/modules/misc/backup.py @@ -0,0 +1,230 @@ +import os +import shutil + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils import config +from utils.db import db + +# noinspection PyUnresolvedReferences +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, restart + +if config.db_type in ['mongodb', 'mongo']: + import bson + + +def dump_mongo(collections, path, db_): + for coll in collections: + with open(os.path.join(path, f'{coll}.bson'), 'wb+') as f: + for doc in db_[coll].find(): + f.write(bson.BSON.encode(doc)) + + +def restore_mongo(path, db_): + for coll in os.listdir(path): + if coll.endswith('.bson'): + with open(os.path.join(path, coll), 'rb+') as f: + db_[coll.split('.')[0]].insert_many(bson.decode_all(f.read())) + + +@Client.on_message(filters.command(['backup', 'back'], prefix) & filters.me) +async def backup(client: Client, message: Message): + """ + Backup the database + """ + try: + if not os.path.exists('backups/'): + os.mkdir('backups/') + + await message.edit('Backing up database...', parse_mode=enums.ParseMode.HTML) + + if config.db_type in ['mongo', 'mongodb']: + dump_mongo(db._database.list_collection_names(), 'backups/', db._database) + return await message.edit( + 'Database backed up to: backups/ folder', + parse_mode=enums.ParseMode.HTML, + ) + else: + shutil.copy(config.db_name, f'backups/{config.db_name}') + await client.send_document( + 'me', + caption='Database backup complete!\nType: ' + '.restore in response to this message to restore the database.', + document=f'backups/{config.db_name}', + parse_mode=enums.ParseMode.HTML, + ) + return await message.edit( + 'Database backed up successfully! (Check your favorites)', + parse_mode=enums.ParseMode.HTML, + ) + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command(['restore', 'res'], prefix) & filters.me) +async def restore(client: Client, message: Message): + """ + Restore the database + """ + try: + await message.edit('Restoring database...', parse_mode=enums.ParseMode.HTML) + if config.db_type in ['mongo', 'mongodb']: + restore_mongo('backups/', db._database) + return await message.edit( + 'Database restored from: backups/ folder', + parse_mode=enums.ParseMode.HTML, + ) + else: + if not message.reply_to_message or not message.reply_to_message.document: + return await message.edit( + 'Reply to a document to restore the database.', + parse_mode=enums.ParseMode.HTML, + ) + elif not message.reply_to_message.document.file_name.casefold().endswith(('.db', '.sqlite', '.sqlite3')): + return await message.edit( + 'Reply to a database file to restore the database.', + parse_mode=enums.ParseMode.HTML, + ) + await message.reply_to_message.download(f'backups/{message.reply_to_message.document.file_name}') + shutil.copy(f'backups/{message.reply_to_message.document.file_name}', config.db_name) + await message.edit( + 'Database restored successfully!', + parse_mode=enums.ParseMode.HTML, + ) + restart() + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command(['backupmods', 'bms'], prefix) & filters.me) +async def backupmods(client: Client, message: Message): + """ + Backup the modules + """ + try: + if not os.path.exists('backups/'): + os.mkdir('backups/') + + await message.edit('Backing up modules...', parse_mode=enums.ParseMode.HTML) + + from utils.misc import modules_help + + for mod in modules_help: + if os.path.isfile(f'modules/custom_modules/{mod}.py'): + f = open(f'backups/{mod}.py', 'wb') + f.write(open(f'modules/custom_modules/{mod}.py', 'rb').read()) + await message.edit( + text='All modules backed up to: backups/ folder', + parse_mode=enums.ParseMode.HTML, + ) + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command(['backupmod', 'bm'], prefix) & filters.me) +async def backupmod(client: Client, message: Message): + """ + Backup the module + """ + try: + if not os.path.exists('backups/'): + os.mkdir('backups/') + + try: + mod = message.text.split(maxsplit=1)[1].split('.')[0] + except Exception: + return await message.edit( + f'Usage: {prefix}backupmod [module]', + parse_mode=enums.ParseMode.HTML, + ) + + await message.edit('Backing up module...', parse_mode=enums.ParseMode.HTML) + + if os.path.isfile(f'modules/custom_modules/{mod}.py'): + f = open(f'backups/{mod}.py', 'wb') + f.write(open(f'modules/custom_modules/{mod}.py', 'rb').read()) + else: + return await message.edit( + f'Module {mod} not found.', + parse_mode=enums.ParseMode.HTML, + ) + await message.reply_document( + document=f'backups/{mod}.py', + caption=f'Module {mod} backed up to: backups/ folder', + parse_mode=enums.ParseMode.HTML, + ) + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command(['restoremod', 'resmod'], prefix) & filters.me) +async def restoremod(client: Client, message: Message): + """ + Restore the module + """ + try: + if not os.path.exists('backups/'): + os.mkdir('backups/') + + try: + mod = message.text.split(maxsplit=1)[1].split('.')[0] + except Exception: + return await message.edit( + f'Usage: {prefix}restoremod [module]', + parse_mode=enums.ParseMode.HTML, + ) + + await message.edit('Restoring module...', parse_mode=enums.ParseMode.HTML) + + if os.path.isfile(f'backups/{mod}.py'): + f = open(f'modules/custom_modules/{mod}.py', 'wb') + f.write(open(f'backups/{mod}.py', 'rb').read()) + else: + return await message.edit( + f'Module {mod} not found.', + parse_mode=enums.ParseMode.HTML, + ) + await message.edit( + f'Module {mod} restored successfully!', + parse_mode=enums.ParseMode.HTML, + ) + restart() + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +@Client.on_message(filters.command(['restoremods', 'resmods'], prefix) & filters.me) +async def restoremods(client: Client, message: Message): + """ + Restore the modules + """ + try: + if not os.path.exists('backups/'): + os.mkdir('backups/') + + await message.edit('Restoring modules...', parse_mode=enums.ParseMode.HTML) + + for mod in os.listdir('backups/'): + if mod.endswith('.py'): + if os.path.isfile(f'modules/{mod}'): + continue + f = open(f'modules/custom_modules/{mod}', 'wb') + f.write(open(f'backups/{mod}', 'rb').read()) + await message.edit( + text='All modules restored from: backups/ folder', + parse_mode=enums.ParseMode.HTML, + ) + restart() + except Exception as e: + await message.edit(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +modules_help['backup'] = { + 'backup': 'Backup database', + 'restore [reply]': 'Restore database', + 'backupmod [name]': 'Backup mod', + 'backupmods': 'Backup all mods', + 'resmod [name]': 'Restore mod', + 'resmods': 'Restore all mods', +} diff --git a/modules/misc/cama.py b/modules/misc/cama.py new file mode 100644 index 0000000..9bcf053 --- /dev/null +++ b/modules/misc/cama.py @@ -0,0 +1,95 @@ +import requests +from pyrogram import Client, filters +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, import_library + +pcp = import_library('pubchempy') + +INATURALIST_API_URL = 'https://api.inaturalist.org/v1/observations' + + +def get_marine_life_details(species_name): + params = { + 'taxon_name': species_name, + 'quality_grade': 'research', + 'iconic_taxa': 'Mollusca,Fish,Crustacea', + 'per_page': 1, + } + + response = requests.get(INATURALIST_API_URL, params=params, timeout=10) + + if response.status_code == 200: + data = response.json() + if data['total_results'] > 0: + observation = data['results'][0] + species = observation['taxon']['name'] + common_name = observation['taxon']['preferred_common_name'] + photo_url = observation['photos'][0]['url'] if observation['photos'] else 'No photo available' + description = observation.get('description', 'No description available.') + return { + 'species': species, + 'common_name': common_name, + 'photo_url': photo_url, + 'description': description, + } + else: + return {'error': 'No marine life found for this species.'} + else: + return {'error': f'Error {response.status_code}: Unable to connect to iNaturalist API.'} + + +@Client.on_message(filters.command('camistry', prefix) & filters.me) +async def fetch_chemical_data_with_visual(_, message): + query = ' '.join(message.text.split()[1:]) # Combine query words properly + + try: + # Fetch chemical data by name + results = pcp.get_compounds(query, 'name', record_type='3d') + + if results: + compound = results[0] + + # Send chemical information without SMILES structure + info = ( + f'Chemical Data for {compound.iupac_name}\n\n' + f'Molecular Formula: {compound.molecular_formula}\n' + f'Molecular Weight: {compound.molecular_weight}\n' + f'CID: {compound.cid}\n' + f'Synonyms: {", ".join(compound.synonyms[:5])}\n' + ) + await message.edit_text(info) + else: + await message.edit_text(f"No chemical data found for the query: '{query}'") + + except pcp.PubChemHTTPError as http_err: + await message.edit_text(f'HTTP error occurred: {format_exc(http_err)}') + + except Exception as e: + await message.edit_text(f'An error occurred: {format_exc(e)}') + + +@Client.on_message(filters.command('marinelife', prefix) & filters.me) +async def marine_life_command(_, message): + if len(message.command) < 2: + await message.edit_text(f'Please specify a species name. Example: {prefix}marinelife dolphin') + return + + species_name = ' '.join(message.command[1:]) + marine_life = get_marine_life_details(species_name) + + if 'error' in marine_life: + await message.edit_text(marine_life['error']) + else: + reply_text = ( + f'Species: {marine_life["species"]}\n' + f'Common Name: {marine_life["common_name"]}\n' + f'Description: {marine_life["description"]}\n' + f"Photo" + ) + await message.edit_text(reply_text) + + +modules_help['cama'] = { + 'camistry [text]': ' getting camicale info', + 'marinelife [text]': ' getting marinelife info', +} diff --git a/modules/misc/mlog.py b/modules/misc/mlog.py new file mode 100644 index 0000000..e6a4797 --- /dev/null +++ b/modules/misc/mlog.py @@ -0,0 +1,139 @@ +import asyncio +import os +from collections import defaultdict + +from pyrogram import Client, filters +from pyrogram.errors import ( + FileReferenceExpired, + FileReferenceInvalid, + TopicClosed, + TopicDeleted, +) +from pyrogram.types import Message +from utils.db import db +from utils.misc import modules_help, prefix + +mlog_enabled = filters.create(lambda _, __, ___: db.get('custom.mlog', 'status', False)) + +# Media cache and processing tasks +user_media_cache = defaultdict(list) +media_processing_tasks = {} + + +# Helper to get group-specific data +def get_group_data(group_id): + return db.get('custom.mlog', str(group_id), {}) + + +# Helper to update group-specific data +def update_group_data(group_id, data): + db.set('custom.mlog', str(group_id), data) + + +@Client.on_message(filters.command(['mlog'], prefix) & filters.me) +async def mlog(_, message: Message): + if len(message.command) < 2 or message.command[1].lower() not in ['on', 'off']: + return await message.edit(f'Usage: {prefix}mlog [on/off]') + + status = message.command[1].lower() == 'on' + db.set('custom.mlog', 'status', status) + await message.edit(f'Media logging is now {"enabled" if status else "disabled"}') + + +@Client.on_message(filters.command(['msetchat'], prefix) & filters.me) +async def set_chat(_, message: Message): + if len(message.command) < 2: + return await message.edit(f'Usage: {prefix}msetchat [chat_id]') + + try: + chat_id = message.command[1] + chat_id = int('-100' + chat_id if not chat_id.startswith('-100') else chat_id) + db.set('custom.mlog', 'chat', chat_id) + await message.edit(f'Chat ID set to {chat_id}') + except ValueError: + await message.edit('Invalid chat ID') + + +@Client.on_message(mlog_enabled & filters.incoming & filters.private & filters.media & ~filters.me & ~filters.bot) +async def media_log(client: Client, message: Message): + user_id = message.from_user.id + user_media_cache[user_id].append(message) + + if user_id not in media_processing_tasks: + media_processing_tasks[user_id] = asyncio.create_task(process_media(client, message.from_user)) + + +async def process_media(client: Client, user): + await asyncio.sleep(5) # Wait to group incoming media + user_id = user.id + + me = await client.get_me() + if user_id == me.id: + return + + chat_id = db.get('custom.mlog', 'chat') + if not chat_id: + return await client.send_message( + 'me', + f'Media Logger is on, but no Chat ID is set. Use {prefix}msetchat to set it.', + ) + + group_data = get_group_data(chat_id) + user_topics = group_data.get('user_topics', {}) + topic_id = user_topics.get(str(user_id)) # Fetch user's topic ID if it exists + + if not topic_id: + topic = await client.create_forum_topic(chat_id, user.first_name) + topic_id = topic.id + user_topics[str(user_id)] = topic_id # Store topic ID for this user + update_group_data(chat_id, {'user_topics': user_topics}) + m = await client.send_message( + chat_id=chat_id, + message_thread_id=topic_id, + text=f'Chat Name: {user.full_name}\nUser ID: {user_id}\nUsername: @{user.username or "N/A"}\nPhone No: +{user.phone_number or "N/A"}', + ) + await m.pin() + + messages_to_process = user_media_cache.pop(user_id, []) + for media_message in messages_to_process: + try: + await media_message.copy(chat_id=chat_id, message_thread_id=topic_id) + await asyncio.sleep(1) # Delay between sending media + except (FileReferenceExpired, FileReferenceInvalid): + # Handle self-destruct photos and video notes + await handle_self_destruct_media(client, media_message, chat_id, topic_id) + except TopicDeleted: + topic = await client.create_forum_topic(chat_id, user.first_name) + topic_id = topic.id + user_topics[str(user_id)] = topic_id # Update the new topic ID + update_group_data(chat_id, {'user_topics': user_topics}) + await client.send_message( + chat_id=chat_id, + message_thread_id=topic_id, + text=f'Chat Name: {user.full_name}\nUser ID: {user_id}\nUsername: @{user.username or "N/A"}\nPhone No: +{user.phone_number or "N/A"}', + ) + await handle_self_destruct_media(client, media_message, chat_id, topic_id) + except TopicClosed: + await client.reopen_forum_topic(chat_id=chat_id, topic_id=topic_id) + await media_message.copy(chat_id=chat_id, message_thread_id=topic_id) + + media_processing_tasks.pop(user_id, None) + + +async def handle_self_destruct_media(client: Client, message: Message, chat_id: int, topic_id: int): + try: + # Download the self-destructing media + file_path = await message.download() + if message.photo: + await client.send_photo(chat_id, file_path, message_thread_id=topic_id) + elif message.video_note: + await client.send_video(chat_id, file_path, message_thread_id=topic_id) + os.remove(file_path) # Clean up after sending + except Exception as e: + print(f'Error handling self-destructing media: {e}') + + +modules_help['mlog'] = { + 'mlog [on/off]': 'Enable or disable media logging', + 'msetchat [chat_id]': 'Set the chat ID for media logging', +} diff --git a/modules/misc/prayer.py b/modules/misc/prayer.py new file mode 100644 index 0000000..bf2d33c --- /dev/null +++ b/modules/misc/prayer.py @@ -0,0 +1,90 @@ +from datetime import datetime + +import aiohttp +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +# Aladhan API credentials +ALADHAN_API_URL = 'https://api.aladhan.com/v1/timingsByCity' +DEFAULT_METHOD = 2 # Islamic Society of North America +DEFAULT_CITY = 'Lahore' +DEFAULT_COUNTRY = 'PK' + + +async def fetch_namaz_times(city_name: str, country_name: str) -> dict: + params = {'city': city_name, 'country': country_name, 'method': DEFAULT_METHOD} + async with aiohttp.ClientSession() as session: + try: + async with session.get(ALADHAN_API_URL, params=params) as response: + response.raise_for_status() + return await response.json() + except Exception as e: + return {'error': str(e)} + + +def format_time_12hr(time_str: str) -> str: + try: + # Split time into hours and minutes + hours, minutes = map(int, time_str.split(':')) + # Convert to 12-hour format + period = 'AM' if hours < 12 else 'PM' + if hours == 0: + hours = 12 + elif hours > 12: + hours -= 12 + elif hours == 12: + period = 'PM' + return f'{hours}:{minutes:02d} {period}' + except Exception as e: + return f'Error formatting time: {str(e)}' + + +@Client.on_message(filters.command('prayer', prefix) & filters.me) +async def namaz_times(client: Client, message: Message): + if message.reply_to_message: + city_name = message.reply_to_message.text + country_name = DEFAULT_COUNTRY # Default to Pakistan if no country is provided + else: + args = message.text.split(maxsplit=2) + if len(args) < 2: + city_name = DEFAULT_CITY + country_name = DEFAULT_COUNTRY + elif len(args) == 2: + city_name = args[1] + country_name = DEFAULT_COUNTRY + else: + city_name = args[1] + country_name = args[2] + + data = await fetch_namaz_times(city_name, country_name) + + if 'error' in data: + result = f'Error: {data["error"]}' + elif 'data' in data: + timings = data['data']['timings'] + today = datetime.now().strftime('%Y-%m-%d') + formatted_timings = { + 'Fajr': format_time_12hr(timings['Fajr']), + 'Dhuhr': format_time_12hr(timings['Dhuhr']), + 'Asr': format_time_12hr(timings['Asr']), + 'Maghrib': format_time_12hr(timings['Maghrib']), + 'Isha': format_time_12hr(timings['Isha']), + } + result = ( + f'Prayer Times for {city_name}, {country_name} on {today}:\n\n' + f'Fajr: {formatted_timings["Fajr"]}\n' + f'Dhuhr: {formatted_timings["Dhuhr"]}\n' + f'Asr: {formatted_timings["Asr"]}\n' + f'Maghrib: {formatted_timings["Maghrib"]}\n' + f'Isha: {formatted_timings["Isha"]}\n' + ) + else: + result = 'Error: Unable to get prayer times for the specified location.' + + await message.edit_text(result) + + +modules_help['namaz'] = { + 'prayer [city_name] [country_name]': 'Shows the prayer times. Default to Pakistan if no country is provided' +} diff --git a/modules/misc/safone.py b/modules/misc/safone.py new file mode 100644 index 0000000..d5520a6 --- /dev/null +++ b/modules/misc/safone.py @@ -0,0 +1,519 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import base64 +import os +from io import BytesIO + +import aiofiles +import aiohttp +import requests +from PIL import Image, ImageEnhance +from pyrogram import Client, enums, filters +from pyrogram.errors import MediaCaptionTooLong, MessageTooLong +from pyrogram.types import InputMediaPhoto, Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + +url = 'https://api.safone.co' + +headers = { + 'Accept-Language': 'en-US,en;q=0.9', + 'Connection': 'keep-alive', + 'DNT': '1', + 'Referer': 'https://api.safone.dev/docs', + 'Sec-Fetch-Dest': 'empty', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Site': 'same-origin', + 'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36', + 'accept': 'application/json', + 'sec-ch-ua': '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"', + 'sec-ch-ua-mobile': '?0', + 'sec-ch-ua-platform': '"Linux"', +} + + +async def make_carbon(code): + url = 'https://carbonara.solopov.dev/api/cook' + + async with aiohttp.ClientSession() as session, session.post(url, json={'code': code}) as resp: + image_data = await resp.read() + + carbon_image = Image.open(BytesIO(image_data)) + + enhancer = ImageEnhance.Brightness(carbon_image) + bright_image = enhancer.enhance(1.0) + + output_image = BytesIO() + bright_image.save(output_image, format='PNG', quality=95) + output_image.name = 'carbon.png' + + return output_image + + +async def telegraph(title, user_name, content): + formatted_content = '
'.join(content.split('\n')) + formatted_content = '

' + formatted_content + '

' + + data = {'title': title, 'content': formatted_content, 'author_name': user_name} + + response = requests.post(url=f'{url}/telegraph/text', headers=headers, json=data, timeout=5) + + result = response.json() + + return result['url'] + + +async def voice_characters(): + response = requests.get(url=f'{url}/speech/characters', headers=headers, timeout=5) + + result = response.json() + + return ', '.join(result['characters']) + + +async def make_rayso(code: str, title: str, theme: str): + data = { + 'code': code, + 'title': title, + 'theme': theme, + 'padding': 64, + 'language': 'auto', + 'darkMode': False, + } + response = requests.post(f'{url}/rayso', data=data, headers=headers, timeout=10) + if response.status_code != 200: + return None + result = response.json() + try: + if result['error'] is not None: + return None + except KeyError: + pass + image_data = result['image'] + file_name = 'rayso.png' + with open(file_name, 'wb') as f: + f.write(base64.b64decode(image_data)) + return file_name + + +@Client.on_message(filters.command('asq', prefix) & filters.me) +async def asq(_, message: Message): + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + await message.edit_text('Query not provided!') + return + await message.edit_text('Processing...') + response = requests.get(url=f'{url}/asq?query={query}', headers=headers, timeout=5) + if response.status_code != 200: + await message.edit_text('Something went wrong!') + return + + result = response.json() + + ans = result['answer'] + await message.edit_text(f'Q. {query}\n A. {ans}', parse_mode=enums.ParseMode.MARKDOWN) + + +@Client.on_message(filters.command('sgemini', prefix) & filters.me) +async def sgemini(_, message: Message): + if len(message.command) > 1: + prompt = message.text.split(maxsplit=1)[1] + else: + await message.edit_text('prompt not provided!') + return + await message.edit_text('Processing...') + response = requests.get(url=f'{url}/bard?query={prompt}', headers=headers, timeout=10) + if response.status_code != 200: + await message.edit_text('Something went wrong!') + return + + result = response.json() + + ans = result['message'] + await message.edit_text(f'Prompt: {prompt}\n Ans: {ans}', parse_mode=enums.ParseMode.MARKDOWN) + + +@Client.on_message(filters.command('app', prefix) & filters.me) +async def app(client: Client, message: Message): + try: + chat_id = message.chat.id + await message.edit_text('Processing...') + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + message.edit_text("What should i search? You didn't provided me with any value to search") + + response = requests.get(url=f'{url}/apps?query={query}&limit=1', headers=headers, timeout=5) + if response.status_code != 200: + await message.edit_text('Something went wrong') + return + + result = response.json() + + try: + coverImage_url = result['results'][0]['icon'] + coverImage = requests.get(url=coverImage_url, timeout=10).content + async with aiofiles.open('coverImage.jpg', mode='wb') as f: + await f.write(coverImage) + + except Exception: + coverImage = None + + description = result['results'][0]['description'] + developer = result['results'][0]['developer'] + IsFree = result['results'][0]['free'] + genre = result['results'][0]['genre'] + package_name = result['results'][0]['id'] + title = result['results'][0]['title'] + price = result['results'][0]['price'] + link = result['results'][0]['link'] + rating = result['results'][0]['rating'] + + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f'Title: {title}\nRating: {rating}\nIsFree: {IsFree}\nPrice: {price}\nPackage Name: {package_name}\nGenres: {genre}\nDeveloper: {developer}\nDescription: {description}\nLink: {link}', + ) + ], + ) + + except MediaCaptionTooLong: + description = description[:850] + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f'Title: {title}\nRating: {rating}\nIsFree: {IsFree}\nPrice: {price}\nPackage Name: {package_name}\nGenres: {genre}\nDeveloper: {developer}\nDescription: {description}\nLink: {link}', + ) + ], + ) + except Exception as e: + await message.edit_text(format_exc(e)) + finally: + if os.path.exists('coverImage.jpg'): + os.remove('coverImage.jpg') + + +@Client.on_message(filters.command('tsearch', prefix) & filters.me) +async def tsearch(client: Client, message: Message): + try: + chat_id = message.chat.id + limit = 10 + await message.edit_text('Processing...') + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + message.edit_text("What should i search? You didn't provided me with any value to search") + + response = requests.get(url=f'{url}/torrent?query={query}&limit={limit}', headers=headers, timeout=10) + if response.status_code != 200: + await message.edit_text('Something went wrong') + return + + result = response.json() + + coverImage_url = result['results'][0]['thumbnail'] + description = result['results'][0]['description'] + genre = result['results'][0]['genre'] + category = result['results'][0]['category'] + title = result['results'][0]['name'] + link = result['results'][0]['magnetLink'] + link_result = await telegraph(title=title, user_name=message.from_user.first_name, content=link) + language = result['results'][0]['language'] + size = result['results'][0]['size'] + + results = [] + + for i in range(min(limit, len(result['results']))): + descriptions = result['results'][i]['description'] + genres = result['results'][i]['genre'] + categorys = result['results'][i]['category'] + titles = result['results'][i]['name'] + links = result['results'][i]['magnetLink'] + languages = result['results'][i]['language'] + sizes = result['results'][i]['size'] + + r = f'Title: {titles}\nCategory: {categorys}\nLanguage: {languages}\nSize: {sizes}\nGenres: {genres}\nDescription: {descriptions}\nMagnet Link: {links}
' + results.append(r) + + all_results_content = '
'.join(results) + + link_results = await telegraph( + title='Search Results', + user_name=message.from_user.first_name, + content=all_results_content, + ) + + if coverImage_url is not None: + coverImage = requests.get(url=coverImage_url, timeout=10).content + async with aiofiles.open('coverImage.jpg', mode='wb') as f: + await f.write(coverImage) + + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f"Title: {title}\nCategory: {category}\nLanguage: {language}\nSize: {size}\nGenres: {genre}\nDescription: {description}\nMagnet Link: Click Here\nMore Results: Click Here", + ) + ], + ) + else: + await message.edit_text( + f"Title: {title}\nCategory: {category}\nLanguage: {language}\nSize: {size}\nGenres: {genre}\nDescription: {description}\nMagnet Link: Click Here\nMore Results: Click Here", + disable_web_page_preview=True, + ) + + except MediaCaptionTooLong: + description = description[:850] + await message.delete() + await client.send_media_group( + chat_id, + [ + InputMediaPhoto( + 'coverImage.jpg', + caption=f"Title: {title}\nCategory: {category}\nLanguage: {language}\nSize: {size}\nGenres: {genre}\nDescription: {description}\nMagnet Link: Click Here", + ) + ], + ) + + except MessageTooLong: + description = description[:150] + await message.edit_text( + f"Title: {title}\nCategory: {category}\nLanguage: {language}\nSize: {size}\nGenres: {genre}\nDescription: {description}\nMagnet Link: Click Here", + disable_web_page_preview=True, + ) + + except Exception as e: + await message.edit_text(format_exc(e)) + finally: + if os.path.exists('coverImage.jpg'): + os.remove('coverImage.jpg') + + +@Client.on_message(filters.command('stts', prefix) & filters.me) +async def tts(client: Client, message: Message): + characters = await voice_characters() + await message.edit_text('Please Wait...') + try: + if len(message.command) > 2: + character, prompt = message.text.split(maxsplit=2)[1:] + if character not in characters: + await message.edit_text( + f'Usage: {prefix}tts [character]* [text/reply to text]*\n Available Characters:
{characters}
' + ) + return + + elif message.reply_to_message and len(message.command) > 1: + character = message.text.split(maxsplit=1)[1] + if character in characters: + prompt = message.reply_to_message.text + else: + await message.edit_text( + f'Usage: {prefix}tts [character]* [text/reply to text]*\n Available Characters:
{characters}
' + ) + return + + else: + await message.edit_text( + f'Usage: {prefix}tts [character]* [text/reply to text]*\n Available Characters:
{characters}
' + ) + return + + data = {'text': prompt, 'character': character} + response = requests.post(url=f'{url}/speech', headers=headers, json=data, timeout=10) + if response.status_code != 200: + await message.edit_text('Something went wrong') + return + + result = response.json() + audio_data = result['audio'] + audio_data = base64.b64decode(audio_data) + async with aiofiles.open(f'{prompt}.mp3', mode='wb') as f: + await f.write(audio_data) + + await message.delete() + await client.send_audio( + chat_id=message.chat.id, + audio=f'{prompt}.mp3', + caption=f'Characters: {character}\nPrompt: {prompt}', + ) + if os.path.exists(f'{prompt}.mp3'): + os.remove(f'{prompt}.mp3') + + except KeyError: + try: + error = result['error'] + await message.edit_text(error) + except KeyError: + await message.edit_text( + f'Usage: {prefix}tts [character]* [text/reply to text]*\n Available Characters:
{characters}
' + ) + except Exception as e: + await message.edit_text(format_exc(e)) + + +@Client.on_message(filters.command(['carbonnowsh', 'carboon', 'carbon', 'cboon'], prefix) & filters.me) +async def carbon(client: Client, message: Message): + if message.reply_to_message: + text = message.reply_to_message.text + message_id = message.reply_to_message.id + elif len(message.command) > 1: + message_id = None + text = message.text.split(maxsplit=1)[1] + else: + await message.edit_text('Query not provided!') + return + await message.edit_text('Processing...') + + image_file = await make_carbon(text) + + await message.delete() + try: + await client.send_photo( + chat_id=message.chat.id, + photo=image_file, + caption=f'Text: {text}', + reply_to_message_id=message_id, + ) + except MediaCaptionTooLong: + cap = text[:850] + await client.send_photo( + chat_id=message.chat.id, + photo=image_file, + caption=f'Text: {cap}', + reply_to_message_id=message_id, + ) + except Exception as e: + await message.edit_text(format_exc(e)) + if os.path.exists('carbon.png'): + os.remove('carbon.png') + + +@Client.on_message(filters.command('ccgen', prefix) & filters.me) +async def ccgen(_, message: Message): + if len(message.command) > 1: + bins = message.text.split(maxsplit=1)[1] + else: + await message.edit_text('Code not provided!') + return + await message.edit_text('Processing...') + response = requests.get(url=f'{url}/ccgen?bins={bins}', headers=headers, timeout=10) + if response.status_code != 200: + await message.edit_text('Something went wrong') + return + + result = response.json() + + cards = result['results'][0]['cards'] + cards_str = '\n'.join([f'"{card}"' for card in cards]) + bins = result['results'][0]['bin'] + await message.edit_text( + f'Bins: {bins}\nTotal: {len(cards)}\nCards: \n{cards_str}' + ) + + +@Client.on_message(filters.command('rayso', prefix) & filters.me) +async def rayso(client: Client, message: Message): + title = 'Untitled' + themes = [ + 'vercel', + 'supabase', + 'tailwind', + 'clerk', + 'mintlify', + 'prisma', + 'bitmap', + 'noir', + 'ice', + 'sand', + 'forest', + 'mono', + 'breeze', + 'candy', + 'crimson', + 'falcon', + 'meadow', + 'midnight', + 'raindrop', + 'sunset', + ] + if message.reply_to_message: + text = message.reply_to_message.text + message_id = message.reply_to_message.id + if 2 <= len(message.command) <= 3: + title = message.text.split(maxsplit=2)[1] + theme = message.text.split(maxsplit=2)[2].lower() + if theme not in themes: + theme = 'breeze' + elif len(message.command) > 1: + message_id = message.id + title = message.text.split(maxsplit=3)[1] + theme = message.text.split(maxsplit=3)[2] + if theme not in themes: + theme = 'breeze' + text = message.text.split(maxsplit=3)[3] + else: + await message.edit_text('Query not provided!') + return + await message.edit_text('Processing...') + + image_file = await make_rayso(text, title, theme) + + if image_file is None: + await message.edit_text('Something went wrong') + return + try: + await client.send_photo( + chat_id=message.chat.id, + photo=image_file, + caption=f'Text: {text}', + reply_to_message_id=message_id, + ) + await message.delete() + except MediaCaptionTooLong: + cap = text[:850] + await client.send_photo( + chat_id=message.chat.id, + photo=image_file, + caption=f'Text: {cap}', + reply_to_message_id=message_id, + ) + await message.delete() + except Exception as e: + await message.edit_text(format_exc(e)) + if os.path.exists(image_file): + os.remove(image_file) + + +modules_help['safone'] = { + 'asq [query]*': 'Asq', + 'app [query]*': 'Search for an app on Play Store', + 'tsearch [query]*': 'Search Torrent', + 'tts [character]* [text/reply to text]*': 'Convert Text to Speech', + 'sgemini [prompt]*': 'Gemini Model through safone api', + 'carbon [code/file/reply]': 'Create beautiful image with your code', + 'ccgen [bins]*': 'Generate credit cards', + 'rayso [title]* [theme]* [text/reply to text]*': 'Create beautiful image with your text', +} diff --git a/modules/misc/sarethai.py b/modules/misc/sarethai.py new file mode 100644 index 0000000..3f87857 --- /dev/null +++ b/modules/misc/sarethai.py @@ -0,0 +1,504 @@ +#  Moon-Userbot - telegram userbot +#  Copyright (C) 2020-present Moon Userbot Organization +# +#  This program is free software: you can redistribute it and/or modify +#  it under the terms of the GNU General Public License as published by +#  the Free Software Foundation, either version 3 of the License, or +#  (at your option) any later version. + +#  This program is distributed in the hope that it will be useful, +#  but WITHOUT ANY WARRANTY; without even the implied warranty of +#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +#  GNU General Public License for more details. + +#  You should have received a copy of the GNU General Public License +#  along with this program.  If not, see . + +import asyncio +import contextlib +import os + +import requests +from modules.url import generate_screenshot +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import no_prefix + +np = no_prefix(prefix) + +# API URLs +BASE_URL = 'https://deliriussapi-oficial.vercel.app' +URL = f'{BASE_URL}/ia' +GOOGLE_SEARCH_URL = f'{BASE_URL}/search/googlesearch?query=' +YOUTUBE_SEARCH_URL = f'{BASE_URL}/search/ytsearch?q=' +MOVIE_SEARCH_URL = f'{BASE_URL}/search/movie?query=' +APK_SEARCH_URL = 'https://bk9.fun/search/apkfab?q=' +APK_DOWNLOAD_URL = 'https://bk9.fun/download/apkfab?url=' + + +def clean_data(data): + parts = data.split('$@$') + + if len(parts) > 1: + return parts[-1] + else: + return data + + +# Store search results temporarily +search_results = {} + + +# Helper Functions +def format_google_results(results): + results = results[:15] + return results, '\n\n'.join( + [f'{i + 1}. **[{item["title"]}]({item["url"]})**\n{item["description"]}' for i, item in enumerate(results)] + ) + + +def format_youtube_results(results): + results = results[:15] + return results, '\n\n'.join( + [ + f'{i + 1}. **[{item["title"]}]({item["url"]})**\nPublished by: [{item["author"]["name"]}]({item["author"]["url"]}) - {item["views"]} views - {item["duration"]}' + for i, item in enumerate(results) + ] + ) + + +def format_movie_results(results): + results = results[:15] + return results, '\n\n'.join( + [ + f'{i + 1}. **{item["title"]}** ({item["release_date"]})\nRating: {item["vote_average"]}/10\nVotes: {item["vote_count"]}' + for i, item in enumerate(results) + ] + ) + + +def format_apk_results(results): + results = results[:15] + return results, '\n\n'.join([f'{i + 1}. [{item["title"]}]({item["link"]})' for i, item in enumerate(results)]) + + +async def send_screenshot(client, message, url): + screenshot_data = generate_screenshot(url) + if screenshot_data: + await client.send_photo( + message.chat.id, + screenshot_data, + caption=f'Screenshot of {url}', + reply_to_message_id=message.id, + ) + else: + await message.reply('Failed to take screenshot.') + + +async def delete_search_data(client, chat_id, message_id): + await asyncio.sleep(60) + for key in ['google', 'youtube', 'movie', 'apk']: + search_key = f'{chat_id}_{key}' + if search_key in search_results and search_results[search_key]['message_id'] == message_id: + del search_results[search_key] + with contextlib.suppress(BaseException): + await client.delete_messages(chat_id, message_id) + break + + +def format_spotify_result(data): + result = '' + for item in data[:15]: # Limit to 15 results + result += f'🎵 **{item["title"]}** by {item["artist"]}\n' + result += f'Album: {item["album"]}\n' + result += f'Duration: {item["duration"]}\n' + result += f'Popularity: {item["popularity"]}\n' + result += f'Publish Date: {item["publish"]}\n' + result += f'[Listen on Spotify]({item["url"]})\n\n' + return result + + +def format_lyrics_result(data): + return f'🎵 **{data["fullTitle"]}** by {data["artist"]}\n\n{data["lyrics"]}' + + +def format_soundcloud_result(data): + result = '' + for item in data[:15]: # Limit to 15 results + result += f'🎵 **{item["title"]}**\n' + result += f'Genre: {item["genre"]}\n' + result += f'Duration: {item["duration"] // 1000 // 60}:{item["duration"] // 1000 % 60}\n' + result += f'Likes: {item["likes"]}\n' + result += f'Plays: {item["play"]}\n' + result += f'[Listen on SoundCloud]({item["link"]})\n\n' + return result + + +def format_deezer_result(data): + result = '' + for item in data[:15]: # Limit to 15 results + result += f'🎵 **{item["title"]}** by {item["artist"]}\n' + result += f'Duration: {item["duration"]}\n' + result += f'Rank: {item["rank"]}\n' + result += f'[Listen on Deezer]({item["url"]})\n\n' + return result + + +def format_apple_music_result(data): + result = '' + for item in data[:15]: # Limit to 15 results + title = item.get('title', 'Unknown Title') + artists = item.get('artists', 'Unknown Artist') + music_type = item.get('type', 'Unknown Type') + url = item.get('url', '#') + result += f'🎵 **{title}** by {artists}\n' + result += f'Type: {music_type}\n' + result += f'[Listen on Apple Music]({url})\n\n' + return result + + +async def search_music(api_url, format_function, message, query): + await message.edit('Searching...') + url = f'{api_url}{query}&limit=10' + response = requests.get(url, timeout=10) + + if response.status_code == 200: + try: + data = response.json() # Directly get the JSON data + + if isinstance(data, list): + result = format_function(data) + elif isinstance(data, dict) and 'data' in data: + result = format_function(data['data']) + else: + result = 'No data found or unexpected format.' + + await message.edit(result, parse_mode=enums.ParseMode.MARKDOWN) + except (ValueError, KeyError, TypeError) as e: + await message.edit(f'An error occurred while processing the data: {str(e)}') + elif response.json()['msg']: + await message.edit(response.json()['msg']) + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command(['gsearch'], prefix) & filters.me) +async def google_search(client: Client, message: Message): + if message.reply_to_message: + query = message.reply_to_message.text.strip() + elif len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + return await message.edit_text(f'{prefix}gsearch ') + + await message.edit('Searching...') + url = f'{GOOGLE_SEARCH_URL}{query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + results, formatted_results = format_google_results(data['data']) + search_message = await message.edit( + f'**Google Search Results for:** `{query}`\n\n{formatted_results}', + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + ) + search_key = f'{message.chat.id}_google' + global search_results + search_results[search_key] = { + 'results': results, + 'message_id': search_message.id, + } + google_url = f'https://www.google.com/search?q={query}' + await send_screenshot(client, message, google_url) + asyncio.create_task(delete_search_data(client, message.chat.id, search_message.id)) + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command(['ytsearch'], prefix) & filters.me) +async def youtube_search(client: Client, message: Message): + if message.reply_to_message: + query = message.reply_to_message.text.strip() + elif len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + return await message.edit_text(f'{prefix}ytsearch ') + + await message.edit('Searching...') + url = f'{YOUTUBE_SEARCH_URL}{query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + results, formatted_results = format_youtube_results(data['data']) + search_message = await message.edit( + f'**YouTube Search Results for:** `{query}`\n\n{formatted_results}', + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + ) + search_key = f'{message.chat.id}_youtube' + global search_results + search_results[search_key] = { + 'results': results, + 'message_id': search_message.id, + } + youtube_url = f'https://www.youtube.com/results?search_query={query}' + await send_screenshot(client, message, youtube_url) + asyncio.create_task(delete_search_data(client, message.chat.id, search_message.id)) + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command(['moviesearch'], prefix) & filters.me) +async def movie_search(client, message: Message): + if message.reply_to_message: + query = message.reply_to_message.text.strip() + elif len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + return await message.edit_text(f'{prefix}moviesearch ') + + await message.edit('Searching...') + url = f'{MOVIE_SEARCH_URL}{query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + results, formatted_results = format_movie_results(data['data']) + + # Split the message into multiple parts if it's too long + parts = [] + part_length = 4096 # Telegram's message length limit + for i in range(0, len(formatted_results), part_length): + parts.append(formatted_results[i : i + part_length]) + + for part in parts: + search_message = await message.reply( + f'**Movie Search Results for:** `{query}`\n\n{part}', + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + ) + + search_key = f'{message.chat.id}_movie' + global search_results + search_results[search_key] = { + 'results': results, + 'message_id': search_message.id, + } + asyncio.create_task(delete_search_data(client, message.chat.id, search_message.id)) + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command(['apksearch'], prefix) & filters.me) +async def apk_search(client, message: Message): + if message.reply_to_message: + query = message.reply_to_message.text.strip() + elif len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + else: + return await message.edit_text(f'{prefix}apksearch ') + + await message.edit('Searching...') + url = f'{APK_SEARCH_URL}{query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + results, formatted_results = format_apk_results(data['BK9']) + search_message = await message.edit( + f'**APK Search Results for:** `{query}`\n\n{formatted_results}', + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + ) + search_key = f'{message.chat.id}_apk' + global search_results + search_results[search_key] = { + 'results': results, + 'message_id': search_message.id, + } + + asyncio.create_task(delete_search_data(client, message.chat.id, search_message.id)) + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command(['wgpt', 'gptweb'], prefix) & filters.me) +async def gptweb(_, message: Message): + if len(message.command) < 2: + await message.edit('Usage: `wgpt `') + return + await message.edit('Thinking...') + query = ' '.join(message.command[1:]) + url = f'{URL}/gptweb?text={query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + await message.edit( + f'**Question:**\n{query}\n**Answer:**\n{data["data"]}', + parse_mode=enums.ParseMode.MARKDOWN, + ) + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command(['wgemini'], prefix) & filters.me) +async def gemini(_, message: Message): + if len(message.command) < 2: + await message.edit('Usage: `wgemini `') + return + await message.edit('Thinking...') + query = ' '.join(message.command[1:]) + url = f'{URL}/gemini?query={query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + await message.edit( + f'**Question:**\n{query}\n**Answer:**\n{data["message"]}', + parse_mode=enums.ParseMode.MARKDOWN, + ) + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command(['sputify'], prefix) & filters.me) +async def spotify_search(_, message: Message): + query = message.text.split(maxsplit=1)[1] if len(message.command) > 1 else message.reply_to_message.text + if not query: + await message.edit('Usage: spotify ') + return + await search_music(f'{BASE_URL}/search/spotify?q=', format_spotify_result, message, query) + + +@Client.on_message(filters.command(['lyrics'], prefix) & filters.me) +async def lyrics_search(_, message: Message): + query = message.text.split(maxsplit=1)[1] if len(message.command) > 1 else message.reply_to_message.text + if not query: + await message.edit('Usage: lyrics ') + return + await search_music(f'{BASE_URL}/search/letra?query=', format_lyrics_result, message, query) + + +@Client.on_message(filters.command(['soundcloud'], prefix) & filters.me) +async def soundcloud_search(_, message: Message): + query = message.text.split(maxsplit=1)[1] if len(message.command) > 1 else message.reply_to_message.text + if not query: + await message.edit('Usage: soundcloud ') + return + await search_music(f'{BASE_URL}/search/soundcloud?q=', format_soundcloud_result, message, query) + + +@Client.on_message(filters.command(['deezer'], prefix) & filters.me) +async def deezer_search(_, message: Message): + query = message.text.split(maxsplit=1)[1] if len(message.command) > 1 else message.reply_to_message.text + if not query: + await message.edit('Usage: deezer ') + return + await search_music(f'{BASE_URL}/search/deezer?q=', format_deezer_result, message, query) + + +@Client.on_message(filters.command(['applemusic'], prefix) & filters.me) +async def applemusic_search(_, message: Message): + query = message.text.split(maxsplit=1)[1] if len(message.command) > 1 else message.reply_to_message.text + if not query: + await message.edit('Usage: applemusic ') + return + await search_music(f'{BASE_URL}/search/applemusic?text=', format_apple_music_result, message, query) + + +@Client.on_message(filters.reply & filters.text & filters.me & np) +async def handle_reply(client: Client, message: Message): + chat_id = message.chat.id + search_keys = [ + f'{chat_id}_google', + f'{chat_id}_youtube', + f'{chat_id}_movie', + f'{chat_id}_apk', + ] + + for search_key in search_keys: + if search_key in search_results: + try: + # Check if the replied-to message is one of the bot's search result messages + if message.reply_to_message.from_user.id != (await client.get_me()).id: + return + + index = int(message.text.strip()) - 1 + results = search_results[search_key]['results'] + search_message_id = search_results[search_key]['message_id'] + if message.reply_to_message.id == search_message_id and 0 <= index < len(results): + await message.edit('Please wait...') + + if search_key.endswith('_movie'): + # Send movie details with image + movie = results[index] + caption = ( + f'**{movie["title"]}** ({movie["release_date"]})\n' + f'Original Title: {movie["original_title"]}\n' + f'Language: {movie["original_language"]}\n' + f'Overview: {movie["overview"]}\n' + f'Popularity: {movie["popularity"]}\n' + f'Rating: {movie["vote_average"]}/10\n' + f'Votes: {movie["vote_count"]}' + ) + if 'image' in movie: + response = requests.get(movie['image'], timeout=10) + if response.status_code == 200: + with open('movie_image.jpg', 'wb') as f: + f.write(response.content) + await message.reply_photo( + photo='movie_image.jpg', + caption=caption, + parse_mode=enums.ParseMode.MARKDOWN, + ) + os.remove('movie_image.jpg') + else: + await message.reply(caption, parse_mode=enums.ParseMode.MARKDOWN) + else: + await message.reply(caption, parse_mode=enums.ParseMode.MARKDOWN) + elif search_key.endswith('_apk'): + apk_url = f'{APK_DOWNLOAD_URL}{results[index]["link"]}' + fetch_apk_url = requests.get(apk_url, timeout=10) + if fetch_apk_url.status_code != 200: + await message.edit('Failed to fetch APK data.') + else: + data_apk = fetch_apk_url.json() + download_url = data_apk['BK9']['link'] + size_apk = data_apk['BK9']['size'] + apk_size = float(size_apk.split(' ')[0]) + + if 'GB' in size_apk or apk_size > 100: + await message.edit('File size is too large to download.') + else: + apk_file_name = f'{data_apk["BK9"]["title"]}.apk' + response = requests.get(download_url, timeout=10) + + if response.status_code != 200: + await message.edit('Failed to download the APK file.') + else: + with open(apk_file_name, 'wb') as f: + f.write(response.content) + + await message.reply_document(apk_file_name) + os.remove(apk_file_name) + else: + url = results[index]['url'] + await send_screenshot(client, message, url) + await message.delete() + return + except ValueError: + pass + + +modules_help['sarethai'] = { + 'wgpt [query]*': 'Ask anything to GPT-Web', + 'gptweb [query]*': 'Ask anything to GPT-Web', + 'wgemini [query]*': 'Ask anything to Gemini', + 'sputify [query]*': 'Search for songs on Spotify', + 'lyrics [song name]*': 'Get the lyrics of a song', + 'soundcloud [query]*': 'Search for songs on SoundCloud', + 'deezer [query]*': 'Search for songs on Deezer', + 'applemusic [query]*': 'Search for songs on Apple Music', + 'gsearch [query]*': 'Searches Google for the query.', + 'ytsearch [query]*': 'Searches YouTube for the query.', + 'moviesearch [query]*': 'Searches movies for the query and returns results.', + 'apksearch [query]*': 'Searches APKs for the query and returns results.', +} diff --git a/modules/misc/search.py b/modules/misc/search.py new file mode 100644 index 0000000..8336b54 --- /dev/null +++ b/modules/misc/search.py @@ -0,0 +1,70 @@ +from asyncio import sleep + +from pyrogram import Client, enums, filters +from pyrogram.types import Message + +# noinspection PyUnresolvedReferences +from utils.misc import modules_help, prefix + +# noinspection PyUnresolvedReferences +from utils.scripts import format_exc + +now = {} + + +@Client.on_message(filters.command(['search'], prefix) & filters.me) +async def search_cmd(client: Client, message: Message): + if now.get(message.chat.id): + return await message.edit( + f'You already have a search in progress!\nType: {prefix}scancel to cancel it.', + parse_mode=enums.ParseMode.HTML, + ) + + await message.edit('Start searching...', parse_mode=enums.ParseMode.HTML) + finished = False + local = False + try: + cmd = message.command[1] + word = message.command[2].lower() + timeout = float(message.command[3]) if len(message.command) > 3 else 2 + except Exception: + return await message.edit( + f'Usage: {prefix}search [/cmd]* [search_word]* [timeout=2.0]', + parse_mode=enums.ParseMode.HTML, + ) + + now[message.chat.id] = True + + try: + await message.reply_text(quote=False, text=cmd, reply_to_message_id=None) + while not finished and now[message.chat.id]: + async for msg in client.get_chat_history(message.chat.id, limit=2): + if msg.from_user.id == message.from_user.id: + continue + elif word in msg.text.lower(): + finished = True + local = True + if not local: + await sleep(timeout) + await message.reply_text(quote=False, text=cmd, reply_to_message_id=None) + else: + break + if now[message.chat.id]: + await message.reply_text('Search finished!', parse_mode=enums.ParseMode.HTML) + except Exception as ex: + await message.edit(format_exc(ex), parse_mode=enums.ParseMode.HTML) + now[message.chat.id] = False + + +@Client.on_message(filters.command(['scancel'], prefix) & filters.me) +async def scancel_cmd(_: Client, message: Message): + if not now.get(message.chat.id): + return await message.edit('There is no search in progress!', parse_mode=enums.ParseMode.HTML) + now[message.chat.id] = False + await message.edit('Search cancelled!', parse_mode=enums.ParseMode.HTML) + + +modules_help['search'] = { + 'search [/cmd]* [search_word]* [timeout=2.0]': 'Search for a specific word in bot (while)', + 'scancel': 'Cancel current search', +} diff --git a/modules/misc/summary.py b/modules/misc/summary.py new file mode 100644 index 0000000..fb469f3 --- /dev/null +++ b/modules/misc/summary.py @@ -0,0 +1,58 @@ +# copyright by https/t.me/shado_hackers + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, import_library + +import_library('lxml_html_clean') +import_library('newspaper', 'newspaper3k') +nltk = import_library('nltk') +from newspaper import Article # noqa: E402 +from newspaper.article import ArticleException # noqa: E402 + +nltk.download('all') + + +@Client.on_message(filters.command('summary', prefix) & filters.me) +async def summarize_article(_, message: Message): + """ + Summarize an article from a given URL. + + Args: + client (Client): Pyrogram client instance. + message (Message): Incoming message. + + Returns: + None + """ + # Extract the URL from the message text (removing the command part) + url = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else None + + if not url: + await message.edit_text('Please provide a valid URL after the command.') + return + + try: + # Extract and summarize the article + article = Article(url) + article.download() + article.parse() + article.nlp() # Uses NLP to analyze the article + + response = f""" + Article Summary + Title: {article.title} + Authors: {', '.join(article.authors) if article.authors else 'N/A'} + Summary: +
{article.summary}
+ """ + await message.edit_text(response) + except ArticleException: + return await message.edit_text('Unable to extract information from the provided URL.') + + except Exception as e: + return await message.edit_text(f'An error occurred: {format_exc(e)}') + + +modules_help['summary'] = {'summary [url]': 'Reply with article links, getting summary of articles'} diff --git a/modules/misc/switch.py b/modules/misc/switch.py new file mode 100644 index 0000000..a3bd72d --- /dev/null +++ b/modules/misc/switch.py @@ -0,0 +1,46 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +ru_keys = """ёйцукенгшщзхъфывапролджэячсмитьбю.Ё"№;%:?ЙЦУКЕНГШЩЗХЪФЫВАПРОЛДЖЭ/ЯЧСМИТЬБЮ,""" +en_keys = """`qwertyuiop[]asdfghjkl;'zxcvbnm,./~@#$%^&QWERTYUIOP{}ASDFGHJKL:"|ZXCVBNM<>?""" +table = str.maketrans(ru_keys + en_keys, en_keys + ru_keys) + + +@Client.on_message(filters.command(['switch', 'sw'], prefix) & filters.me) +async def switch(client: Client, message: Message): + if len(message.command) == 1: + if message.reply_to_message: + text = message.reply_to_message.text + else: + history = await client.get_history(message.chat.id, limit=2) + if history and history[1].from_user.is_self and history[1].text: + text = history[1].text + else: + await message.edit('Text to switch not found', parse_mode=enums.ParseMode.HTML) + return + else: + text = message.text.split(maxsplit=1)[1] + + await message.edit(str.translate(text, table), parse_mode=enums.ParseMode.HTML) + + +modules_help['switch'] = { + 'sw [reply/text for switch]*': 'Useful when you forgot to change the keyboard layout[RU]', +} diff --git a/modules/misc/transcribeyt.py b/modules/misc/transcribeyt.py new file mode 100644 index 0000000..8bb409d --- /dev/null +++ b/modules/misc/transcribeyt.py @@ -0,0 +1,94 @@ +import os +import time + +import requests +from pyrogram import Client, enums, filters +from pyrogram.errors import MessageTooLong +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +# Replace with your Gladia API key +gladia_key = 'your key ' +gladia_url = 'https://api.gladia.io/v2/transcription/' + + +# Function to make fetch requests to the Gladia API +def make_fetch_request(url, headers, method='GET', data=None): + if method == 'POST': + response = requests.post(url, headers=headers, json=data, timeout=10) + else: + response = requests.get(url, headers=headers, timeout=10) + return response.json() + + +@Client.on_message(filters.command('transcribeyt', prefix) & filters.me) +async def transcribe_audio(_, message: Message): + """ + Transcribe an audio URL using the Gladia API. + + Usage: .transcribe + """ + audio_url = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else None + + # Check if a valid URL was provided + if not audio_url: + await message.reply('Please provide a valid audio URL.') + return + + headers = {'x-gladia-key': gladia_key, 'Content-Type': 'application/json'} + + request_data = {'audio_url': audio_url} + + # Send initial request to Gladia API + status_message = await message.reply('- Sending initial request to Gladia API...') + initial_response = make_fetch_request(gladia_url, headers, 'POST', request_data) + + # Check if the response contains the result_url + if 'result_url' not in initial_response: + await status_message.edit(f'Error in transcription request: {initial_response}') + return + + result_url = initial_response['result_url'] + await status_message.edit('Initial request sent. Polling for transcription results...') + + # Polling for transcription result + while True: + poll_response = make_fetch_request(result_url, headers) + + if poll_response.get('status') == 'done': + transcription = poll_response.get('result', {}).get('transcription', {}).get('full_transcript') + if transcription: + # Format the transcription result with HTML + result_html = f""" + Transcription Result +
+
{transcription}
+ """ + try: + # Attempt to send transcription as a message + await message.reply_text(result_html, parse_mode=enums.ParseMode.HTML) + except MessageTooLong: + # Save the large response to a file + with open('transcription.txt', 'w') as f: + f.write(result_html) + + # Read general details to include in the caption + general_details = "Here's the transcription result." + + # Send the file with a caption + await message.reply_document( + 'transcription.txt', + caption=f'General Details:\n{general_details}', + ) + + # Clean up by removing the file + os.remove('transcription.html') + else: + await status_message.edit('Transcription completed, but no transcript was found.') + break + else: + await status_message.edit(f'Transcription status: {poll_response.get("status")}') + time.sleep(30) # Wait for a few seconds before polling again + + +modules_help['transcribeyt'] = {'transcribeyt [yt video url]': 'Reply with a YT video link to get transcribed '} diff --git a/modules/notes.py b/modules/notes.py new file mode 100644 index 0000000..98f8334 --- /dev/null +++ b/modules/notes.py @@ -0,0 +1,133 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, errors, filters +from pyrogram.types import Message +from utils.db import db +from utils.handlers import NoteSendHandler +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['save'], prefix) & filters.me) +async def save_note(client: Client, message: Message): + await message.edit('Loading...') + + try: + chat = await client.get_chat(db.get('core.notes', 'chat_id', 0)) + except (errors.RPCError, ValueError, KeyError): + # group is not accessible or isn't created + chat = await client.create_supergroup('Userbot_Notes_Filters', "Don't touch this group, please") + db.set('core.notes', 'chat_id', chat.id) + + chat_id = chat.id + + if message.reply_to_message and len(message.text.split()) >= 2: + note_name = message.text.split(maxsplit=1)[1] + if message.reply_to_message.media_group_id: + checking_note = db.get('core.notes', f'note{note_name}', False) + if not checking_note: + get_media_group = [ + _.id for _ in await client.get_media_group(message.chat.id, message.reply_to_message.id) + ] + try: + message_id = await client.forward_messages(chat_id, message.chat.id, get_media_group) + except errors.ChatForwardsRestricted: + await message.edit( + 'Forwarding messages is restricted by chat admins', + ) + return + note = { + 'MESSAGE_ID': str(message_id[1].id), + 'MEDIA_GROUP': True, + 'CHAT_ID': str(chat_id), + } + db.set('core.notes', f'note{note_name}', note) + await message.edit(f'Note {note_name} saved') + else: + await message.edit('This note already exists') + else: + checking_note = db.get('core.notes', f'note{note_name}', False) + if not checking_note: + try: + message_id = await message.reply_to_message.forward(chat_id) + except errors.ChatForwardsRestricted: + message_id = await message.copy(chat_id) + note = { + 'MEDIA_GROUP': False, + 'MESSAGE_ID': str(message_id.id), + 'CHAT_ID': str(chat_id), + } + db.set('core.notes', f'note{note_name}', note) + await message.edit(f'Note {note_name} saved') + else: + await message.edit('This note already exists') + elif len(message.text.split()) >= 3: + note_name = message.text.split(maxsplit=1)[1].split()[0] + checking_note = db.get('core.notes', f'note{note_name}', False) + if not checking_note: + message_id = await client.send_message(chat_id, message.text.split(note_name)[1].strip()) + note = { + 'MEDIA_GROUP': False, + 'MESSAGE_ID': str(message_id.id), + 'CHAT_ID': str(chat_id), + } + db.set('core.notes', f'note{note_name}', note) + await message.edit(f'Note {note_name} saved') + else: + await message.edit('This note already exists') + else: + await message.edit( + f'Example: {prefix}save note_name', + ) + + +@Client.on_message(filters.command('note', prefix) & filters.me) +async def note_send(client: Client, message: Message): + handler = NoteSendHandler(client, message) + await handler.handle_note_send() + + +@Client.on_message(filters.command(['notes'], prefix) & filters.me) +async def notes(_, message: Message): + await message.edit('Loading...') + text = 'Available notes:\n\n' + collection = db.get_collection('core.notes') + for note in collection: + if note[:4] == 'note': + text += f'{note[4:]}\n' + await message.edit(text) + + +@Client.on_message(filters.command(['clear'], prefix) & filters.me) +async def clear_note(_, message: Message): + if len(message.text.split()) >= 2: + note_name = message.text.split(maxsplit=1)[1] + find_note = db.get('core.notes', f'note{note_name}', False) + if find_note: + db.remove('core.notes', f'note{note_name}') + await message.edit(f'Note {note_name} deleted') + else: + await message.edit('There is no such note') + else: + await message.edit(f'Example: {prefix}clear note_name') + + +modules_help['notes'] = { + 'save [name]*': 'Save note', + 'note [name]*': 'Get saved note', + 'notes': 'Get note list', + 'clear [name]*': 'Delete note', +} diff --git a/modules/open.py b/modules/open.py new file mode 100644 index 0000000..9c25295 --- /dev/null +++ b/modules/open.py @@ -0,0 +1,115 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import os +import time + +import aiofiles +from pyrogram import Client, filters +from pyrogram.errors import MessageTooLong +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.rentry import paste as rentry_paste +from utils.scripts import edit_or_reply, format_exc, progress + + +async def read_file(file_path): + async with aiofiles.open(file_path) as file: + content = await file.read() + return content + + +def check_extension(file_path): + extensions = { + '.txt': "
",
+        '.py': "
",
+        '.js': "
",
+        '.json': "
",
+        '.smali': "
",
+        '.sh': "
",
+        '.c': "
",
+        '.java': "
",
+        '.php': "
",
+        '.doc': "
",
+        '.docx': "
",
+        '.rtf': "
",
+        '.s': "
",
+        '.dart': "
",
+        '.cfg': "
",
+        '.swift': "
",
+        '.cs': "
",
+        '.vb': "
",
+        '.css': "
",
+        '.htm': "
",
+        '.html': "
",
+        '.rss': "
",
+        '.xhtml': "
",
+        '.cpp': "
",
+    }
+
+    ext = os.path.splitext(file_path)[1].lower()
+
+    return extensions.get(ext, '
')
+
+
+@Client.on_message(filters.command('open', prefix) & filters.me)
+async def openfile(client: Client, message: Message):
+    if not message.reply_to_message:
+        return await message.edit_text('Kindly Reply to a File')
+
+    try:
+        ms = await edit_or_reply(message, 'Downloading...')
+        ct = time.time()
+        file_path = await message.reply_to_message.download(progress=progress, progress_args=(ms, ct, 'Downloading...'))
+        await ms.edit_text('Trying to open file...')
+        file_info = os.stat(file_path)
+        file_name = file_path.split('/')[-1:]
+        file_size = file_info.st_size
+        last_modified = datetime.datetime.fromtimestamp(file_info.st_mtime).strftime('%Y-%m-%d %H:%M:%S')
+        code_start = check_extension(file_path=file_path)
+        content = await read_file(file_path=file_path)
+        await ms.edit_text(
+            f'File Name: {file_name[0]}\nSize: {file_size} bytes\nLast Modified: {last_modified}\nContent: {code_start}{content}
', + ) + + except MessageTooLong: + await ms.edit_text('File Content is too long... Pasting to rentry...') + content_new = f'```{code_start[11:-2]}\n{content}```' + try: + rentry_url, edit_code = await rentry_paste(text=content_new, return_edit=True) + except RuntimeError: + await ms.edit_text('Error: Failed to paste to rentry') + return + await client.send_message( + 'me', + f"Here's your edit code for Url: {rentry_url}\nEdit code: {edit_code}", + disable_web_page_preview=True, + ) + await ms.edit_text( + f'File Name: {file_name[0]}\nSize: {file_size} bytes\nLast Modified: {last_modified}\nContent: {rentry_url}\nNote: Edit Code has been sent to your saved messages', + disable_web_page_preview=True, + ) + + except Exception as e: + await ms.edit_text(format_exc(e)) + + finally: + if os.path.exists(file_path): + os.remove(file_path) + + +modules_help['open'] = {'open': "Open content of any text supported filetype and show it's raw data"} diff --git a/modules/pdf2md.py b/modules/pdf2md.py new file mode 100644 index 0000000..0a7a2f4 --- /dev/null +++ b/modules/pdf2md.py @@ -0,0 +1,87 @@ +import os + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.config import gemini_key +from utils.misc import modules_help, prefix +from utils.scripts import import_library + +import_library('pyzerox', 'py-zerox') + +import litellm # noqa: E402 +from pyzerox import zerox # noqa: E402 +from pyzerox.errors import ModelAccessError, NotAVisionModel # noqa: E402 + +kwargs = {} + +CUSTOM_SYSTEM_PROMPT = "For the below pdf page, convert it into as accurate markdown format as possible with it's structure intact i.e, tables, charts, layouts etc. Return only the markdown with no explanation text. Do not exclude any content from the page." + + +MODEL = 'gemini/gemini-1.5-pro' + + +@Client.on_message(filters.command('pdf2md', prefix) & filters.me) +async def pdf2md(client: Client, message: Message): + if not message.reply_to_message: + await message.edit('Reply to a pdf file') + return + if not message.reply_to_message.document: + await message.edit('Reply to a pdf file') + return + if message.reply_to_message.document.mime_type != 'application/pdf': + await message.edit('Reply to a pdf file') + return + if gemini_key == '': + await message.edit('Set GEMINI_KEY to use this command') + return + file_name = message.reply_to_message.document.file_name + file_name = file_name.split('.')[0] + if os.path.exists(f'{file_name}.md'): + os.remove(f'{file_name}.md') + md = f'{file_name}.md' + os.environ['GEMINI_API_KEY'] = gemini_key + await message.edit('Downloading pdf...') + pdf = await message.reply_to_message.download() + await message.edit('Converting pdf to markdown...') + try: + result = await zerox( + file_path=pdf, + model=MODEL, + custom_system_prompt=CUSTOM_SYSTEM_PROMPT, + select_pages=None, + **kwargs, + ) + if result: + pages = result.pages + for page in pages: + with open(md, 'a') as f: + f.write(page.content) + f.write('\n\n') + else: + await message.edit('No result') + return + except ModelAccessError: + await message.edit('Model not accessible') + return + except NotAVisionModel: + await message.edit('Model is not a vision model') + return + except litellm.InternalServerError: + await message.edit('Internal Server Error') + return + except Exception as e: + await message.edit(f'Error: {e}') + return + await message.edit('Uploading markdown...') + await client.send_document( + message.chat.id, + document=md, + file_name=f'{message.reply_to_message.document.file_name.split(".")[0]}.md', + reply_to_message_id=message.reply_to_message.id, + ) + await message.delete() + os.remove(pdf) + os.remove(md) + + +modules_help['pdf2md'] = {'pdf2md': 'Convert a pdf to markdown'} diff --git a/modules/perfectrussian.py b/modules/perfectrussian.py new file mode 100644 index 0000000..0807dcc --- /dev/null +++ b/modules/perfectrussian.py @@ -0,0 +1,36 @@ +import random + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import with_reply + + +@Client.on_message(filters.command('prus', prefix) & filters.me) +@with_reply +async def prussian_cmd(_, message: Message): + words = [ + 'сука', + 'нахуй', + 'блять', + 'блядь', + 'пиздец', + 'еблан', + 'уебан', + 'уебок', + 'пизда', + 'очко', + 'хуй', + ] + splitted = message.reply_to_message.text.split() + + for i in range(0, len(splitted), random.randint(2, 3)): # noqa: S311 + for _j in range(1, 2): + splitted.insert(i, random.choice(words)) # noqa: S311 + + await message.edit(' '.join(splitted), parse_mode=enums.ParseMode.HTML) + + +modules_help['perfectrussian'] = { + 'prus': 'translate your message into perfect 🇷🇺Russian', +} diff --git a/modules/ping.py b/modules/ping.py new file mode 100644 index 0000000..fecaeaf --- /dev/null +++ b/modules/ping.py @@ -0,0 +1,31 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['ping', 'p'], prefix) & filters.me) +async def ping(client: Client, message: Message): + latency = await client.ping() + await message.edit(f'Pong! {latency}ms') + + +modules_help['ping'] = { + 'ping': 'Check ping to Telegram servers', +} diff --git a/modules/prefix.py b/modules/prefix.py new file mode 100644 index 0000000..a7c49a0 --- /dev/null +++ b/modules/prefix.py @@ -0,0 +1,47 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.db import db +from utils.misc import modules_help, prefix +from utils.scripts import restart + + +@Client.on_message(filters.command(['sp', 'setprefix'], prefix) & filters.me) +async def setprefix(_, message: Message): + if len(message.command) > 1: + pref = message.command[1] + db.set('core.main', 'prefix', pref) + await message.edit(f'Prefix [ {pref} ] is set!\nRestarting...') + db.set( + 'core.updater', + 'restart_info', + { + 'type': 'restart', + 'chat_id': message.chat.id, + 'message_id': message.id, + }, + ) + restart() + else: + await message.edit('The prefix must not be empty!') + + +modules_help['prefix'] = { + 'sp [prefix]': 'Set custom prefix', + 'setprefix [prefix]': 'Set custom prefix', +} diff --git a/modules/purge.py b/modules/purge.py new file mode 100644 index 0000000..7cb6059 --- /dev/null +++ b/modules/purge.py @@ -0,0 +1,54 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import with_reply + + +@Client.on_message(filters.command('del', prefix) & filters.me) +async def del_msg(_, message: Message): + await message.delete() + await message.reply_to_message.delete() + + +@Client.on_message(filters.command('purge', prefix) & filters.me) +@with_reply +async def purge(client: Client, message: Message): + chunk = [] + async for msg in client.get_chat_history( + chat_id=message.chat.id, + limit=message.id - message.reply_to_message.id + 1, + ): + if msg.id < message.reply_to_message.id: + break + chunk.append(msg.id) + if len(chunk) >= 100: + await client.delete_messages(message.chat.id, chunk) + chunk.clear() + await asyncio.sleep(1) + + if len(chunk) > 0: + await client.delete_messages(message.chat.id, chunk) + + +modules_help['purge'] = { + 'purge [reply]': 'Purge (delete all messages) chat from replied message to last', + 'del [reply]': 'Delete replied message', +} diff --git a/modules/python.py b/modules/python.py new file mode 100644 index 0000000..5e166f7 --- /dev/null +++ b/modules/python.py @@ -0,0 +1,74 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from contextlib import redirect_stdout +from io import StringIO + +from pyrogram import Client, filters +from pyrogram.types import Message + +# noinspection PyUnresolvedReferences +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + +# noinspection PyUnresolvedReferences + + +# noinspection PyUnusedLocal +@Client.on_message(filters.command(['ex', 'exec', 'py', 'exnoedit'], prefix) & filters.me) +async def user_exec(_: Client, message: Message): + if len(message.command) == 1: + await message.edit("Code to execute isn't provided") + return + + code = message.text.split(maxsplit=1)[1] + stdout = StringIO() + + await message.edit('Executing...') + + try: + with redirect_stdout(stdout): + exec(code) # skipcq # noqa: S102 + text = f'Code:\n{code}\n\nResult:\n{stdout.getvalue()}' + if message.command[0] == 'exnoedit': + await message.reply(text) + else: + await message.edit(text) + except Exception as e: + await message.edit(format_exc(e)) + + +# noinspection PyUnusedLocal +@Client.on_message(filters.command(['ev', 'eval'], prefix) & filters.me) +async def user_eval(client: Client, message: Message): + if len(message.command) == 1: + await message.edit("Code to eval isn't provided") + return + + code = message.text.split(maxsplit=1)[1] + + try: + result = eval(code) # skipcq # noqa: S307 + await message.edit(f'Expression:\n{code}\n\nResult:\n{result}') + except Exception as e: + await message.edit(format_exc(e)) + + +modules_help['python'] = { + 'ex [python code]': 'Execute Python code', + 'exnoedit [python code]': 'Execute Python code and return result with reply', + 'eval [python code]': 'Eval Python code', +} diff --git a/modules/reactionspam.py b/modules/reactionspam.py new file mode 100644 index 0000000..0e38f6d --- /dev/null +++ b/modules/reactionspam.py @@ -0,0 +1,44 @@ +import random + +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + +emojis = [ + '👍', + '👎', + '❤️', + '🔥', + '🥰', + '👏', + '😁', + '🤔', + '🤯', + '😱', + '🤬', + '😢', + '🎉', + '🤩', + '🤮', + '💩', +] + + +@Client.on_message(filters.command('reactspam', prefix) & filters.me) +async def reactspam(client: Client, message: Message): + await message.edit('One moment...', parse_mode=enums.ParseMode.HTML) + try: + selected_emojis = random.sample(emojis, 3) + print(selected_emojis) + await client.send_reaction( + message.chat.id, + message_id=message.reply_to_message.id, + emoji=selected_emojis, + ) + await message.delete() + except Exception as e: + return await message.edit_text(format_exc(e)) + + +modules_help['reactionspam'] = {'reactspam [amount]* [emoji]*': 'spam reactions'} diff --git a/modules/removebg.py b/modules/removebg.py new file mode 100644 index 0000000..db720e1 --- /dev/null +++ b/modules/removebg.py @@ -0,0 +1,183 @@ +# Copyright (C) 2020-2021 by DevsExpo@Github, < https://github.com/DevsExpo >. +# +# This file is part of < https://github.com/DevsExpo/FridayUserBot > project, +# and is released under the "GNU v3.0 License Agreement". +# Please see < https://github.com/DevsExpo/blob/master/LICENSE > +# +# All rights reserved. +# Modifed by @moonuserbot + +import io +import os +from datetime import datetime +from functools import wraps +from io import BytesIO + +import requests +from PIL import Image +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.config import rmbg_key +from utils.misc import modules_help, prefix +from utils.scripts import edit_or_reply, format_exc + + +async def convert_to_image(message, client) -> None | str: + """Convert Most Media Formats To Raw Image""" + if not message: + return None + if not message.reply_to_message: + return None + final_path = None + if not ( + message.reply_to_message.video + or message.reply_to_message.photo + or message.reply_to_message.sticker + or message.reply_to_message.media + or message.reply_to_message.animation + or message.reply_to_message.audio + or message.reply_to_message.document + ): + return None + if message.reply_to_message.photo: + final_path = await message.reply_to_message.download() + elif message.reply_to_message.sticker: + if message.reply_to_message.sticker.mime_type == 'image/webp': + final_path = 'webp_to_png_s_proton.png' + path_s = await message.reply_to_message.download() + im = Image.open(path_s) + im.save(final_path, 'PNG') + else: + path_s = await client.download_media(message.reply_to_message) + final_path = 'lottie_proton.png' + cmd = f'lottie_convert.py --frame 0 -if lottie -of png {path_s} {final_path}' + await exec(cmd) # skipcq # noqa: S102 + elif message.reply_to_message.audio: + thumb = message.reply_to_message.audio.thumbs[0].file_id + final_path = await client.download_media(thumb) + elif message.reply_to_message.video or message.reply_to_message.animation: + final_path = 'fetched_thumb.png' + vid_path = await client.download_media(message.reply_to_message) + await exec(f'ffmpeg -i {vid_path} -filter:v scale=500:500 -an {final_path}') # skipcq # noqa: S102 + elif message.reply_to_message.document: + if ( + message.reply_to_message.document.mime_type == 'image/jpeg' + or message.reply_to_message.document.mime_type == 'image/png' + ): + final_path = await message.reply_to_message.download() + elif message.reply_to_message.document.mime_type == 'image/webp': + final_path = 'webp_to_png_s_proton.png' + path_s = await message.reply_to_message.download() + im = Image.open(path_s) + im.save(final_path, 'PNG') + else: + return None + return final_path + + +def remove_background(photo_data): + with open(photo_data, 'rb') as image_file: + image_data = image_file.read() + response = requests.post( + 'https://api.remove.bg/v1.0/removebg', + files={'image_file': image_data}, + data={'size': 'auto'}, + headers={'X-Api-Key': rmbg_key}, + timeout=10, + ) + if response.status_code == 200: + return BytesIO(response.content) + print('Error:', response.status_code, response.text) + return None + + +def _check_rmbg(func): + @wraps(func) + async def check_rmbg(client: Client, message: Message): + if not rmbg_key: + await edit_or_reply( + message, + "Is Your RMBG Api 'rmbg_key' Valid Or You Didn't Add It??", + ) + else: + await func(client, message) + + return check_rmbg + + +@Client.on_message(filters.command('rmbg', prefix) & filters.me) +@_check_rmbg +async def rmbg(client: Client, message: Message): + pablo = await edit_or_reply(message, 'Processing...') + if not message.reply_to_message: + await pablo.edit('Reply To A Image Please!') + return + cool = await convert_to_image(message, client) + if not cool: + await pablo.edit('Reply to a valid media first.') + return + start = datetime.now() + await pablo.edit('sending to ReMove.BG') + input_file_name = cool + files = { + 'image_file': (input_file_name, open(input_file_name, 'rb')), + } + r = requests.post( + 'https://api.remove.bg/v1.0/removebg', + headers={'X-Api-Key': rmbg_key}, + files=files, + allow_redirects=True, + stream=True, + timeout=10, + ) + if os.path.exists(cool): + os.remove(cool) + output_file_name = r + contentType = output_file_name.headers.get('content-type') + if 'image' in contentType: + with io.BytesIO(output_file_name.content) as remove_bg_image: + remove_bg_image.name = 'BG_rem.png' + await client.send_document(message.chat.id, remove_bg_image, reply_to_message_id=message.id) + end = datetime.now() + ms = (end - start).seconds + await pablo.edit(f"Removed image's Background in {ms} seconds.") + if os.path.exists('BG_rem.png'): + os.remove('BG_rem.png') + else: + await pablo.edit('ReMove.BG API returned Errors.' + f'\n`{output_file_name.content.decode("UTF-8")}') + + +@Client.on_message(filters.command('rebg', prefix) & filters.me) +async def rembg(client: Client, message: Message): + await message.edit('Processing...') + chat_id = message.chat.id + try: + try: + photo_data = await message.download() + except ValueError: + try: + photo_data = await message.reply_to_message.download() + except ValueError: + await message.edit('File not found') + return + background_removed_data = remove_background(photo_data) + + if background_removed_data: + await message.delete() + await client.send_photo(chat_id, photo=background_removed_data, caption='Background removed!') + else: + await message.edit_text( + "`Is Your RMBG Api 'rmbg_key' Valid Or You Didn't Add It??`\n **Check logs for details**", + parse_mode=enums.ParseMode.MARKDOWN, + ) + except Exception as e: + await message.reply_text(f'An error occurred: {format_exc(e)}') + finally: + if os.path.exists(photo_data): + os.remove(photo_data) + + +modules_help['removebg'] = { + 'rebg [reply to image]*': 'remove background from image without transparency', + 'rmbg [reply to image]*': 'remove background from image with transparency', +} diff --git a/modules/say.py b/modules/say.py new file mode 100644 index 0000000..a38668d --- /dev/null +++ b/modules/say.py @@ -0,0 +1,36 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['say', 's'], prefix) & filters.me) +async def say(_, message: Message): + if len(message.command) == 1: + return + command = ' '.join(message.command[1:]) + await message.edit(f'{command}') + + +modules_help['say'] = { + 'say [command]*': "Send message that won't be interpreted by userbot", +} diff --git a/modules/sendmod.py b/modules/sendmod.py new file mode 100644 index 0000000..b56adb7 --- /dev/null +++ b/modules/sendmod.py @@ -0,0 +1,55 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, format_module_help, format_small_module_help + + +@Client.on_message(filters.command(['sendmod', 'sm'], prefix) & filters.me) +async def sendmod(client: Client, message: Message): + if len(message.command) == 1: + await message.edit('Module name to send is not provided') + return + + await message.edit('Dispatching...') + try: + module_name = message.command[1].lower() + if module_name in modules_help: + text = format_module_help(module_name) + if len(text) >= 1024: + text = format_small_module_help(module_name) + if os.path.isfile(f'modules/{module_name}.py'): + await client.send_document(message.chat.id, f'modules/{module_name}.py', caption=text) + elif os.path.isfile(f'modules/custom_modules/{module_name.lower()}.py'): + await client.send_document( + message.chat.id, + f'modules/custom_modules/{module_name}.py', + caption=text, + ) + await message.delete() + else: + await message.edit(f'Module {module_name} not found!') + except Exception as e: + await message.edit(format_exc(e)) + + +modules_help['sendmod'] = { + 'sendmod [module_name]': 'Send module to interlocutor', +} diff --git a/modules/sessionkiller.py b/modules/sessionkiller.py new file mode 100644 index 0000000..fecde80 --- /dev/null +++ b/modules/sessionkiller.py @@ -0,0 +1,147 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +# TODO: Add ability to kill session by hash + +import time +from datetime import datetime +from html import escape + +from pyrogram import Client, ContinuePropagation, filters +from pyrogram.errors import RPCError +from pyrogram.raw.functions.account import GetAuthorizations, ResetAuthorization +from pyrogram.raw.types import UpdateServiceNotification +from pyrogram.types import Message +from utils.db import db +from utils.misc import modules_help, prefix + +auth_hashes = db.get('core.sessionkiller', 'auths_hashes', []) + + +@Client.on_message(filters.command(['sessions'], prefix) & filters.me) +async def sessions_list(client: Client, message: Message): + formatted_sessions = [] + sessions = (await client.invoke(GetAuthorizations())).authorizations + for num, session in enumerate(sessions, 1): + formatted_sessions.append( + f'{num}. {escape(session.device_model)} on {escape(session.platform if session.platform != "" else "unknown platform")}\n' + f'Hash: {session.hash}\n' + f'App name: {escape(session.app_name)} v.{escape(session.app_version if session.app_version != "" else "unknown")}\n' + f'Created (last activity): {datetime.fromtimestamp(session.date_created).isoformat()} ({datetime.fromtimestamp(session.date_active).isoformat()})\n' + f'IP and location: : {session.ip} ({session.country})\n' + f'Official status: {"✅" if session.official_app else "❌️"}\n' + f'2FA accepted: {"❌️️" if session.password_pending else "✅"}\n' + f'Can accept calls / secret chats: {"❌️️" if session.call_requests_disabled else "✅"} / {"❌️️" if session.encrypted_requests_disabled else "✅"}' + ) + answer = 'Active sessions at your account:\n\n' + chunk = [] + for s in formatted_sessions: + chunk.append(s) + if len(chunk) == 5: + answer += '\n\n'.join(chunk) + await message.reply(answer) + answer = '' + chunk.clear() + if chunk: + await message.reply('\n\n'.join(chunk)) + await message.delete() + + +@Client.on_message(filters.command(['sessionkiller', 'sk'], prefix) & filters.me) +async def sessionkiller(client: Client, message: Message): + if len(message.command) == 1: + if db.get('core.sessionkiller', 'enabled', False): + await message.edit( + 'Sessionkiller status: enabled\n' + f'You can disable it with {prefix}sessionkiller disable' + ) + else: + await message.edit( + 'Sessionkiller status: disabled\n' + f'You can enable it with {prefix}sessionkiller enable' + ) + elif message.command[1] in ['enable', 'on', '1', 'yes', 'true']: + db.set('core.sessionkiller', 'enabled', True) + await message.edit('Sessionkiller enabled!') + db.set( + 'core.sessionkiller', + 'auths_hashes', + [auth.hash for auth in (await client.invoke(GetAuthorizations())).authorizations], + ) + + elif message.command[1] in ['disable', 'off', '0', 'no', 'false']: + db.set('core.sessionkiller', 'enabled', False) + await message.edit('Sessionkiller disabled!') + else: + await message.edit(f'Usage: {prefix}sessionkiller [enable|disable]') + + +@Client.on_raw_update() +async def check_new_login(client: Client, update: UpdateServiceNotification, _, __): + if not isinstance(update, UpdateServiceNotification) or not update.type.startswith('auth'): + raise ContinuePropagation + if not db.get('core.sessionkiller', 'enabled', False): + raise ContinuePropagation + authorizations = (await client.invoke(GetAuthorizations()))['authorizations'] + for auth in authorizations: + if auth.current: + continue + if auth['hash'] not in auth_hashes: + # found new unexpected login + try: + await client.invoke(ResetAuthorization(hash=auth.hash)) + except RPCError: + info_text = ( + 'Someone tried to log in to your account. You can see this report because you' + "turned on this feature. But I couldn't terminate attacker's session and " + '⚠ you must reset it manually. You should change your 2FA password ' + '(if enabled), or set it.\n' + ) + else: + info_text = ( + 'Someone tried to log in to your account. Since you have enabled ' + "this feature, I deleted the attacker's session from your account. " + 'You should change your 2FA password (if enabled), or set it.\n' + ) + logined_time = datetime.utcfromtimestamp(auth.date_created).strftime('%d-%m-%Y %H-%M-%S UTC') + full_report = ( + '!!! ACTION REQUIRED !!!\n' + + info_text + + 'Below is the information about the attacker that I got.\n\n' + f'Unique authorization hash: {auth.hash} (not valid anymore)\n' + f'Device model: {escape(auth.device_model)}\n' + f'Platform: {escape(auth.platform)}\n' + f'API ID: {auth.api_id}\n' + f'App name: {escape(auth.app_name)}\n' + f'App version: {auth.app_version}\n' + f'Logined at: {logined_time}\n' + f'IP: {auth.ip}\n' + f'Country: {auth.country}\n' + f'Official app: {"yes" if auth.official_app else "no"}\n\n' + f'It is you? Type {prefix}sk off and try logging ' + f'in again.' + ) + # schedule sending report message so user will get notification + schedule_date = int(time.time() + 15) + await client.send_message('me', full_report, schedule_date=schedule_date) + return + + +modules_help['sessions'] = { + 'sessionkiller [enable|disable]': 'When enabled, every new session will be terminated.\n' + 'Useful for additional protection for your account', + 'sessions': 'List all sessions on your account', +} diff --git a/modules/sgb.py b/modules/sgb.py new file mode 100644 index 0000000..fb27e7e --- /dev/null +++ b/modules/sgb.py @@ -0,0 +1,51 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + + +from pyrogram import Client, filters +from pyrogram.errors import YouBlockedUser +from pyrogram.types import Message +from utils.conv import Conversation +from utils.misc import modules_help, prefix +from utils.scripts import format_exc + + +@Client.on_message(filters.command('sgb', prefix) & filters.me) +async def sg(client: Client, message: Message): + if message.reply_to_message and message.reply_to_message.from_user: + user_id = message.reply_to_message.from_user.id + else: + await message.edit(f'Usage: {prefix}sgb [id]') + return + try: + await message.edit('Processing please wait') + bot_username = '@SangMata_beta_bot' + async with Conversation(client, bot_username, timeout=15) as conv: + await conv.send_message(str(user_id)) + response = await conv.get_response(timeout=10) + if 'you have used up your quota' in response.text: + await message.edit(response.text.splitlines()[0]) + return + return await message.edit(response.text) + except YouBlockedUser: + await message.edit('Please unblock @SangMata_beta_bot first.') + except TimeoutError: + await message.edit('No response from bot within the timeout period.') + except Exception as e: + await message.edit(f'Error: {format_exc(e)}') + + +modules_help['sangmata'] = {'sgb': 'reply to any user'} diff --git a/modules/shell.py b/modules/shell.py new file mode 100644 index 0000000..b6b470f --- /dev/null +++ b/modules/shell.py @@ -0,0 +1,63 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +from subprocess import PIPE, Popen, TimeoutExpired +from time import perf_counter + +from pyrogram import Client, filters +from pyrogram.errors import MessageTooLong +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['shell', 'sh'], prefix) & filters.me) +async def shell(_, message: Message): + if len(message.command) < 2: + return await message.edit('Specify the command in message text') + cmd_text = message.text.split(maxsplit=1)[1] + cmd_args = cmd_text.split() + cmd_obj = Popen( # noqa: S603 + cmd_args, + stdout=PIPE, + stderr=PIPE, + text=True, + ) + + char = '#' if os.getuid() == 0 else '$' + text = f'{char} {cmd_text}\n\n' + + await message.edit(text + 'Running...') + try: + start_time = perf_counter() + stdout, stderr = cmd_obj.communicate(timeout=60) + except TimeoutExpired: + text += 'Timeout expired (60 seconds)' + else: + stop_time = perf_counter() + if stdout: + text += f'Output:\n{stdout}\n\n' + if stderr: + text += f'Error:\n{stderr}\n\n' + text += f'Completed in {round(stop_time - start_time, 5)} seconds with code {cmd_obj.returncode}' + try: + await message.edit(text) + except MessageTooLong: + await message.edit(text[:-100]) + cmd_obj.kill() + + +modules_help['shell'] = {'sh [command]*': 'Execute command in shell'} diff --git a/modules/socialstalk.py b/modules/socialstalk.py new file mode 100644 index 0000000..f6769df --- /dev/null +++ b/modules/socialstalk.py @@ -0,0 +1,194 @@ +import io +import json +from datetime import datetime + +import requests +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +TIKTOK_API_URL = 'https://api.maher-zubair.tech/stalk/tiktok?q=' +INSTAGRAM_API_URL = 'https://tools.betabotz.eu.org/tools/stalk-ig?q=' +GH_STALK = 'https://api.github.com/users/' + + +@Client.on_message(filters.command(['tiktokstalk'], prefix) & filters.me) +async def tiktok_stalk(_, message: Message): + query = '' + if len(message.command) > 1: + query = message.command[1] + elif message.reply_to_message: + query = message.reply_to_message.text.strip() + + if not query: + await message.edit('Usage: `tiktokstalk ` or reply to a message containing the username.') + return + + await message.edit('Fetching TikTok profile...') + url = f'{TIKTOK_API_URL}{query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json().get('result', {}) + if data: + profile_pic_url = data.get('profile', '') + profile_pic = requests.get(profile_pic_url, timeout=10).content + profile_pic_stream = io.BytesIO(profile_pic) + profile_pic_stream.name = 'profile.jpg' + + await message.reply_photo( + photo=profile_pic_stream, + caption=( + f'TikTok Profile:\n' + f'Name: {data.get("name", "N/A")}\n' + f'Username: {data.get("username", "N/A")}\n' + f'Followers: {data.get("followers", "N/A")}\n' + f'Following: {data.get("following", "N/A")}\n' + f'Likes: {data.get("likes", "N/A")}\n' + f'Description: {data.get("desc", "N/A")}\n' + f'Bio: {data.get("bio", "N/A")}' + ), + parse_mode=enums.ParseMode.MARKDOWN, + ) + else: + await message.edit('No data found for this TikTok user.') + await message.delete() + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command('ipinfo', prefix) & filters.me) +async def ipinfo(_, message: Message): + searchip = message.text.split(' ', 1) + if len(searchip) == 1: + await message.edit_text(f'Usage:{prefix}ipinfo [ip]') + return + searchip = searchip[1] + m = await message.edit_text('Searching...') + await m.edit_text('🔎') + try: + url = requests.get( + f'http://ip-api.com/json/{searchip}?fields=status,message,continent,continentCode,country,countryCode,region,regionName,city,district,zip,lat,lon,timezone,offset,currency,isp,org,as,asname,reverse,mobile,proxy,hosting,query' + , timeout=10) + response = json.loads(url.text) + text = f""" +IP Address: {response['query']} +Status: {response['status']} +Continent Code: {response['continentCode']} +Country: {response['country']} +Country Code : {response['countryCode']} +Region: {response['region']} +Region Name : {response['regionName']} +City: {response['city']} +District: {response['district']} +ZIP: {response['zip']} +Latitude: {response['lat']} +Longitude: {response['lon']} +Time Zone: {response['timezone']} +Offset: {response['offset']} +Currency: {response['currency']} +ISP: {response['isp']} +Org: {response['org']} +As: {response['as']} +Asname: {response['asname']} +Reverse: {response['reverse']} +User is on Mobile: {response['mobile']} +Proxy: {response['proxy']} +Hosting: {response['hosting']}""" + await m.edit_text(text) + except Exception: + await m.edit_text('Unable To Find Info!') + + +@Client.on_message(filters.command('instastalk', prefix) & filters.me) +async def instagram_stalk(_, message: Message): + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + query = message.reply_to_message.text + else: + await message.edit( + f'Usage: {prefix}instastalk or reply to a message containing the username.' + ) + return + + await message.edit('Fetching Instagram profile...') + url = f'{INSTAGRAM_API_URL}{query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json().get('result', {}).get('user_info', {}) + if data: + profile_pic_url = data.get('profile_pic_url', '') + profile_pic = requests.get(profile_pic_url, timeout=10).content + profile_pic_stream = io.BytesIO(profile_pic) + profile_pic_stream.name = 'profile.jpg' + + await message.reply_photo( + photo=profile_pic_stream, + caption=( + f'Instagram Profile:\n' + f'Full Name: {data.get("full_name", "N/A")}\n' + f'Username: {data.get("username", "N/A")}\n' + f'Biography: {data.get("biography", "N/A")}\n' + f'External URL: {data.get("external_url", "N/A")}\n' + f'Posts: {data.get("posts", "N/A")}\n' + f'Followers: {data.get("followers", "N/A")}\n' + f'Following: {data.get("following", "N/A")}' + ), + parse_mode=enums.ParseMode.MARKDOWN, + ) + else: + await message.edit('No data found for this Instagram user.') + await message.delete() + else: + await message.edit('An error occurred, please try again later.') + + +@Client.on_message(filters.command('ghstalk', prefix) & filters.me) +async def github_stalk(_, message: Message): + if len(message.command) > 1: + query = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + query = message.reply_to_message.text + else: + await message.edit( + f'Usage: {prefix}ghstalk or reply to a message containing the username.' + ) + return + + await message.edit('Fetching GitHub profile...') + url = f'{GH_STALK}{query}' + response = requests.get(url, timeout=10) + if response.status_code == 200: + data = response.json() + created_at = data.get('created_at', 'N/A') + formatted_date = datetime.strptime(created_at, '%Y-%m-%dT%H:%M:%S%z') if created_at != 'N/A' else None + if data: + await message.reply_photo( + photo=data.get('avatar_url', '').replace('?v=4', ''), + caption=f'GitHub Profile:\n' + f'Name: {data.get("name", "N/A")}\n' + f'Username: {data.get("login", "N/A")}\n' + f'Bio: {data.get("bio", "N/A")}\n' + f"Public Repositories: {data.get('public_repos', 'N/A')}\n" + f"Public Gists: {data.get('public_gists', 'N/A')}\n" + f'Company: {data.get("company", "N/A")}\n' + f'Location: {data.get("location", "N/A")}\n' + f'Email: {data.get("email", "N/A")}\n' + f'Website: {data.get("blog", "N/A")}\n' + f'Created At: {formatted_date.strftime("%Y-%m-%d %I:%M:%S %p") if formatted_date else "N/A"}\n' + f'Hireable: {data.get("hireable", "N/A")}\n' + f'Followers: {data.get("followers", "N/A")}\n' + f'Following: {data.get("following", "N/A")}', + ) + else: + await message.edit('No data found for this GitHub user.') + else: + await message.edit('An error occurred, please try again later.') + + +modules_help['socialstalk'] = { + 'tiktokstalk [username]*': 'Get TikTok profile information', + 'ipinfo [IP address]*': 'Get information about an IP address', + 'instastalk [username]*': 'Get Instagram profile information', + 'ghstalk [username]*': 'Get GitHub profile information', +} diff --git a/modules/spam.py b/modules/spam.py new file mode 100644 index 0000000..69582a9 --- /dev/null +++ b/modules/spam.py @@ -0,0 +1,53 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + +commands = ['spam', 'statspam', 'slowspam', 'fastspam'] + + +@Client.on_message(filters.command(commands, prefix) & filters.me) +async def spam(client: Client, message: Message): + amount = int(message.command[1]) + text = ' '.join(message.command[2:]) + + cooldown = {'spam': 0.15, 'statspam': 0.1, 'slowspam': 0.9, 'fastspam': 0} + + await message.delete() + + for _msg in range(amount): + if message.reply_to_message: + sent = await message.reply_to_message.reply(text) + else: + sent = await client.send_message(message.chat.id, text) + + if message.command[0] == 'statspam': + await asyncio.sleep(0.1) + await sent.delete() + + await asyncio.sleep(cooldown[message.command[0]]) + + +modules_help['spam'] = { + 'spam [amount] [text]': 'Start spam', + 'statspam [amount] [text]': 'Send and delete', + 'fastspam [amount] [text]': 'Start fast spam', + 'slowspam [amount] [text]': 'Start slow spam', +} diff --git a/modules/spin.py b/modules/spin.py new file mode 100644 index 0000000..dac7778 --- /dev/null +++ b/modules/spin.py @@ -0,0 +1,135 @@ +import asyncio +import random +from io import BytesIO + +import aiohttp + +# noinspection PyUnresolvedReferences +from modules.squotes import render_message +from pyrogram import Client, enums, filters, types +from pyrogram.types import Message + +# noinspection PyUnresolvedReferences +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, import_library, resize_image + +Image = import_library('PIL', 'pillow').Image +np = import_library('numpy') +imageio = import_library('imageio') + + +def create_gif(filename: str, offset: int, fps: int = 2, typ: str = 'spin'): + img = Image.open(f'downloads/{filename}') + if typ.lower() != 'spin': + img = img.resize((random.randint(200, 1280), random.randint(200, 1280)), Image.ANTIALIAS) # noqa: S311 # noqa: S311 + imageio.mimsave( + 'downloads/video.gif', + [img.rotate(-(i % 360)) for i in range(1, 361, offset)], + fps=fps, + ) + + +async def quote_cmd(client: Client, message: types.Message): + count = 1 + + is_png = False + send_for_me = False + no_reply = False + + messages = [] + + async for msg in client.get_chat_history(message.chat.id, offset_id=message.reply_to_message.id, reverse=True): + if msg.empty: + continue + if msg.message_id >= message.id: + break + if no_reply: + msg.reply_to_message = None + + messages.append(msg) + + if len(messages) >= count: + break + + if send_for_me: + await message.delete() + message = await client.send_message('me', 'Generating...', parse_mode=enums.ParseMode.HTML) + else: + await message.edit('Generating...', parse_mode=enums.ParseMode.HTML) + + url = 'https://quotes.fl1yd.su/generate' + params = { + 'messages': [await render_message(client, msg) for msg in messages if not msg.empty], + 'quote_color': '#162330', + 'text_color': '#fff', + } + + response = await aiohttp.ClientSession().post(url, json=params) + if response.status != 200: + return await message.edit( + f'Quotes API error!\n{response.text}', + parse_mode=enums.ParseMode.HTML, + ) + + resized = resize_image(BytesIO(await response.read()), img_type='PNG' if is_png else 'WEBP') + return resized, is_png + + +@Client.on_message(filters.command(['spin', 'dspin'], prefix) & filters.me) +async def spin_handler(client: Client, message: Message): + if not message.reply_to_message: + await message.edit( + 'Reply to a message to spin it!', + parse_mode=enums.ParseMode.HTML, + ) + await message.edit('Downloading sticker...', parse_mode=enums.ParseMode.HTML) + return await message.edit('Invalid file type!', parse_mode=enums.ParseMode.HTML) + try: + coro = True + if message.reply_to_message.document: + filename = message.reply_to_message.document.file_name + if ( + not filename.endswith('.webp') + and not filename.endswith('.png') + and not filename.endswith('.jpg') + and not filename.endswith('.jpeg') + ): + return await message.edit('Invalid file type!', parse_mode=enums.ParseMode.HTML) + elif message.reply_to_message.sticker: + if message.reply_to_message.sticker.is_video: + return await message.edit('Video stickers not allowed', parse_mode=enums.ParseMode.HTML) + filename = 'sticker.webp' + elif message.reply_to_message.text: + result = await quote_cmd(client, message) + filename = 'sticker.png' if result[1] else 'sticker.webp' + open('downloads/' + filename, 'wb').write(result[0].getbuffer()) + coro = False + else: + filename = 'photo.jpg' + if coro: + await message.reply_to_message.download(f'downloads/{filename}') + except Exception as ex: + return await message.edit( + f'Message can not be loaded:\n{format_exc(ex)}', + parse_mode=enums.ParseMode.HTML, + ) + await message.edit('Spinning...', parse_mode=enums.ParseMode.HTML) + offset = int(message.command[1]) if len(message.command) > 1 else 10 + fps = int(message.command[2]) if len(message.command) > 2 else 30 + try: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, lambda: create_gif(filename, offset, fps, message.command[0])) + await message.delete() + return await client.send_animation( + chat_id=message.chat.id, + animation='downloads/video.gif', + reply_to_message_id=message.reply_to_message.id, + ) + except Exception as e: + await message.reply(format_exc(e), parse_mode=enums.ParseMode.HTML) + + +modules_help['spin'] = { + 'spin [offset] [fps]': 'Spin message (Reply required)', + 'dspin [offset] [fps]': 'SHAKAL spin message (Reply required)', +} diff --git a/modules/squotes.py b/modules/squotes.py new file mode 100644 index 0000000..9f180ab --- /dev/null +++ b/modules/squotes.py @@ -0,0 +1,448 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import base64 +from io import BytesIO + +import requests +from pyrogram import Client, errors, filters, types +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, resize_image, with_reply + +QUOTES_API = 'https://quotes-o042.onrender.com/generate' + + +@Client.on_message(filters.command(['q', 'quote'], prefix) & filters.me) +@with_reply +async def quote_cmd(client: Client, message: Message): + if len(message.command) > 1 and message.command[1].isdigit(): + count = int(message.command[1]) + if count < 1: + count = 1 + elif count > 15: + count = 15 + else: + count = 1 + + is_png = '!png' in message.command or '!file' in message.command + send_for_me = '!me' in message.command or '!ls' in message.command + no_reply = '!noreply' in message.command or '!nr' in message.command + + messages = [] + + async for msg in client.get_chat_history( + message.chat.id, + offset_id=message.reply_to_message.id + count, + limit=count, + ): + if msg.empty: + continue + if msg.id >= message.id: + break + if no_reply: + msg.reply_to_message = None + + messages.append(msg) + + if len(messages) >= count: + break + + messages.reverse() + + if send_for_me: + await message.delete() + message = await client.send_message('me', 'Generating...') + else: + await message.edit('Generating...') + + params = { + 'messages': [await render_message(client, msg) for msg in messages if not msg.empty], + 'quote_color': '#162330', + 'text_color': '#fff', + } + + response = requests.post(QUOTES_API, json=params, timeout=10) + if not response.ok: + return await message.edit(f'Quotes API error!\n{response.text}') + + resized = resize_image(BytesIO(response.content), img_type='PNG' if is_png else 'WEBP') + await message.edit('Sending...') + + try: + func = client.send_document if is_png else client.send_sticker + chat_id = 'me' if send_for_me else message.chat.id + await func(chat_id, resized) + except errors.RPCError as e: # no rights to send stickers, etc + await message.edit(format_exc(e)) + else: + await message.delete() + + +@Client.on_message(filters.command(['fq', 'fakequote'], prefix) & filters.me) +@with_reply +async def fake_quote_cmd(client: Client, message: types.Message): + is_png = '!png' in message.command or '!file' in message.command + send_for_me = '!me' in message.command or '!ls' in message.command + no_reply = '!noreply' in message.command or '!nr' in message.command + + fake_quote_text = ' '.join( + [ + arg for arg in message.command[1:] if arg not in ['!png', '!file', '!me', '!ls', '!noreply', '!nr'] + ] # remove some special arg words + ) + + if not fake_quote_text: + return await message.edit('Fake quote text is empty') + + q_message = await client.get_messages(message.chat.id, message.reply_to_message.id) + q_message.text = fake_quote_text + q_message.entities = None + if no_reply: + q_message.reply_to_message = None + + if send_for_me: + await message.delete() + message = await client.send_message('me', 'Generating...') + else: + await message.edit('Generating...') + + params = { + 'messages': [await render_message(client, q_message)], + 'quote_color': '#162330', + 'text_color': '#fff', + } + + response = requests.post(QUOTES_API, json=params, timeout=10) + if not response.ok: + return await message.edit(f'Quotes API error!\n{response.text}') + + resized = resize_image(BytesIO(response.content), img_type='PNG' if is_png else 'WEBP') + await message.edit('Sending...') + + try: + func = client.send_document if is_png else client.send_sticker + chat_id = 'me' if send_for_me else message.chat.id + await func(chat_id, resized) + except errors.RPCError as e: # no rights to send stickers, etc + await message.edit(format_exc(e)) + else: + await message.delete() + + +files_cache = {} + + +async def render_message(app: Client, message: types.Message) -> dict: + async def get_file(file_id) -> str: + if file_id in files_cache: + return files_cache[file_id] + + content = await app.download_media(file_id, in_memory=True) + data = base64.b64encode(bytes(content.getbuffer())).decode() + files_cache[file_id] = data + return data + + # text + if message.photo: + text = message.caption if message.caption else '' + elif message.poll: + text = get_poll_text(message.poll) + elif message.sticker: + text = '' + else: + text = get_reply_text(message) + + # media + if message.photo: + media = await get_file(message.photo.file_id) + elif message.sticker: + media = await get_file(message.sticker.file_id) + else: + media = '' + + # entities + entities = [] + if message.entities: + for entity in message.entities: + entities.append( + { + 'offset': entity.offset, + 'length': entity.length, + 'type': str(entity.type).split('.')[-1].lower(), + } + ) + + def move_forwards(msg: types.Message): + if msg.forward_origin: + if isinstance(msg.forward_origin, types.MessageOriginUser): + msg.from_user = msg.forward_origin.sender_user + elif isinstance(msg.forward_origin, types.MessageOriginHiddenUser): + msg.from_user.id = 0 + msg.from_user.first_name = msg.forward_origin.sender_user_name + msg.from_user.last_name = '' + elif isinstance(msg.forward_origin, types.MessageOriginChat): + msg.sender_chat = msg.forward_origin.sender_chat + msg.from_user.id = 0 + if msg.forward_origin.author_signature: + msg.author_signature = msg.forward_origin.author_signature + + move_forwards(message) + + # author + author = {} + if message.from_user and message.from_user.id != 0: + from_user = message.from_user + + author['id'] = from_user.id + author['name'] = get_full_name(from_user) + if message.author_signature: + author['rank'] = message.author_signature + elif message.chat.type != 'supergroup' or message.forward_date: + author['rank'] = '' + else: + try: + member = await message.chat.get_member(from_user.id) + except errors.UserNotParticipant: + author['rank'] = '' + else: + author['rank'] = getattr(member, 'title', '') or ( + 'owner' if member.status == 'creator' else 'admin' if member.status == 'administrator' else '' + ) + + if from_user.photo: + author['avatar'] = await get_file(from_user.photo.big_file_id) + elif not from_user.photo and from_user.username: + # may be user blocked us, we will try to get avatar via t.me + t_me_page = requests.get(f'https://t.me/{from_user.username}', timeout=10).text + sub = ' 0 and link[0] and link[0] != 'https://telegram.org/img/t_logo.png': + # found valid link + avatar = requests.get(link[0], timeout=10).content + author['avatar'] = base64.b64encode(avatar).decode() + else: + author['avatar'] = '' + else: + author['avatar'] = '' + else: + author['avatar'] = '' + elif message.from_user and message.from_user.id == 0: + author['id'] = 0 + author['name'] = message.from_user.first_name + author['rank'] = '' + else: + author['id'] = message.sender_chat.id + author['name'] = message.sender_chat.title + author['rank'] = 'channel' if message.sender_chat.type == 'channel' else '' + + if message.sender_chat.photo: + author['avatar'] = await get_file(message.sender_chat.photo.big_file_id) + else: + author['avatar'] = '' + author['via_bot'] = message.via_bot.username if message.via_bot else '' + + # reply + reply = {} + reply_msg = message.reply_to_message + if reply_msg and not reply_msg.empty: + move_forwards(reply_msg) + + if reply_msg.from_user: + reply['id'] = reply_msg.from_user.id + reply['name'] = get_full_name(reply_msg.from_user) + else: + reply['id'] = reply_msg.sender_chat.id + reply['name'] = reply_msg.sender_chat.title + + reply['text'] = get_reply_text(reply_msg) + + return { + 'text': text, + 'media': media, + 'entities': entities, + 'author': author, + 'reply': reply, + } + + +def get_audio_text(audio: types.Audio) -> str: + if audio.title and audio.performer: + return f' ({audio.title} — {audio.performer})' + if audio.title: + return f' ({audio.title})' + if audio.performer: + return f' ({audio.performer})' + return '' + + +def get_reply_text(reply: types.Message) -> str: + return ( + '📷 Photo' + ('\n' + reply.caption if reply.caption else '') + if reply.photo + else ( + get_reply_poll_text(reply.poll) + if reply.poll + else ( + '📍 Location' + if reply.location or reply.venue + else ( + '👤 Contact' + if reply.contact + else ( + '🖼 GIF' + if reply.animation + else ( + '🎧 Music' + get_audio_text(reply.audio) + if reply.audio + else ( + '📹 Video' + if reply.video + else ( + '📹 Videomessage' + if reply.video_note + else ( + '🎵 Voice' + if reply.voice + else ( + (reply.sticker.emoji + ' ' if reply.sticker.emoji else '') + 'Sticker' + if reply.sticker + else ( + '💾 File ' + reply.document.file_name + if reply.document + else ( + '🎮 Game' + if reply.game + else ( + '🎮 set new record' + if reply.game_high_score + else ( + f'{reply.dice.emoji} - {reply.dice.value}' + if reply.dice + else ( + ( + '👤 joined the group' + if reply.new_chat_members[0].id + == reply.from_user.id + else f'👤 invited {get_full_name(reply.new_chat_members[0])} to the group' + ) + if reply.new_chat_members + else ( + ( + '👤 left the group' + if reply.left_chat_member.id + == reply.from_user.id + else f'👤 removed {get_full_name(reply.left_chat_member)}' + ) + if reply.left_chat_member + else ( + f'✏ changed group name to {reply.new_chat_title}' + if reply.new_chat_title + else ( + '🖼 changed group photo' + if reply.new_chat_photo + else ( + '🖼 removed group photo' + if reply.delete_chat_photo + else ( + '📍 pinned message' + if reply.pinned_message + else ( + '🎤 started a new video chat' + if reply.video_chat_started + else ( + '🎤 ended the video chat' + if reply.video_chat_ended + else ( + '🎤 invited participants to the video chat' + if reply.video_chat_members_invited + else ( + '👥 created the group' + if reply.group_chat_created + or reply.supergroup_chat_created + else ( + '👥 created the channel' + if reply.channel_chat_created + else reply.text + or 'unsupported message' + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + ) + + +def get_poll_text(poll: types.Poll) -> str: + text = get_reply_poll_text(poll) + '\n' + + text += poll.question + '\n' + for option in poll.options: + text += f'- {option.text}' + if option.voter_count > 0: + text += f' ({option.voter_count} voted)' + text += '\n' + + text += f'Total: {poll.total_voter_count} voted' + + return text + + +def get_reply_poll_text(poll: types.Poll) -> str: + if poll.is_anonymous: + text = '📊 Anonymous poll' if poll.type == 'regular' else '📊 Anonymous quiz' + else: + text = '📊 Poll' if poll.type == 'regular' else '📊 Quiz' + if poll.is_closed: + text += ' (closed)' + + return text + + +def get_full_name(user: types.User) -> str: + name = user.first_name + if user.last_name: + name += ' ' + user.last_name + return name + + +modules_help['squotes'] = { + 'q [reply]* [count 1-15] [!png] [!me] [!noreply]': 'Generate a quote\n' + 'Available options: !png — send as PNG, !me — send quote to' + 'saved messages, !noreply — generate quote without reply', + 'fq [reply]* [!png] [!me] [!noreply] [text]*': 'Generate a fake quote', +} diff --git a/modules/stickers.py b/modules/stickers.py new file mode 100644 index 0000000..d310280 --- /dev/null +++ b/modules/stickers.py @@ -0,0 +1,130 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +from io import BytesIO + +from pyrogram import Client, enums, filters, types +from utils.misc import modules_help, prefix +from utils.scripts import ( + format_exc, + interact_with, + interact_with_to_delete, + resize_image, + with_reply, +) + + +@Client.on_message(filters.command('kang', prefix) & filters.me) +@with_reply +async def kang(client: Client, message: types.Message): + await message.edit('Please wait...') + + if len(message.command) < 2: + await message.edit( + f'No arguments provided\nUsage: {prefix}kang [pack]* [emoji]', + ) + return + + pack = message.command[1] + emoji = message.command[2] if len(message.command) >= 3 else '✨' + + await client.unblock_user('@stickers') + await interact_with(await client.send_message('@stickers', '/cancel', parse_mode=enums.ParseMode.MARKDOWN)) + await interact_with(await client.send_message('@stickers', '/addsticker', parse_mode=enums.ParseMode.MARKDOWN)) + + result = await interact_with(await client.send_message('@stickers', pack, parse_mode=enums.ParseMode.MARKDOWN)) + if '.TGS' in result.text: + await message.edit("Animated packs aren't supported") + return + if 'StickerExample.psd' not in result.text: + await message.edit( + "Stickerpack doesn't exitst. Create it using @Stickers bot (via /newpack command)", + ) + return + + try: + path = await message.reply_to_message.download() + except ValueError: + await message.edit( + "Replied message doesn't contain any downloadable media", + ) + return + + resized = resize_image(path) + if os.path.exists(path): + os.remove(path) + + await interact_with(await client.send_document('@stickers', resized, parse_mode=enums.ParseMode.MARKDOWN)) + response = await interact_with(await client.send_message('@stickers', emoji, parse_mode=enums.ParseMode.MARKDOWN)) + if '/done' in response.text: + # ok + await interact_with(await client.send_message('@stickers', '/done', parse_mode=enums.ParseMode.MARKDOWN)) + await client.delete_messages('@stickers', interact_with_to_delete) + await message.edit( + f'Sticker added to pack', + ) + else: + await message.edit('Something went wrong. Check history with @stickers') + interact_with_to_delete.clear() + + +@Client.on_message(filters.command(['stp', 's2p', 'stick2png'], prefix) & filters.me) +@with_reply +async def stick2png(client: Client, message: types.Message): + try: + await message.edit('Downloading...') + + path = await message.reply_to_message.download() + with open(path, 'rb') as f: + content = f.read() + if os.path.exists(path): + os.remove(path) + + file_io = BytesIO(content) + file_io.name = 'sticker.png' + + await client.send_document(message.chat.id, file_io, parse_mode=enums.ParseMode.MARKDOWN) + except Exception as e: + await message.edit(format_exc(e)) + else: + await message.delete() + + +@Client.on_message(filters.command(['resize'], prefix) & filters.me) +@with_reply +async def resize_cmd(client: Client, message: types.Message): + try: + await message.edit('Downloading...') + + path = await message.reply_to_message.download() + resized = resize_image(path) + resized.name = 'image.png' + if os.path.exists(path): + os.remove(path) + + await client.send_document(message.chat.id, resized, parse_mode=enums.ParseMode.MARKDOWN) + except Exception as e: + await message.edit(format_exc(e)) + else: + await message.delete() + + +modules_help['stickers'] = { + 'kang [reply]* [pack]* [emoji]': 'Add sticker to defined pack', + 'stp [reply]*': 'Convert replied sticker to PNG', + 'resize [reply]*': 'Resize replied image to 512xN format', +} diff --git a/modules/support.py b/modules/support.py new file mode 100644 index 0000000..03ae8c0 --- /dev/null +++ b/modules/support.py @@ -0,0 +1,94 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import datetime +import random + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import gitrepo, modules_help, prefix, python_version, userbot_version + + +@Client.on_message(filters.command(['support', 'repo'], prefix) & filters.me) +async def support(_, message: Message): + devs = ['@Qbtaumai', '@H4T3H46K3R'] + random.shuffle(devs) + + commands_count = 0.0 + for module in modules_help: + for _cmd in module: + commands_count += 1 + + await message.edit( + f'Moon-Userbot\n\n' + 'GitHub: Moon-Userbot\n' + 'Custom modules repository: ' + 'custom_modules\n' + 'License: GNU GPL v3\n\n' + 'Channel: @moonuserbot\n' + 'Custom modules: @moonub_modules\n' + 'Chat [EN]: @moonub_chat\n' + f'Main developers: {", ".join(devs)}\n\n' + f'Python version: {python_version}\n' + f'Modules count: {len(modules_help) / 1}\n' + f'Commands count: {commands_count}', + disable_web_page_preview=True, + ) + + +@Client.on_message(filters.command(['version', 'ver'], prefix) & filters.me) +async def version(client: Client, message: Message): + changelog = '' + ub_version = '.'.join(userbot_version.split('.')[:2]) + async for m in client.search_messages('moonuserbot', query=f'{userbot_version}.'): + if ub_version in m.text: + changelog = m.message_id + + await message.delete() + + if gitrepo is not None: + remote_url = list(gitrepo.remote().urls)[0] + commit_time = ( + datetime.datetime.fromtimestamp(gitrepo.head.commit.committed_date) + .astimezone(datetime.UTC) + .strftime('%Y-%m-%d %H:%M:%S %Z') + ) + git_info = ( + ( + f'\nBranch: {gitrepo.active_branch}\n' + if gitrepo.active_branch != 'master' + else '\n' + ) + + f'Commit: ' + f'{gitrepo.head.commit.hexsha[:7]} by {gitrepo.head.commit.author.name}\n' + f'Commit time: {commit_time}' + ) + else: + git_info = '' + + await message.reply( + f'Moon Userbot version: {userbot_version}\n' + f'Changelog in channel.\n' + f'Changelog written by ' + f'Abhi\n\n' + f'{git_info}', + ) + + +modules_help['support'] = { + 'support': 'Information about userbot', + 'version': 'Check userbot version', +} diff --git a/modules/thumbnail.py b/modules/thumbnail.py new file mode 100644 index 0000000..99764b4 --- /dev/null +++ b/modules/thumbnail.py @@ -0,0 +1,42 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from PIL import Image +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command('setthumb', prefix) & filters.me) +async def setthumb(_, message: Message): + THUMB_PATH = 'downloads/thumb' + if message.reply_to_message: + if not os.path.exists(THUMB_PATH): + os.makedirs(THUMB_PATH) + new_thumb = await message.reply_to_message.download() + with Image.open(new_thumb) as img: + if img.format in ['PNG', 'JPG', 'JPEG']: + new_path = os.path.join(THUMB_PATH, 'thumb.jpg') + os.rename(new_thumb, new_path) + await message.edit_text('Thumbnail set successfully!') + else: + await message.edit_text('Kindly reply to a PHOTO Entity!') + return + + +modules_help['thumb'] = {'setthumb [reply_to_photo]*': 'set your own custom thumbnail'} diff --git a/modules/type.py b/modules/type.py new file mode 100644 index 0000000..5f41773 --- /dev/null +++ b/modules/type.py @@ -0,0 +1,40 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix + + +@Client.on_message(filters.command(['type', 'typewriter'], prefix) & filters.me) +async def type_cmd(_, message: Message): + text = message.text.split(maxsplit=1)[1] + typed = '' + typing_symbol = '▒' + + for char in text: + await message.edit(typed + typing_symbol) + await asyncio.sleep(0.1) + typed += char + await message.edit(typed) + await asyncio.sleep(0.1) + + +modules_help['type'] = { + 'type | typewriter [text]*': "Typing emulation. Don't use a lot of characters, you can receive a lot of floodwaits!" +} diff --git a/modules/updater.py b/modules/updater.py new file mode 100644 index 0000000..39bdce1 --- /dev/null +++ b/modules/updater.py @@ -0,0 +1,111 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os +import shutil +import subprocess +import sys + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.db import db +from utils.misc import modules_help, prefix, requirements_list +from utils.scripts import format_exc, restart + + +def check_command(command): + return shutil.which(command) is not None + + +@Client.on_message(filters.command('restart', prefix) & filters.me) +async def restart_cmd(_, message: Message): + db.set( + 'core.updater', + 'restart_info', + { + 'type': 'restart', + 'chat_id': message.chat.id, + 'message_id': message.id, + }, + ) + + if 'LAVHOST' in os.environ: + await message.edit('Your lavHost is restarting...') + os.system('lavhost restart') # noqa: S605, S607 + return + + await message.edit('Restarting...') + if os.path.exists('moonlogs.txt'): + os.remove('moonlogs.txt') + restart() + + +@Client.on_message(filters.command('update', prefix) & filters.me) +async def update(_, message: Message): + db.set( + 'core.updater', + 'restart_info', + { + 'type': 'update', + 'chat_id': message.chat.id, + 'message_id': message.id, + }, + ) + + if 'LAVHOST' in os.environ: + await message.edit('Your lavHost is updating...') + os.system('lavhost update') # noqa: S605, S607 + return + + await message.edit('Updating...') + try: + if not check_command('termux-setup-storage'): + subprocess.run([sys.executable, '-m', 'pip', 'install', '-U', 'pip'], check=True) + subprocess.run(['git', 'pull'], check=True) # noqa: S607 + + if os.path.exists('requirements.txt') and os.path.getsize('requirements.txt') > 0: + subprocess.run( + [ + sys.executable, + '-m', + 'pip', + 'install', + '-U', + '-r', + 'requirements.txt', + ], + check=True, + ) + + if requirements_list: + subprocess.run( # noqa: S603 + [sys.executable, '-m', 'pip', 'install', '-U', *requirements_list], + check=True, + ) + except Exception as e: + await message.edit(format_exc(e)) + db.remove('core.updater', 'restart_info') + else: + await message.edit('Updating: done! Restarting...') + if os.path.exists('moonlogs.txt'): + os.remove('moonlogs.txt') + restart() + + +modules_help['updater'] = { + 'update': 'Update the userbot. If new core modules are avaliable, they will be installed', + 'restart': 'Restart userbot', +} diff --git a/modules/upl.py b/modules/upl.py new file mode 100644 index 0000000..2fb5d72 --- /dev/null +++ b/modules/upl.py @@ -0,0 +1,122 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import io +import os +import time + +from pyrogram import Client, filters +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, progress + + +@Client.on_message(filters.command('upl', prefix) & filters.me) +async def upl(client: Client, message: Message): + if len(message.command) > 1: + link = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + link = message.reply_to_message.text + else: + await message.edit(f'Usage: {prefix}upl [filepath to upload]') + return + + if not os.path.isfile(link): + await message.edit(f'Error: {link} is not a valid file path.') + return + + try: + await message.edit('Uploading Now...') + await client.send_document( + message.chat.id, + link, + progress=progress, + progress_args=(message, time.time(), 'Uploading Now...', link), + ) + await message.delete() + except Exception as e: + await message.edit(format_exc(e)) + + +@Client.on_message(filters.command('dlf', prefix) & filters.me) +async def dlf(client: Client, message: Message): + if message.reply_to_message: + await client.download_media( + message.reply_to_message, + progress=progress, + progress_args=(message, time.time(), 'Uploading Now...'), + ) + await message.edit('Downloaded Successfully!') + else: + await message.edit(f'Usage: {prefix}dlf [reply to a file]') + + +@Client.on_message(filters.command('moonlogs', prefix) & filters.me) +async def mupl(client: Client, message: Message): + link = 'moonlogs.txt' + if os.path.exists(link): + try: + await message.edit('Uploading Now...') + with open(link, 'rb') as f: + data = f.read() + bio = io.BytesIO(data) + bio.name = 'moonlogs.txt' + await client.send_document( + message.chat.id, bio, progress=progress, progress_args=(message, time.time(), 'Uploading Now...') + ) + await message.delete() + except Exception as e: + await client.send_message(message.chat.id, format_exc(e)) + else: + await message.edit("Error: LOGS file doesn't exist.") + + +@Client.on_message(filters.command('uplr', prefix) & filters.me) +async def uplr(client: Client, message: Message): + if len(message.command) > 1: + link = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + link = message.reply_to_message.text + else: + await message.edit(f'Usage: {prefix}upl [filepath to upload]') + return + + if not os.path.isfile(link): + await message.edit(f'Error: {link} is not a valid file path.') + return + + try: + await message.edit('Uploading Now...') + await client.send_document( + message.chat.id, + link, + progress=progress, + progress_args=(message, time.time(), 'Uploading Now...', link), + ) + await message.delete() + except Exception as e: + await message.edit(format_exc(e)) + finally: + if os.path.exists(link): + os.remove(link) + + +modules_help['uplud'] = { + 'upl [filepath]/[reply to path]*': 'Upload a file from your local machine to Telegram', + 'dlf': 'Download a file from Telegram to your local machine', + 'uplr [filepath]/[reply to path]*': 'Upload a file from your local machine to Telegram, delete the file after uploading', + 'moonlogs': 'Upload the moonlogs.txt file to Telegram', +} diff --git a/modules/url.py b/modules/url.py new file mode 100644 index 0000000..09155cf --- /dev/null +++ b/modules/url.py @@ -0,0 +1,267 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio +import math +import mimetypes +import os +import time +from datetime import datetime +from io import BytesIO +from urllib.parse import unquote + +import requests +import urllib3 +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from pySmartDL import SmartDL +from utils.config import apiflash_key +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, humanbytes, progress + + +def generate_screenshot(url): + api_url = f'https://api.apiflash.com/v1/urltoimage?access_key={apiflash_key}&url={url}&format=png' + response = requests.get(api_url, timeout=10) + if response.status_code == 200: + return BytesIO(response.content) + return None + + +http = urllib3.PoolManager() + + +@Client.on_message(filters.command('short', prefix) & filters.me) +async def short(_, message: Message): + if len(message.command) > 1: + link = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + link = message.reply_to_message.text + else: + await message.edit(f'Usage: {prefix}short [url to short]') + return + r = http.request('GET', 'https://clck.ru/--?url=' + link) + await message.edit( + r.data.decode().replace('https://', 'Shortened Url:'), + disable_web_page_preview=True, + ) + + +@Client.on_message(filters.command('urldl', prefix) & filters.me) +async def urldl(client: Client, message: Message): + if len(message.command) > 1: + message_id = None + link = message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + message_id = message.reply_to_message.id + link = message.reply_to_message.text + else: + await message.edit(f'Usage: {prefix}urldl [url to download]') + return + + await message.edit('Trying to download...') + + c_time = time.time() + + resp = requests.head(link, allow_redirects=True, timeout=5) + if resp.status_code != 200: + return await message.edit('Failed to fetch request header information') + + content_type = resp.headers.get('Content-Type').split(';')[0] + extension = mimetypes.guess_extension(content_type) + + # Check if the file is an executable binary + is_executable = content_type in [ + 'application/octet-stream', + 'application/x-msdownload', + ] + + # Get the file extension from the URL + url_extension = os.path.splitext(link)[1].lower() + + try: + os.makedirs('downloads') + if is_executable: + file_name = 'downloads/' + link.split('/')[-1] + if not file_name.endswith(url_extension): + file_name += url_extension + elif extension: + file_name = 'downloads/' + link.split('/')[-1] + if not file_name.endswith(extension): + file_name += extension + else: + file_name = 'downloads/' + link.split('/')[-1] + except FileNotFoundError: + if is_executable: + file_name = 'downloads/' + link.split('/')[-1] + if not file_name.endswith(url_extension): + file_name += url_extension + elif extension: + file_name = 'downloads/' + link.split('/')[-1] + if not file_name.endswith(extension): + file_name += extension + else: + file_name = 'downloads/' + link.split('/')[-1] + except FileExistsError: + if is_executable: + file_name = 'downloads/' + link.split('/')[-1] + if not file_name.endswith(url_extension): + file_name += url_extension + elif extension: + file_name = 'downloads/' + link.split('/')[-1] + if not file_name.endswith(extension): + file_name += extension + else: + file_name = 'downloads/' + link.split('/')[-1] + + downloader = SmartDL(link, file_name, progress_bar=False, timeout=10) + start_t = datetime.now() + try: + downloader.start(blocking=False) + except Exception as e: + return await message.edit_text(format_exc(e)) + while not downloader.isFinished(): + total_length = downloader.filesize or None + downloaded = downloader.get_dl_size(human=True) + u_m = '' + now = time.time() + diff = now - c_time + percentage = downloader.get_progress() * 100 + speed = downloader.get_speed(human=True) + progress_str = ( + ''.join(['▰' for _ in range(math.floor(percentage / 5))]) + + ''.join(['▱' for _ in range(20 - math.floor(percentage / 5))]) + + f'\nProgress: {round(percentage, 2)}%' + ) + eta = downloader.get_eta(human=True) + try: + m = 'Trying to download...\n' + m += f'File Name: {unquote(link.split("/")[-1])}\n' + m += f'Speed: {speed}\n' + m += f'{progress_str}\n' + m += f'{downloaded} of {humanbytes(total_length)}\n' + m += f'ETA: {eta}' + if round(diff % 10.00) == 0 and m != u_m: + await message.edit_text(disable_web_page_preview=True, text=m) + u_m = m + await asyncio.sleep(5) + except Exception as e: + await message.edit_text(format_exc(e)) + if os.path.exists(file_name): + end_t = datetime.now() + sec = (end_t - start_t).seconds + await message.edit_text(f'Downloaded to {file_name} in {sec} seconds') + ms_ = await message.edit('Starting Upload...') + await client.send_document( + message.chat.id, + file_name, + progress=progress, + progress_args=(ms_, c_time, '`Uploading...`'), + caption=f'File Name: {unquote(link.split("/")[-1])}\n', + reply_to_message_id=message_id, + ) + await message.delete() + os.remove(file_name) + else: + await message.edit('Failed to download') + + +@Client.on_message(filters.command('upload', prefix) & filters.me) +async def upload_cmd(_, message: Message): + max_size = 512 * 1024 * 1024 + max_size_mb = 100 + + min_file_age = 31 + max_file_age = 180 + + ms_ = await message.edit('`Downloading...`', parse_mode=enums.ParseMode.MARKDOWN) + c_time = time.time() + + try: + file_name = await message.download(progress=progress, progress_args=(ms_, c_time, '`Downloading...`')) + except ValueError: + try: + file_name = await message.reply_to_message.download( + progress=progress, progress_args=(ms_, c_time, '`Downloading...`') + ) + except ValueError: + await message.edit('File to upload not found') + return + + if os.path.getsize(file_name) > max_size: + await message.edit(f"Files longer than {max_size_mb}MB isn't supported") + if os.path.exists(file_name): + os.remove(file_name) + return + + await message.edit('Uploading...') + with open(file_name, 'rb') as f: + response = requests.post( + 'https://x0.at', + files={'file': f}, + timeout=10, + ) + + if response.ok: + file_size_mb = os.path.getsize(file_name) / 1024 / 1024 + file_age = int(min_file_age + (max_file_age - min_file_age) * ((1 - (file_size_mb / max_size_mb)) ** 2)) + url = response.text.replace('https://', '') + await message.edit( + f'Your URL: {url}\nYour file will remain live for {file_age} days', + disable_web_page_preview=True, + ) + else: + await message.edit(f'API returned an error!\n{response.text}\n Not allowed') + print(response.text) + if os.path.exists(file_name): + os.remove(file_name) + + +@Client.on_message(filters.command(['ws', 'webshot'], prefix) & filters.me) +async def webshot(client: Client, message: Message): + if len(message.command) > 1: + url = message.text.split(maxsplit=1)[1] + if not url.startswith('https://'): + url = 'https://' + message.text.split(maxsplit=1)[1] + elif message.reply_to_message: + url = message.reply_to_message.text + if not url.startswith('https://'): + url = 'https://' + url + else: + await message.edit_text(f'Usage: {prefix}webshot/{prefix}ws [url/reply to url]') + return + + chat_id = message.chat.id + await message.edit('Generating screenshot...') + + try: + screenshot_data = generate_screenshot(url) + if screenshot_data: + await message.delete() + await client.send_photo(chat_id, screenshot_data, caption=f'Screenshot of {url}') + else: + await message.edit_text('Failed to generate screenshot...\nMake sure url is correct') + except Exception as e: + await message.edit_text(f'An error occurred: {format_exc(e)}') + + +modules_help['url'] = { + 'short [url]*': 'short url', + 'urldl [url]*': 'download url content', + 'upload [file|reply]*': 'upload file to internet', + 'webshot [link]*': 'Screenshot of web page', + 'ws [reply to link]*': 'Screenshot of web page', +} diff --git a/modules/user_info.py b/modules/user_info.py new file mode 100644 index 0000000..0f458a1 --- /dev/null +++ b/modules/user_info.py @@ -0,0 +1,106 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +from pyrogram import Client, filters +from pyrogram.raw import functions +from pyrogram.types import Message +from utils.misc import modules_help, prefix +from utils.scripts import format_exc, interact_with, interact_with_to_delete + + +@Client.on_message(filters.command('inf', prefix) & filters.me) +async def get_user_inf(client: Client, message: Message): + if len(message.command) >= 2: + peer = await client.resolve_peer(message.command[1]) + elif message.reply_to_message and message.reply_to_message.from_user: + peer = await client.resolve_peer(message.reply_to_message.from_user.id) + else: + peer = await client.resolve_peer('me') + + response = await client.invoke(functions.users.GetFullUser(id=peer)) + + user = response.users[0] + full_user = response.full_user + + username = 'None' if user.username is None else f'@{user.username}' + about = 'None' if full_user.about is None else full_user.about + + user_info = f"""|=Username: {username} +|-Id: {user.id} +|-Bot: {user.bot} +|-Scam: {user.scam} +|-Name: {user.first_name} +|-Deleted: {user.deleted} +|-BIO: {about} +""" + await message.edit(user_info) + + +@Client.on_message(filters.command('inffull', prefix) & filters.me) +async def get_full_user_inf(client: Client, message: Message): + await message.edit('Receiving the information...') + + try: + if len(message.command) >= 2: + peer = await client.resolve_peer(message.command[1]) + elif message.reply_to_message and message.reply_to_message.from_user: + peer = await client.resolve_peer(message.reply_to_message.from_user.id) + else: + peer = await client.resolve_peer('me') + + response = await client.invoke(functions.users.GetFullUser(id=peer)) + + user = response.users[0] + full_user = response.full_user + + await client.unblock_user('@creationdatebot') + try: + response = await interact_with(await client.send_message('creationdatebot', f'/id {user.id}')) + except RuntimeError: + creation_date = 'None' + else: + creation_date = response.text + # await client.delete_messages("@creationdatebot", interact_with_to_delete) + interact_with_to_delete.clear() + + username = 'None' if user.username is None else f'@{user.username}' + about = 'None' if full_user.about is None else full_user.about + user_info = f"""|=Username: {username} +|-Id: {user.id} +|-Account creation date: {creation_date} +|-Bot: {user.bot} +|-Scam: {user.scam} +|-Name: {user.first_name} +|-Deleted: {user.deleted} +|-BIO: {about} +|-Contact: {user.contact} +|-Can pin message: {full_user.can_pin_message} +|-Mutual contact: {user.mutual_contact} +|-Access hash: {user.access_hash} +|-Restricted: {user.restricted} +|-Verified: {user.verified} +|-Phone calls available: {full_user.phone_calls_available} +|-Phone calls private: {full_user.phone_calls_private} +|-Blocked: {full_user.blocked}""" + await message.edit(user_info) + except Exception as e: + await message.edit(format_exc(e)) + + +modules_help['user_info'] = { + 'inf [reply|id|username]': 'Get brief information about user', + 'inffull [reply|id|username': 'Get full information about user', +} diff --git a/modules/vt.py b/modules/vt.py new file mode 100644 index 0000000..efc70be --- /dev/null +++ b/modules/vt.py @@ -0,0 +1,135 @@ +# Copyright (C) 2020-2021 by DevsExpo@Github, < https://github.com/DevsExpo >. +# +# This file is part of < https://github.com/DevsExpo/FridayUserBot > project, +# and is released under the "GNU v3.0 License Agreement". +# Please see < https://github.com/DevsExpo/blob/master/LICENSE > +# +# All rights reserved. + +import os +import time + +import requests +from pyrogram import Client, enums, filters +from pyrogram.types import Message +from utils.config import vt_key as vak +from utils.misc import modules_help, prefix +from utils.scripts import edit_or_reply, format_exc, progress + + +@Client.on_message(filters.command('vt', prefix) & filters.me) +async def scan_my_file(_, message: Message): + ms_ = await edit_or_reply(message, '`Please Wait! Scanning This File`') + if not message.reply_to_message: + return await ms_.edit( + '`Please Reply To File To Scan For Viruses`', + parse_mode=enums.ParseMode.MARKDOWN, + ) + if not message.reply_to_message.document: + return await ms_.edit( + '`Please Reply To File To Scan For Viruses`', + parse_mode=enums.ParseMode.MARKDOWN, + ) + if vak is None: + return await ms_.edit( + '`You Need To Set VIRUSTOTAL_API_KEY For Functing Of This Plugin.`', + parse_mode=enums.ParseMode.MARKDOWN, + ) + if int(message.reply_to_message.document.file_size) > 32000000: + return await ms_.edit( + f'**File Too Large, Use `{prefix}vtl` instead**', + parse_mode=enums.ParseMode.MARKDOWN, + ) + c_time = time.time() + downloaded_file_name = await message.reply_to_message.download( + progress=progress, + progress_args=(ms_, c_time, '`Downloading This File!`'), + ) + + url = 'https://www.virustotal.com/vtapi/v2/file/scan' + params = {'apikey': vak} + files = {'file': (downloaded_file_name, open(downloaded_file_name, 'rb'))} + response = requests.post(url, files=files, params=params, timeout=10) + try: + r_json = response.json() + md5 = r_json['md5'] + except Exception as e: + return await ms_.edit(format_exc(e)) + await ms_.edit( + f'Scanned {message.reply_to_message.document.file_name}. You Can Visit : Here In 5-10 Min To See File Report' + ) + if os.path.exists(downloaded_file_name): + os.remove(downloaded_file_name) + + +@Client.on_message(filters.command('vtl', prefix) & filters.me) +async def scan_my_large_file(_, message: Message): + ms_ = await edit_or_reply(message, '`Please Wait! Scanning This File`') + if not message.reply_to_message: + return await ms_.edit( + '`Please Reply To File To Scan For Viruses`', + parse_mode=enums.ParseMode.MARKDOWN, + ) + if not message.reply_to_message.document: + return await ms_.edit( + '`Please Reply To File To Scan For Viruses`', + parse_mode=enums.ParseMode.MARKDOWN, + ) + if vak is None: + return await ms_.edit( + '`You Need To Set VIRUSTOTAL_API_KEY For Functing Of This Plugin.`', + parse_mode=enums.ParseMode.MARKDOWN, + ) + if int(message.reply_to_message.document.file_size) > 650000000: + return await ms_.edit( + '**File Too Large, exceeded Max capacity of 650MB**', + parse_mode=enums.ParseMode.MARKDOWN, + ) + c_time = time.time() + downloaded_file_name = await message.reply_to_message.download( + progress=progress, + progress_args=(ms_, c_time, '`Downloading This File!`'), + ) + + url1 = 'https://www.virustotal.com/api/v3/files/upload_url' + + headers = {'accept': 'application/json', 'x-apikey': vak} + + rponse = requests.get(url1, headers=headers, timeout=10) + try: + r_json = rponse.json() + upl_data = r_json['data'] + except Exception as e: + return await ms_.edit(format_exc(e)) + + url = upl_data + + files = {'file': (downloaded_file_name, open(downloaded_file_name, 'rb'))} + headers = {'accept': 'application/json', 'x-apikey': vak} + response = requests.post(url, files=files, headers=headers, timeout=10) + + r_json = response.json() + analysis_url = r_json['data']['links']['self'] + + url = analysis_url + + headers = {'accept': 'application/json', 'x-apikey': vak} + + response_result = requests.get(url, headers=headers, timeout=10) + + try: + r_json = response_result.json() + md5 = r_json['meta']['file_info']['md5'] + except Exception as e: + return await ms_.edit(format_exc(e)) + await ms_.edit( + f'Scanned {message.reply_to_message.document.file_name}. You Can Visit : Here In 5-10 Min To See File Report' + ) + if os.path.exists(downloaded_file_name): + os.remove(downloaded_file_name) + + +modules_help['virustotal'] = { + 'vt [reply to file]*': 'Scan for viruses on Virus Total (for lower file size <32MB)', + 'vtl [reply to file]*': 'Scan for viruses on Virus Total (for lower file size >=32MB)', +} diff --git a/modules_list.txt b/modules_list.txt new file mode 100644 index 0000000..9a37dd2 --- /dev/null +++ b/modules_list.txt @@ -0,0 +1,115 @@ +# Userbot Modules +# Generated: Fri Jun 19 03:34:52 PM CEST 2026 + +## Core Modules (utils/) +- utils/config.py +- utils/conv.py +- utils/db.py +- utils/handlers.py +- utils/__init__.py +- utils/misc.py +- utils/module.py +- utils/rentry.py +- utils/scripts.py + +## Image Modules (images/) +- images/icons.py +- images/imgur.py +- images/ncode.py +- images/pinterest.py +- images/risearch.py +- images/unsplash2.py +- images/unsplash.py + +## Bot Modules (modules/) +- modules/1000-7.py +- modules/admintool.py +- modules/admlist.py +- modules/afk.py +- modules/aimage.py +- modules/amogus.py +- modules/amongus.py +- modules/animations.py +- modules/anime/anilist.py +- modules/anime/anime.py +- modules/anime/neko.py +- modules/aniquotes.py +- modules/antipm.py +- modules/blackbox.py +- modules/calculator.py +- modules/cdxl.py +- modules/chatbot.py +- modules/circle.py +- modules/clear_notifs.py +- modules/cohere.py +- modules/demotivator.py +- modules/destroy.py +- modules/dice.py +- modules/direct.py +- modules/duckduckgo.py +- modules/durov.py +- modules/example.py +- modules/fakeactions.py +- modules/filters.py +- modules/fliptext.py +- modules/flux.py +- modules/f.py +- modules/gemini.py +- modules/google.py +- modules/hearts.py +- modules/help.py +- modules/huggingface.py +- modules/id.py +- modules/joindate.py +- modules/kokodrilo_explodando.py +- modules/leave_chat.py +- modules/loader.py +- modules/markitdown.py +- modules/mention.py +- modules/mirror_flip.py +- modules/misc/autobackup.py +- modules/misc/autofwd.py +- modules/misc/backup.py +- modules/misc/cama.py +- modules/misc/mlog.py +- modules/misc/prayer.py +- modules/misc/safone.py +- modules/misc/sarethai.py +- modules/misc/search.py +- modules/misc/summary.py +- modules/misc/switch.py +- modules/misc/transcribeyt.py +- modules/notes.py +- modules/open.py +- modules/pdf2md.py +- modules/perfectrussian.py +- modules/ping.py +- modules/prefix.py +- modules/purge.py +- modules/python.py +- modules/reactionspam.py +- modules/removebg.py +- modules/say.py +- modules/sendmod.py +- modules/sessionkiller.py +- modules/sgb.py +- modules/shell.py +- modules/socialstalk.py +- modules/spam.py +- modules/spin.py +- modules/squotes.py +- modules/stickers.py +- modules/support.py +- modules/thumbnail.py +- modules/type.py +- modules/updater.py +- modules/upl.py +- modules/url.py +- modules/user_info.py +- modules/vt.py + +## Custom Modules (modules/custom_modules/) +- (none) + +--- +Total: 101 modules (85 bot + 0 custom + 7 image + 9 core) diff --git a/n8n/compose.yaml b/n8n/compose.yaml deleted file mode 100644 index f6982ac..0000000 --- a/n8n/compose.yaml +++ /dev/null @@ -1,57 +0,0 @@ -services: - n8n: - image: docker.n8n.io/n8nio/n8n - container_name: n8n - restart: unless-stopped - environment: - - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true - - N8N_PORT=5678 - - N8N_RUNNERS_ENABLED=true - - NODE_ENV=production - - GENERIC_TIMEZONE=Europe/Bratislava - - TZ=Europe/Bratislava - - N8N_SECURE_COOKIE=false - - N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE=true - volumes: - - node-data:/home/node/.n8n - - files:/files - extra_hosts: - - "enterprise.n8n.io:104.26.13.187" - - "enterprise.n8n.io:104.26.12.187" - - "enterprise.n8n.io:172.67.68.102" - dns: - - 1.1.1.1 - - 8.8.8.8 - networks: - - proxy - - n8n - labels: - - "traefik.enable=true" - - "traefik.http.services.n8n.loadbalancer.server.port=5678" - - # Prod Router - - "traefik.http.routers.n8n.rule=Host(`n8n.forust.xyz`)" - - "traefik.http.routers.n8n.entrypoints=websecure" - - "traefik.http.routers.n8n.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.n8n-local.rule=Host(`n8n.workstation.internal`)" - - "traefik.http.routers.n8n-local.entrypoints=websecure" - - "traefik.http.routers.n8n-local.tls=true" - # Dev Router - - "traefik.http.routers.n8n-dev.rule=Host(`n8n.gigaforust.internal`)" - - "traefik.http.routers.n8n-dev.entrypoints=websecure" - - "traefik.http.routers.n8n-dev.tls=true" - - - glance.name=n8n - - glance.icon=si:n8n - - glance.url=https://n8n.forust.xyz/ - - glance.description=n8n is a workflow automation tool that enables you to connect various apps and services to automate tasks and processes. -networks: - n8n: - external: false - proxy: - external: true - -volumes: - node-data: - files: diff --git a/n8n/k8s/config.yaml b/n8n/k8s/config.yaml deleted file mode 100644 index 25f15b6..0000000 --- a/n8n/k8s/config.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: n8n-config - namespace: n8n -data: - N8N_PORT: "5678" - N8N_RUNNERS_ENABLED: "true" - NODE_ENV: production - GENERIC_TIMEZONE: Europe/Bratislava - TZ: Europe/Bratislava - N8N_SECURE_COOKIE: "false" - N8N_COMMUNITY_PACKAGES_ALLOW_TOOL_USAGE: "true" - N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true" diff --git a/n8n/k8s/ingress.yaml b/n8n/k8s/ingress.yaml deleted file mode 100644 index 7c4d7b6..0000000 --- a/n8n/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: n8n-prod - namespace: n8n -spec: - entryPoints: - - websecure - routes: - - match: Host(`n8n.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: n8n-service - port: 5678 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: n8n-local - namespace: n8n -spec: - entryPoints: - - websecure - routes: - - match: Host(`n8n.workstation.internal`) || Host(`n8n.gigaforust.internal`) - kind: Rule - services: - - name: n8n-service - port: 5678 diff --git a/n8n/k8s/n8n.yaml b/n8n/k8s/n8n.yaml deleted file mode 100644 index f60ff47..0000000 --- a/n8n/k8s/n8n.yaml +++ /dev/null @@ -1,78 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: n8n-service - namespace: n8n -spec: - selector: - app: n8n - ports: - - port: 5678 - targetPort: 5678 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: n8n-deployment - namespace: n8n -spec: - replicas: 1 - selector: - matchLabels: - app: n8n - template: - metadata: - labels: - app: n8n - spec: - containers: - - name: n8n - image: docker.n8n.io/n8nio/n8n - envFrom: - - configMapRef: - name: n8n-config - ports: - - containerPort: 5678 - volumeMounts: - - name: n8n-node-data - mountPath: /home/node/.n8n - - name: n8n-files - mountPath: /files - resources: - requests: - memory: "512Mi" - cpu: "150m" - limits: - memory: "5Gi" - cpu: "1500m" - volumes: - - name: n8n-node-data - persistentVolumeClaim: - claimName: n8n-node-pvc - - name: n8n-files - persistentVolumeClaim: - claimName: n8n-files-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: n8n-node-pvc - namespace: n8n -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 2Gi ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: n8n-files-pvc - namespace: n8n -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 10Gi diff --git a/n8n/k8s/namespace.yaml b/n8n/k8s/namespace.yaml deleted file mode 100644 index 8389695..0000000 --- a/n8n/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: n8n diff --git a/netronome/.env.example b/netronome/.env.example deleted file mode 100644 index 671bd52..0000000 --- a/netronome/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -## https://github.com/autobrr/netronome?tab=readme-ov-file#environment-variables - -# Server settings -NETRONOME__HOST=0.0.0.0 # Listen address -NETRONOME__PORT=7575 # Web UI port -NETRONOME__BASE_URL=/netronome # Base URL for reverse proxy - -# Database (SQLite by default) -NETRONOME__DB_TYPE=postgres # sqlite or postgres -NETRONOME__DB_PATH=netronome.db # SQLite database path - -# PostgreSQL (when DB_TYPE=postgres) -NETRONOME__DB_TYPE=postgres -NETRONOME__DB_HOST=postgres -NETRONOME__DB_PORT=5432 -NETRONOME__DB_USER=netronome -NETRONOME__DB_PASSWORD=netronome -NETRONOME__DB_NAME=netronome -NETRONOME__DB_SSLMODE=disable - -# Authentication -NETRONOME__AUTH_WHITELIST=127.0.0.1/32,192.168.1.0/24 # IP whitelist (comma-separated) -NETRONOME__SESSION_SECRET= # Session secret (auto-generated if empty) - -# OIDC (optional) -NETRONOME__OIDC_ISSUER=https://accounts.google.com -NETRONOME__OIDC_CLIENT_ID=your-client-id -NETRONOME__OIDC_CLIENT_SECRET=your-secret -NETRONOME__OIDC_REDIRECT_URL=https://example.com/api/auth/oidc/callback \ No newline at end of file diff --git a/netronome/compose.yaml b/netronome/compose.yaml deleted file mode 100644 index 4a23502..0000000 --- a/netronome/compose.yaml +++ /dev/null @@ -1,58 +0,0 @@ -services: - netronome: - image: ghcr.io/autobrr/netronome:latest - restart: unless-stopped - container_name: netronome - ports: - - "7575:7575" - cap_add: - - NET_RAW - env_file: - - .env - labels: - - "traefik.enable=true" - - "traefik.http.services.netronome.loadbalancer.server.port=7575" - - # Prod Router - - "traefik.http.routers.netronome.rule=Host(`nm.forust.xyz`)" - - "traefik.http.routers.netronome.entrypoints=websecure" - - "traefik.http.routers.netronome.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.netronome-local.rule=Host(`nm.workstation.internal`)" - - "traefik.http.routers.netronome-local.entrypoints=websecure" - - "traefik.http.routers.netronome-local.tls=true" - # Dev Router - - "traefik.http.routers.netronome-dev.rule=Host(`nm.gigaforust.internal`)" - - "traefik.http.routers.netronome-dev.entrypoints=websecure" - - "traefik.http.routers.netronome-dev.tls=true" - networks: - - proxy - - netronome - depends_on: - postgres: - condition: service_healthy - postgres: - container_name: netronome-postgres - image: postgres:17-alpine - environment: - - POSTGRES_USER=netronome - - POSTGRES_PASSWORD=netronome - - POSTGRES_DB=netronome - volumes: - - data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U netronome"] - interval: 5s - timeout: 5s - retries: 5 - restart: unless-stopped - networks: - - netronome -volumes: - data: - -networks: - proxy: - external: true - netronome: - external: false diff --git a/netronome/k8s/config.yaml b/netronome/k8s/config.yaml deleted file mode 100644 index fa4f125..0000000 --- a/netronome/k8s/config.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: netronome-config - namespace: netronome -data: - NETRONOME__DB_TYPE: "postgres" - NETRONOME__DB_HOST: "netronome-postgres-service" - NETRONOME__DB_PORT: "5432" - NETRONOME__DB_NAME: "netronome" - NETRONOME__DB_SSLMODE: "disable" - NETRONOME__HOST: "0.0.0.0" - NETRONOME__PORT: "7575" - NETRONOME__BASE_URL: "/" diff --git a/netronome/k8s/ingress.yaml b/netronome/k8s/ingress.yaml deleted file mode 100644 index 970490a..0000000 --- a/netronome/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: netronome-prod - namespace: netronome -spec: - entryPoints: - - websecure - routes: - - match: Host(`nm.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: netronome-service - port: 7575 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: netronome-local - namespace: netronome -spec: - entryPoints: - - websecure - routes: - - match: Host(`nm.workstation.internal`) || Host(`nm.gigaforust.internal`) - kind: Rule - services: - - name: netronome-service - port: 7575 diff --git a/netronome/k8s/namespace.yaml b/netronome/k8s/namespace.yaml deleted file mode 100644 index 35ac1ec..0000000 --- a/netronome/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: netronome diff --git a/netronome/k8s/netronome.yaml b/netronome/k8s/netronome.yaml deleted file mode 100644 index dc8495d..0000000 --- a/netronome/k8s/netronome.yaml +++ /dev/null @@ -1,58 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: netronome-service - namespace: netronome -spec: - selector: - app: netronome - ports: - - port: 7575 - protocol: TCP - targetPort: 7575 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: netronome-deployment - namespace: netronome - labels: - app: netronome -spec: - replicas: 1 - selector: - matchLabels: - app: netronome - template: - metadata: - labels: - app: netronome - spec: - containers: - - name: netronome - image: ghcr.io/autobrr/netronome:latest - ports: - - name: netronome-port - protocol: TCP - containerPort: 7575 - envFrom: - - configMapRef: - name: netronome-config - env: - - name: NETRONOME__DB_USER - valueFrom: - secretKeyRef: - name: netronome-secrets - key: NETRONOME__DB_USER - - name: NETRONOME__DB_PASSWORD - valueFrom: - secretKeyRef: - name: netronome-secrets - key: NETRONOME__DB_PASSWORD - resources: - requests: - memory: "100Mi" - cpu: "100m" - limits: - memory: "512Mi" - cpu: "500m" diff --git a/netronome/k8s/postgres.yaml b/netronome/k8s/postgres.yaml deleted file mode 100644 index 9ce09f7..0000000 --- a/netronome/k8s/postgres.yaml +++ /dev/null @@ -1,71 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: netronome-postgres-service - namespace: netronome -spec: - selector: - app: netronome-postgres - ports: - - protocol: TCP - port: 5432 - targetPort: 5432 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: netronome-postgres - namespace: netronome -spec: - serviceName: "netronome-postgres-service" - replicas: 1 - selector: - matchLabels: - app: netronome-postgres - template: - metadata: - labels: - app: netronome-postgres - spec: - containers: - - name: netronome-postgres - image: postgres:17-alpine - ports: - - name: postgres-port - protocol: TCP - containerPort: 5432 - env: - - name: POSTGRES_USER - valueFrom: - secretKeyRef: - name: netronome-secrets - key: NETRONOME__DB_USER - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: netronome-secrets - key: NETRONOME__DB_PASSWORD - - name: POSTGRES_DB - valueFrom: - configMapKeyRef: - name: netronome-config - key: NETRONOME__DB_NAME - resources: - requests: - memory: "512Mi" - cpu: "500m" - limits: - memory: "1Gi" - cpu: "1000m" - volumeMounts: - - name: netronome-pg-data - mountPath: /var/lib/postgresql/data - volumeClaimTemplates: - - metadata: - name: netronome-pg-data - spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi diff --git a/nextcloud/compose.yaml b/nextcloud/compose.yaml deleted file mode 100644 index 323c641..0000000 --- a/nextcloud/compose.yaml +++ /dev/null @@ -1,75 +0,0 @@ -services: - nextcloud-aio-mastercontainer: - image: ghcr.io/nextcloud-releases/all-in-one:beta - init: true # This setting makes sure that signals from main process inside the container are correctly forwarded to children. See https://docs.docker.com/reference/compose-file/services/#init - restart: unless-stopped - container_name: nextcloud-aio-mastercontainer # Do not change - volumes: - - nextcloud_aio_mastercontainer:/mnt/docker-aio-config # Do not change (backup) - - /var/run/docker.sock:/var/run/docker.sock - - /dev/dri:/dev/dri - ports: - - 8888:8080 - networks: - - nextcloud-aio - - proxy # Optional: Connects the mastercontainer to the proxy network in order to make the built-in reverse proxy detection work. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - - labels: - - "traefik.enable=true" - # AIO Services configuration - - "traefik.http.services.nextcloud-aio.loadbalancer.server.port=8080" - - "traefik.http.services.nextcloud-aio.loadbalancer.server.scheme=https" - - "traefik.http.services.nextcloud-aio.loadbalancer.serverstransport=insecureTransport@file" - - # Prod Router - # - "traefik.http.routers.nextcloud-aio.rule=Host(`naio.forust.xyz`)" - # - "traefik.http.routers.nextcloud-aio.entrypoints=websecure" - # - "traefik.http.routers.nextcloud-aio.middlewares=security-chain@file" - # - "traefik.http.routers.nextcloud-aio.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.nextcloud-aio-local.rule=Host(`naio.workstation.internal`)" - - "traefik.http.routers.nextcloud-aio-local.entrypoints=websecure" - - "traefik.http.routers.nextcloud-aio-local.tls=true" - # Dev Router - - "traefik.http.routers.nextcloud-aio-dev.rule=Host(`naio.gigaforust.internal`)" - - "traefik.http.routers.nextcloud-aio-dev.entrypoints=websecure" - - "traefik.http.routers.nextcloud-aio-dev.tls=true" - - # Glance Metadata - - glance.name=Nextcloud - - glance.url=https://nextcloud.forust.xyz/ - - glance.description=Nextcloud is a suite of client-server software for creating and using file hosting services. - environment: - AIO_DISABLE_BACKUP_SECTION: false - APACHE_PORT: 11000 # Is needed when running behind a web server or reverse proxy (like Apache, Nginx, Caddy, Cloudflare Tunnel and else). See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - APACHE_IP_BINDING: 0.0.0.0 # Configure when going with reverse-proxy https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - APACHE_ADDITIONAL_NETWORK: proxy # (Optional) Connect the apache container to an additional docker network. When going with reverse-proxy. See https://github.com/nextcloud/all-in-one/blob/main/reverse-proxy.md - BORG_RETENTION_POLICY: --keep-within=7d --keep-weekly=4 --keep-monthly=6 # Backup retention See https://github.com/nextcloud/all-in-one#how-to-adjust-borgs-retention-policy - COLLABORA_SECCOMP_DISABLED: false # Setting this to true allows to disable Collabora's Seccomp feature. See https://github.com/nextcloud/all-in-one#how-to-disable-collaboras-seccomp-feature - FULLTEXTSEARCH_JAVA_OPTIONS: "-Xms1024M -Xmx1024M" # adjust fulltextsearch java options. https://github.com/nextcloud/all-in-one#how-to-adjust-the-fulltextsearch-java-options - NEXTCLOUD_DATADIR: /mnt/nextcloud/ncdata # Allows to set the host directory for Nextcloud's datadir. ⚠️⚠️⚠️ Warning: do not set or adjust this value after the initial Nextcloud installation is done! See https://github.com/nextcloud/all-in-one#how-to-change-the-default-location-of-nextclouds-datadir - NEXTCLOUD_MOUNT: /mnt/ # Allows the Nextcloud container to access the chosen directory on the host. See https://github.com/nextcloud/all-in-one#how-to-allow-the-nextcloud-container-to-access-directories-on-the-host - NEXTCLOUD_UPLOAD_LIMIT: 16G # https://github.com/nextcloud/all-in-one#how-to-adjust-the-upload-limit-for-nextcloud - NEXTCLOUD_MAX_TIME: 7200 # Max uploading time See https://github.com/nextcloud/all-in-one#how-to-adjust-the-max-execution-time-for-nextcloud - NEXTCLOUD_MEMORY_LIMIT: 512M # Can be adjusted if you need more. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-php-memory-limit-for-nextcloud - # NEXTCLOUD_TRUSTED_CACERTS_DIR: /path/to/my/cacerts # CA certificates will be trusted by the OS of the nextcloud container See https://github.com/nextcloud/all-in-one#how-to-trust-user-defined-certification-authorities-ca - NEXTCLOUD_STARTUP_APPS: deck twofactor_totp tasks calendar contacts notes # Allows to modify the Nextcloud apps that are installed on starting AIO the first time. See https://github.com/nextcloud/all-in-one#how-to-change-the-nextcloud-apps-that-are-installed-on-the-first-startup - NEXTCLOUD_ADDITIONAL_APKS: imagemagick # This allows to add additional packages to the Nextcloud container permanently. Default is imagemagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-os-packages-permanently-to-the-nextcloud-container - NEXTCLOUD_ADDITIONAL_PHP_EXTENSIONS: imagick # dditional php extensions to the Nextcloud container permanently. Default is imagick but can be overwritten by modifying this value. See https://github.com/nextcloud/all-in-one#how-to-add-php-extensions-permanently-to-the-nextcloud-container - # NEXTCLOUD_ENABLE_DRI_DEVICE: true # This allows to enable the /dev/dri device for containers that profit from it. ⚠️⚠️⚠️ Warning: this only works if the '/dev/dri' device is present on the host! If it should not exist on your host, don't set this to true as otherwise the Nextcloud container will fail to start! See https://github.com/nextcloud/all-in-one#how-to-enable-hardware-acceleration-for-nextcloud - # NEXTCLOUD_KEEP_DISABLED_APPS: false # Setting this to true will keep Nextcloud apps that are disabled in the AIO interface and not uninstall them if they should be installed. See https://github.com/nextcloud/all-in-one#how-to-keep-disabled-apps - SKIP_DOMAIN_VALIDATION: true # This should only be set to true if things are correctly configured. See https://github.com/nextcloud/all-in-one?tab=readme-ov-file#how-to-skip-the-domain-validation - # TALK_PORT: 3478 # This a-llows to adjust the port that the talk container is using which is exposed on the host. See https://github.com/nextcloud/all-in-one#how-to-adjust-the-talk-port - # WATCHTOWER_DOCKER_SOCKET_PATH: /var/run/docker.sock # Needs to be specified if the docker socket on the host is not located in the default '/var/run/docker.sock'. For macos it needs to be '/var/run/docker.sock' - -networks: - proxy: - external: true - nextcloud-aio: - name: nextcloud-aio - driver: bridge - external: false -volumes: - # If you want to store the data on a different drive, see https://github.com/nextcloud/all-in-one#how-to-store-the-filesinstallation-on-a-separate-drive - nextcloud_aio_mastercontainer: - name: nextcloud_aio_mastercontainer # Do not change diff --git a/nextcloud/k8s/external-service.yaml b/nextcloud/k8s/external-service.yaml deleted file mode 100644 index 3fa7497..0000000 --- a/nextcloud/k8s/external-service.yaml +++ /dev/null @@ -1,53 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: nextcloud-apache - namespace: nextcloud -spec: - ports: - - port: 11000 - targetPort: 11000 ---- -apiVersion: discovery.k8s.io/v1 -kind: EndpointSlice -metadata: - name: nextcloud-apache - namespace: nextcloud - labels: - kubernetes.io/service-name: nextcloud-apache -addressType: IPv4 -ports: - - port: 11000 - protocol: TCP -endpoints: - - addresses: - - "192.168.88.100" - conditions: - ready: true ---- -apiVersion: v1 -kind: Service -metadata: - name: nextcloud-aio - namespace: nextcloud -spec: - ports: - - port: 8888 - targetPort: 8888 ---- -apiVersion: discovery.k8s.io/v1 -kind: EndpointSlice -metadata: - name: nextcloud-aio - namespace: nextcloud - labels: - kubernetes.io/service-name: nextcloud-aio -addressType: IPv4 -ports: - - port: 8888 - protocol: TCP -endpoints: - - addresses: - - "192.168.88.100" - conditions: - ready: true diff --git a/nextcloud/k8s/ingress.yaml b/nextcloud/k8s/ingress.yaml deleted file mode 100644 index 62f140c..0000000 --- a/nextcloud/k8s/ingress.yaml +++ /dev/null @@ -1,75 +0,0 @@ -# Nextcloud -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: nextcloud-prod - namespace: nextcloud -spec: - entryPoints: - - websecure - routes: - - match: Host(`nextcloud.forust.xyz`) - kind: Rule - middlewares: - - name: nextcloud-chain@file - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: nextcloud-apache - port: 11000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: nextcloud-local - namespace: nextcloud -spec: - entryPoints: - - websecure - routes: - - match: Host(`nextcloud.workstation.internal`) || Host(`nextcloud.gigaforust.internal`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - - name: nextcloud-chain@file - services: - - name: nextcloud-apache - port: 11000 -# --- -# Nextcloud AIO -# apiVersion: traefik.io/v1alpha1 -# kind: IngressRoute -# metadata: -# name: naio-prod -# namespace: nextcloud -# spec: -# entryPoints: -# - websecure -# routes: -# - match: Host(`naio.forust.xyz`) -# kind: Rule -# services: -# - name: nextcloud-aio -# port: 8888 -# scheme: https -# serversTransport: insecure-transport -# tls: -# certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: naio-local - namespace: nextcloud -spec: - entryPoints: - - websecure - routes: - - match: Host(`naio.workstation.internal`) || Host(`naio.gigaforust.internal`) - kind: Rule - services: - - name: nextcloud-aio - port: 8888 - scheme: https - serversTransport: insecure-transport diff --git a/nextcloud/k8s/namespace.yaml b/nextcloud/k8s/namespace.yaml deleted file mode 100644 index e8c2e8f..0000000 --- a/nextcloud/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: nextcloud diff --git a/nextcloud/k8s/servers-transport.yaml b/nextcloud/k8s/servers-transport.yaml deleted file mode 100644 index cbc4029..0000000 --- a/nextcloud/k8s/servers-transport.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: ServersTransport -metadata: - name: insecure-transport - namespace: nextcloud -spec: - insecureSkipVerify: true diff --git a/penpot/.env.example b/penpot/.env.example deleted file mode 100644 index 040fc0a..0000000 --- a/penpot/.env.example +++ /dev/null @@ -1 +0,0 @@ -PENPOT_SECRET_KEY=somesecretfdsfsdfds \ No newline at end of file diff --git a/penpot/compose.yaml b/penpot/compose.yaml deleted file mode 100644 index 5eff9ee..0000000 --- a/penpot/compose.yaml +++ /dev/null @@ -1,284 +0,0 @@ -## Common flags: -# demo-users -# email-verification -# log-emails -# log-invitation-tokens -# login-with-github -# login-with-gitlab -# login-with-google -# login-with-ldap -# login-with-oidc -# login-with-password -# prepl-server -# registration -# secure-session-cookies -# smtp -# smtp-debug -# telemetry -# webhooks -## -## You can read more about all available flags and other -## environment variables here: -## https://help.penpot.app/technical-guide/configuration/#penpot-configuration -# -# WARNING: if you're exposing Penpot to the internet, you should remove the flags -# 'disable-secure-session-cookies' and 'disable-email-verification' -x-flags: &penpot-flags - PENPOT_FLAGS: disable-email-verification enable-prepl-server disable-secure-session-cookies login-with-github - -x-uri: &penpot-public-uri - PENPOT_PUBLIC_URI: http://localhost:9001 - -x-body-size: - # Max body size (30MiB); Used for plain requests, should never be - # greater than multi-part size - &penpot-http-body-size - PENPOT_HTTP_SERVER_MAX_BODY_SIZE: 31457280 - - # Max multipart body size (350MiB) - PENPOT_HTTP_SERVER_MAX_MULTIPART_BODY_SIZE: 367001600 - -## Penpot SECRET KEY. It serves as a master key from which other keys for subsystems -## (eg http sessions, or invitations) are derived. -## -## We recommend to use a trully randomly generated -## 512 bits base64 encoded string here. You can generate one with: -## -## python3 -c "import secrets; print(secrets.token_urlsafe(64))" -x-secret-key: &penpot-secret-key - PENPOT_SECRET_KEY: ${PENPOT_SECRET_KEY} - -networks: - penpot: - proxy: - external: true - -volumes: - penpot_postgres_v15: - penpot_assets: - penpot_traefik: - # penpot_minio: - -services: - ## Traefik service declaration example. Consider using it if you are going to expose - ## penpot to the internet, or a different host than `localhost`. - - # traefik: - # image: traefik:v3.3 - # networks: - # - penpot - # command: - # - "--api.insecure=true" - # - "--entryPoints.web.address=:80" - # - "--providers.docker=true" - # - "--providers.docker.exposedbydefault=false" - # - "--entryPoints.websecure.address=:443" - # - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" - # - "--certificatesresolvers.letsencrypt.acme.email=" - # - "--certificatesresolvers.letsencrypt.acme.storage=/traefik/acme.json" - # volumes: - # - "penpot_traefik:/traefik" - # - "/var/run/docker.sock:/var/run/docker.sock" - # ports: - # - "80:80" - # - "443:443" - - penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-latest}" - restart: always - # ports: - # - 9001:8080 - - volumes: - - penpot_assets:/opt/data/assets - - depends_on: - - penpot-backend - - penpot-exporter - - networks: - - penpot - - proxy - - labels: - - "traefik.enable=true" - - "traefik.http.services.penpot.loadbalancer.server.port=8080" - - # Prod Router - - "traefik.http.routers.penpot.rule=Host(`penpot.forust.xyz`)" - - "traefik.http.routers.penpot.entrypoints=websecure" - - "traefik.http.routers.penpot.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.penpot-local.rule=Host(`penpot.workstation.internal`)" - - "traefik.http.routers.penpot-local.entrypoints=websecure" - - "traefik.http.routers.penpot-local.tls=true" - # Dev Router - - "traefik.http.routers.penpot-dev.rule=Host(`penpot.gigaforust.internal`)" - - "traefik.http.routers.penpot-dev.entrypoints=websecure" - - "traefik.http.routers.penpot-dev.tls=true" - environment: - <<: [*penpot-flags, *penpot-http-body-size] - penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-latest}" - restart: always - - volumes: - - penpot_assets:/opt/data/assets - - depends_on: - penpot-postgres: - condition: service_healthy - penpot-valkey: - condition: service_healthy - - networks: - - penpot - - ## Configuration envronment variables for the backend container. - - environment: - <<: [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key] - - ## The PREPL host. Mainly used for external programatic access to penpot backend - ## (example: admin). By default it will listen on `localhost` but if you are going to use - ## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`. - - # PENPOT_PREPL_HOST: 0.0.0.0 - - ## Database connection parameters. Don't touch them unless you are using custom - ## postgresql connection parameters. - - PENPOT_DATABASE_URI: postgresql://penpot-postgres/penpot - PENPOT_DATABASE_USERNAME: penpot - PENPOT_DATABASE_PASSWORD: penpot - - ## Valkey (or previously redis) is used for the websockets notifications. Don't touch - ## unless the valkey container has different parameters or different name. - - PENPOT_REDIS_URI: redis://penpot-valkey/0 - - ## Default configuration for assets storage: using filesystem based with all files - ## stored in a docker volume. - - PENPOT_ASSETS_STORAGE_BACKEND: assets-fs - PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets - - ## Also can be configured to to use a S3 compatible storage - ## service like MiniIO. Look below for minio service setup. - - # AWS_ACCESS_KEY_ID: - # AWS_SECRET_ACCESS_KEY: - # PENPOT_ASSETS_STORAGE_BACKEND: assets-s3 - # PENPOT_STORAGE_ASSETS_S3_ENDPOINT: http://penpot-minio:9000 - # PENPOT_STORAGE_ASSETS_S3_BUCKET: - - ## Telemetry. When enabled, a periodical process will send anonymous data about this - ## instance. Telemetry data will enable us to learn how the application is used, - ## based on real scenarios. If you want to help us, please leave it enabled. You can - ## audit what data we send with the code available on github. - - PENPOT_TELEMETRY_ENABLED: true - PENPOT_TELEMETRY_REFERER: compose - # PENPOT_SMTP_DEFAULT_FROM: no-reply@example.com - # PENPOT_SMTP_DEFAULT_REPLY_TO: no-reply@example.com - # PENPOT_SMTP_HOST: penpot-mailcatch - # PENPOT_SMTP_PORT: 1025 - # PENPOT_SMTP_USERNAME: - # PENPOT_SMTP_PASSWORD: - # PENPOT_SMTP_TLS: false - # PENPOT_SMTP_SSL: false - - penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-latest}" - restart: always - - depends_on: - penpot-valkey: - condition: service_healthy - - networks: - - penpot - - environment: - <<: [*penpot-secret-key] - # Don't touch it; this uses an internal docker network to - # communicate with the frontend. - PENPOT_PUBLIC_URI: http://penpot-frontend:8080 - - ## Valkey (or previously Redis) is used for the websockets notifications. - PENPOT_REDIS_URI: redis://penpot-valkey/0 - - penpot-postgres: - image: "postgres:15" - restart: always - stop_signal: SIGINT - - healthcheck: - test: ["CMD-SHELL", "pg_isready -U penpot"] - interval: 2s - timeout: 10s - retries: 5 - start_period: 2s - - volumes: - - penpot_postgres_v15:/var/lib/postgresql/data - - networks: - - penpot - - environment: - - POSTGRES_INITDB_ARGS=--data-checksums - - POSTGRES_DB=penpot - - POSTGRES_USER=penpot - - POSTGRES_PASSWORD=penpot - - penpot-valkey: - image: valkey/valkey:8.1 - restart: always - - healthcheck: - test: ["CMD-SHELL", "valkey-cli ping | grep PONG"] - interval: 1s - timeout: 3s - retries: 5 - start_period: 3s - - networks: - - penpot - - environment: - # You can increase the max memory size if you have sufficient resources, - # although this should not be necessary. - - VALKEY_EXTRA_FLAGS=--maxmemory 128mb --maxmemory-policy volatile-lfu - ## A mailcatch service, used as temporal SMTP server. You can access via HTTP to the - ## port 1080 for read all emails the penpot platform has sent. Should be only used as a - ## temporal solution while no real SMTP provider is configured. - - # penpot-mailcatch: - # image: sj26/mailcatcher:latest - # restart: always - # expose: - # - '1025' - # ports: - # - "1080:1080" - # networks: - # - penpot - - ## Example configuration of MiniIO (S3 compatible object storage service); If you don't - ## have preference, then just use filesystem, this is here just for the completeness. - - # minio: - # image: "minio/minio:latest" - # command: minio server /mnt/data --console-address ":9001" - # restart: always - # - # volumes: - # - "penpot_minio:/mnt/data" - # - # environment: - # - MINIO_ROOT_USER=minioadmin - # - MINIO_ROOT_PASSWORD=minioadmin - # - # ports: - # - 9000:9000 - # - 9001:9001 diff --git a/portainer/compose.yaml b/portainer/compose.yaml deleted file mode 100644 index cf6c920..0000000 --- a/portainer/compose.yaml +++ /dev/null @@ -1,39 +0,0 @@ -services: - portainer: - image: portainer/portainer-ce:2.41.0 - container_name: portainer - restart: always - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - data:/data - ports: - - 9000:9000 - # - 8000:8000 # Remove if you do not intend to use Edge Agents - labels: - - "traefik.enable=true" - - "traefik.http.services.portainer.loadbalancer.server.port=9000" - - # Prod Router - - "traefik.http.routers.portainer.rule=Host(`portainer.forust.xyz`)" - - "traefik.http.routers.portainer.entrypoints=websecure" - - "traefik.http.routers.portainer.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.portainer-local.rule=Host(`portainer.workstation.internal`)" - - "traefik.http.routers.portainer-local.entrypoints=websecure" - - "traefik.http.routers.portainer-local.tls=true" - # Dev Router - - "traefik.http.routers.portainer-dev.rule=Host(`portainer.gigaforust.internal`)" - - "traefik.http.routers.portainer-dev.entrypoints=websecure" - - "traefik.http.routers.portainer-dev.tls=true" - - # Glance Metadata - - glance.name=Portainer - - glance.url=https://portainer.forust.xyz/ - - glance.description=Portainer is a lightweight management UI which allows you to easily manage your Docker environments. - networks: - - proxy -volumes: - data: -networks: - proxy: - external: true diff --git a/portainer/k8s/ingress.yaml b/portainer/k8s/ingress.yaml deleted file mode 100644 index f1acace..0000000 --- a/portainer/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: portainer-prod - namespace: portainer -spec: - entryPoints: - - websecure - routes: - - match: Host(`portainer.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: portainer-service - port: 9000 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: portainer-local - namespace: portainer -spec: - entryPoints: - - websecure - routes: - - match: Host(`portainer.workstation.internal`) || Host(`portainer.gigaforust.internal`) - kind: Rule - services: - - name: portainer-service - port: 9000 diff --git a/portainer/k8s/namespace.yaml b/portainer/k8s/namespace.yaml deleted file mode 100644 index aec7989..0000000 --- a/portainer/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: portainer diff --git a/portainer/k8s/portainer.yaml b/portainer/k8s/portainer.yaml deleted file mode 100644 index f675fa8..0000000 --- a/portainer/k8s/portainer.yaml +++ /dev/null @@ -1,64 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: portainer-service - namespace: portainer -spec: - selector: - app: portainer - ports: - - port: 9000 - targetPort: 9000 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: portainer-deployment - namespace: portainer -spec: - replicas: 1 - selector: - matchLabels: - app: portainer - template: - metadata: - labels: - app: portainer - spec: - containers: - - name: portainer - image: portainer/portainer-ce:2.41.0 - ports: - - containerPort: 9000 - volumeMounts: - - name: data - mountPath: /data - - name: docker-sock - mountPath: /var/run/docker.sock - resources: - requests: - memory: "512Mi" - cpu: "200m" - limits: - memory: "2Gi" - cpu: "1" - volumes: - - name: data - persistentVolumeClaim: - claimName: portainer-data-pvc - - name: docker-sock - hostPath: - path: /var/run/docker.sock - type: Socket ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: portainer-data-pvc - namespace: portainer -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 2Gi diff --git a/prometheus-stack/.env.example b/prometheus-stack/.env.example deleted file mode 100644 index de563c9..0000000 --- a/prometheus-stack/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -GRAFANA_ADMIN_USER=admin -GRAFANA_ADMIN_PASSWORD=changeme diff --git a/prometheus-stack/alertmanager.yaml b/prometheus-stack/alertmanager.yaml deleted file mode 100644 index 04c071b..0000000 --- a/prometheus-stack/alertmanager.yaml +++ /dev/null @@ -1,5 +0,0 @@ -route: - receiver: default - -receivers: - - name: default diff --git a/prometheus-stack/compose.yaml b/prometheus-stack/compose.yaml deleted file mode 100644 index dcd13b2..0000000 --- a/prometheus-stack/compose.yaml +++ /dev/null @@ -1,66 +0,0 @@ -services: - grafana: - image: grafana/grafana:11.6.0 - container_name: prometheus-grafana - restart: unless-stopped - env_file: - - .env - environment: - GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin} - GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?grafana admin password required} - volumes: - - grafana-data:/var/lib/grafana - labels: - - "traefik.enable=true" - - "traefik.http.services.prometheus-grafana.loadbalancer.server.port=3000" - - # Prod Router - - "traefik.http.routers.grafana.rule=Host(`grafana.forust.xyz`)" - - "traefik.http.routers.grafana.entrypoints=websecure" - - "traefik.http.routers.grafana.tls=true" - # Local Router - - "traefik.http.routers.grafana-local.rule=Host(`grafana.workstation.internal`)" - - "traefik.http.routers.grafana-local.entrypoints=websecure" - - "traefik.http.routers.grafana-local.tls=true" - # Dev Router - - "traefik.http.routers.grafana-dev.rule=Host(`grafana.gigaforust.internal`)" - - "traefik.http.routers.grafana-dev.entrypoints=websecure" - - "traefik.http.routers.grafana-dev.tls=true" - networks: - - proxy - - prometheus: - image: prom/prometheus:v3.2.1 - container_name: prometheus-prometheus - restart: unless-stopped - command: - - "--config.file=/etc/prometheus/prometheus.yaml" - - "--storage.tsdb.path=/prometheus" - - "--storage.tsdb.retention.time=90d" - volumes: - - prometheus-data:/prometheus - - ./prometheus.yaml:/etc/prometheus/prometheus.yaml:ro - networks: - - proxy - - alertmanager: - image: prom/alertmanager:v0.28.1 - container_name: prometheus-alertmanager - restart: unless-stopped - command: - - "--config.file=/etc/alertmanager/alertmanager.yaml" - - "--storage.path=/alertmanager" - volumes: - - alertmanager-data:/alertmanager - - ./alertmanager.yaml:/etc/alertmanager/alertmanager.yaml:ro - networks: - - proxy - -volumes: - grafana-data: - prometheus-data: - alertmanager-data: - -networks: - proxy: - external: true diff --git a/prometheus-stack/k8s/grafana-values.yaml b/prometheus-stack/k8s/grafana-values.yaml deleted file mode 100644 index 6e7c8e1..0000000 --- a/prometheus-stack/k8s/grafana-values.yaml +++ /dev/null @@ -1,52 +0,0 @@ -grafana: - grafana.ini: - server: - domain: grafana.forust.xyz - root_url: https://grafana.forust.xyz - auth.anonymous: - enabled: false - users: - allow_sign_up: false - admin: - existingSecret: grafana-admin - userKey: admin-user - passwordKey: admin-password - - persistence: - enabled: true - size: 10Gi - - ingress: - enabled: false - - service: - port: 80 - -prometheus: - prometheusSpec: - storageSpec: - volumeClaimTemplate: - spec: - storageClassName: "local-path" - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 40Gi - resources: - requests: - memory: "700Mi" - cpu: 200m - limits: - memory: "2Gi" -alertmanager: - alertmanagerSpec: - storage: - volumeClaimTemplate: - spec: - accessModes: - - ReadWriteOnce - - resources: - requests: - storage: 20Gi diff --git a/prometheus-stack/k8s/ingress.yaml b/prometheus-stack/k8s/ingress.yaml deleted file mode 100644 index bee1f73..0000000 --- a/prometheus-stack/k8s/ingress.yaml +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: grafana-prod - namespace: prometheus -spec: - entryPoints: - - websecure - routes: - - match: Host(`grafana.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - - name: "security-chain@file" - services: - - name: prometheus-stack-grafana - port: 80 - tls: - certResolver: letsencrypt - domains: - - main: grafana.forust.xyz ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: grafana-local - namespace: prometheus -spec: - entryPoints: - - websecure - routes: - - match: Host(`grafana.workstation.internal`) || Host(`grafana.gigaforust.internal`) - kind: Rule - services: - - name: prometheus-stack-grafana - port: 80 diff --git a/prometheus-stack/k8s/namespace.yaml b/prometheus-stack/k8s/namespace.yaml deleted file mode 100644 index ea5d16f..0000000 --- a/prometheus-stack/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: prometheus diff --git a/prometheus-stack/k8s/secrets.yaml.example b/prometheus-stack/k8s/secrets.yaml.example deleted file mode 100644 index 931408f..0000000 --- a/prometheus-stack/k8s/secrets.yaml.example +++ /dev/null @@ -1,9 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: grafana-admin - namespace: prometheus -type: Opaque -stringData: - admin-user: "admin" - admin-password: "" diff --git a/prometheus-stack/prometheus.yaml b/prometheus-stack/prometheus.yaml deleted file mode 100644 index 88ce109..0000000 --- a/prometheus-stack/prometheus.yaml +++ /dev/null @@ -1,8 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -scrape_configs: - - job_name: prometheus - static_configs: - - targets: ["localhost:9090"] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7850b35 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,28 @@ +[project] +name = "userbot" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "aiofiles>=25.1.0", + "aiohttp>=3.13.2", + "beautifulsoup4>=4.14.2", + "click>=8.3.0", + "dnspython>=2.8.0", + "environs>=14.5.0", + "flask>=3.1.2", + "gitpython>=3.1.45", + "gunicorn>=23.0.0", + "humanize>=4.14.0", + "pillow>=10.3.0", + "psutil>=7.1.3", + "pygments>=2.19.2", + "pymongo>=4.15.3", + "pyrofork==2.3.68", + "pysmartdl>=1.3.4", + "qrcode>=8.2", + "requests>=2.32.5", + "tgcrypto>=1.2.5", + "wheel>=0.45.1", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a4c4499 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,22 @@ +pyrofork==2.3.68 +tgcrypto +wheel +gunicorn +flask +humanize +pygments +pymongo +psutil +Pillow>=10.3.0 +click +dnspython +requests +environs +GitPython +beautifulsoup4 +aiohttp +aiofiles +pySmartDL +qrcode +bottle +dulwich \ No newline at end of file diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index 5bef6ef..0000000 --- a/ruff.toml +++ /dev/null @@ -1,11 +0,0 @@ -target-version = "py311" -line-length = 120 - -[lint] -select = ["E", "F", "W", "I", "N", "UP", "S", "B", "A", "C4", "SIM", "ARG"] -ignore = ["E501", "S101"] - -[format] -quote-style = "single" -indent-style = "space" -line-ending = "lf" diff --git a/searxng/.env.example b/searxng/.env.example deleted file mode 100644 index e8c7808..0000000 --- a/searxng/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# Read the documentation before using the `docker-compose.yml` file: -# https://docs.searxng.org/admin/installation-docker.html -# -# Additional ENVs: -# https://docs.searxng.org/admin/settings/settings_general.html#settings-general -# https://docs.searxng.org/admin/settings/settings_server.html#settings-server - -# Use a specific version tag. E.g. "latest" or "2026.3.25-541c6c3cb". -#SEARXNG_VERSION=latest - -# Listen to a specific address. -#SEARXNG_HOST=[::] - -# Listen to a specific port. -SEARXNG_PORT=8080 diff --git a/searxng/compose.yaml b/searxng/compose.yaml deleted file mode 100644 index 3408d05..0000000 --- a/searxng/compose.yaml +++ /dev/null @@ -1,45 +0,0 @@ -# Read the documentation before using the `docker-compose.yml` file: -# https://docs.searxng.org/admin/installation-docker.html -services: - core: - container_name: searxng-core - image: docker.io/searxng/searxng:${SEARXNG_VERSION:-latest} - restart: unless-stopped - # ports: - # - ${SEARXNG_PORT:-8080} - env_file: .env - labels: - - "traefik.enable=true" - - "traefik.http.services.searxng.loadbalancer.server.port=8080" - # Prod Router - - "traefik.http.routers.searxng.rule=Host(`s.forust.xyz` || `search.forust.xyz`)" - - "traefik.http.routers.searxng.entrypoints=websecure" - - "traefik.http.routers.searxng.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.searxng-local.rule=Host(`s.workstation.internal` || `searxng.workstation.internal`)" - - "traefik.http.routers.searxng-local.entrypoints=websecure" - - "traefik.http.routers.searxng-local.tls=true" - # Dev Router - - "traefik.http.routers.searxng-dev.rule=Host(`searxng.gigaforust.internal`)" - - "traefik.http.routers.searxng-dev.entrypoints=websecure" - - "traefik.http.routers.searxng-dev.tls=true" - networks: - - proxy - volumes: - - ./core-config/:/etc/searxng/:Z - - core-data:/var/cache/searxng/ - - valkey: - container_name: searxng-valkey - image: docker.io/valkey/valkey:9-alpine - command: valkey-server --save 30 1 --loglevel warning - restart: unless-stopped - volumes: - - valkey-data:/data/ - -volumes: - core-data: - valkey-data: -networks: - proxy: - external: true diff --git a/searxng/k8s/config.yaml b/searxng/k8s/config.yaml deleted file mode 100644 index e555598..0000000 --- a/searxng/k8s/config.yaml +++ /dev/null @@ -1,11 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: searxng-config - namespace: searxng -data: - SEARXNG_BASE_URL: "https://s.forust.xyz" - SEARXNG_PORT: "8080" - SEARXNG_BIND_ADDRESS: "0.0.0.0" - SEARXNG_LIMITER: "true" - SEARXNG_VALKEY_URL: "valkey://searxng-valkey-service:6379/0" diff --git a/searxng/k8s/ingress.yaml b/searxng/k8s/ingress.yaml deleted file mode 100644 index 5c598f8..0000000 --- a/searxng/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: searxng-prod - namespace: searxng -spec: - entryPoints: - - websecure - routes: - - match: Host(`s.forust.xyz`) || Host(`search.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: searxng-service - port: 8080 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: searxng-local - namespace: searxng -spec: - entryPoints: - - websecure - routes: - - match: Host(`s.workstation.internal`) || Host(`searxng.workstation.internal`) || Host(`s.gigaforust.internal`) || Host(`searxng.gigaforust.internal`) - kind: Rule - services: - - name: searxng-service - port: 8080 diff --git a/searxng/k8s/namespace.yaml b/searxng/k8s/namespace.yaml deleted file mode 100644 index 8739154..0000000 --- a/searxng/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: searxng diff --git a/searxng/k8s/searxng.yaml b/searxng/k8s/searxng.yaml deleted file mode 100644 index 367e191..0000000 --- a/searxng/k8s/searxng.yaml +++ /dev/null @@ -1,50 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: searxng-service - namespace: searxng -spec: - selector: - app: searxng - ports: - - port: 8080 - targetPort: 8080 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: searxng-deployment - namespace: searxng -spec: - replicas: 1 - selector: - matchLabels: - app: searxng - template: - metadata: - labels: - app: searxng - spec: - containers: - - name: searxng - image: docker.io/searxng/searxng:latest - envFrom: - - configMapRef: - name: searxng-config - - secretRef: - name: searxng-secrets - ports: - - containerPort: 8080 - volumeMounts: - - name: cache - mountPath: /var/cache/searxng - resources: - requests: - memory: "300Mi" - cpu: "30m" - limits: - memory: "700Mi" - cpu: "500m" - volumes: - - name: cache - emptyDir: {} diff --git a/searxng/k8s/valkey.yaml b/searxng/k8s/valkey.yaml deleted file mode 100644 index 5d13832..0000000 --- a/searxng/k8s/valkey.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: searxng-valkey-service - namespace: searxng -spec: - clusterIP: None - selector: - app: searxng-valkey - ports: - - port: 6379 - targetPort: 6379 ---- -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: searxng-valkey-statefulset - namespace: searxng -spec: - serviceName: searxng-valkey-service - replicas: 1 - selector: - matchLabels: - app: searxng-valkey - template: - metadata: - labels: - app: searxng-valkey - spec: - containers: - - name: valkey - image: docker.io/valkey/valkey:9-alpine - command: - - valkey-server - - --save - - "30 1" - - --loglevel - - warning - ports: - - containerPort: 6379 - volumeMounts: - - name: valkey-data - mountPath: /data - volumeClaimTemplates: - - metadata: - name: valkey-data - spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 1Gi diff --git a/string_gen.py b/string_gen.py new file mode 100644 index 0000000..5dcf665 --- /dev/null +++ b/string_gen.py @@ -0,0 +1,10 @@ +from pyrogram import Client, enums + +api_id = input('Enter Your API ID: \n') +api_hash = input('Enter Your API HASH : \n') + +with Client('my_account', api_id=api_id, api_hash=api_hash, hide_password=True) as bot_: + first_name = (bot_.get_me()).first_name + string_session_ = f'String Session For {first_name} \n{bot_.export_session_string()}' + bot_.send_message('me', string_session_, parse_mode=enums.ParseMode.HTML, disable_web_page_preview=True) + print(f'String Has Been Sent To Your Saved Message : {first_name}') diff --git a/termix/compose.yaml b/termix/compose.yaml deleted file mode 100644 index 7cd5c03..0000000 --- a/termix/compose.yaml +++ /dev/null @@ -1,34 +0,0 @@ -services: - termix: - image: ghcr.io/lukegus/termix:latest - container_name: termix - restart: unless-stopped - # ports: - # - "3331:8080" - volumes: - - data:/app/data - environment: - PORT: "8080" - labels: - - "traefik.enable=true" - - "traefik.http.services.termix.loadbalancer.server.port=8080" - - # Prod Router - - "traefik.http.routers.termix.rule=Host(`termix.forust.xyz`)" - - "traefik.http.routers.termix.entrypoints=websecure" - - "traefik.http.routers.termix.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.termix-local.rule=Host(`termix.workstation.internal`)" - - "traefik.http.routers.termix-local.entrypoints=websecure" - - "traefik.http.routers.termix-local.tls=true" - # Dev Router - - "traefik.http.routers.termix-dev.rule=Host(`termix.gigaforust.internal`)" - - "traefik.http.routers.termix-dev.entrypoints=websecure" - - "traefik.http.routers.termix-dev.tls=true" - networks: - - proxy -networks: - proxy: - external: true -volumes: - data: diff --git a/termix/k8s/config.yaml b/termix/k8s/config.yaml deleted file mode 100644 index ae01acd..0000000 --- a/termix/k8s/config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: termix-config - namespace: termix -data: - PORT: "8080" diff --git a/termix/k8s/ingress.yaml b/termix/k8s/ingress.yaml deleted file mode 100644 index abe72ee..0000000 --- a/termix/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: termix-prod - namespace: termix -spec: - entryPoints: - - websecure - routes: - - match: Host(`termix.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: termix-service - port: 8080 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: termix-local - namespace: termix -spec: - entryPoints: - - websecure - routes: - - match: Host(`termix.workstation.internal`) || Host(`termix.gigaforust.internal`) - kind: Rule - services: - - name: termix-service - port: 8080 diff --git a/termix/k8s/namespace.yaml b/termix/k8s/namespace.yaml deleted file mode 100644 index 2154cb6..0000000 --- a/termix/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: termix diff --git a/termix/k8s/termix.yaml b/termix/k8s/termix.yaml deleted file mode 100644 index 248ec9b..0000000 --- a/termix/k8s/termix.yaml +++ /dev/null @@ -1,69 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: termix-service - namespace: termix -spec: - selector: - app: termix - ports: - - port: 8080 - targetPort: 8080 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: termix-deployment - namespace: termix -spec: - replicas: 1 - selector: - matchLabels: - app: termix - template: - metadata: - labels: - app: termix - spec: - containers: - - name: termix - image: ghcr.io/lukegus/termix:latest - envFrom: - - configMapRef: - name: termix-config - ports: - - containerPort: 8080 - volumeMounts: - - name: termix-data - mountPath: /app/data - resources: - requests: - memory: "128Mi" - cpu: "100m" - limits: - memory: "256Mi" - cpu: "300m" - livenessProbe: - httpGet: - path: /health - port: 8080 - initialDelaySeconds: 20 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - volumes: - - name: termix-data - persistentVolumeClaim: - claimName: termix-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: termix-pvc - namespace: termix -spec: - accessModes: - - ReadWriteOnce - resources: - requests: - storage: 1Gi diff --git a/termux-install.sh b/termux-install.sh new file mode 100644 index 0000000..4a045ef --- /dev/null +++ b/termux-install.sh @@ -0,0 +1,143 @@ +if ! command -v termux-setup-storage; then + echo This script can be executed only on Termux + exit 1 +fi + +termux-wake-lock + +pkg update -y && pkg upgrade -y +pkg install python3 git clang ffmpeg wget libjpeg-turbo libcrypt ndk-sysroot zlib openssl -y || exit 2 + + +LDFLAGS="-L${PREFIX}/lib/" CFLAGS="-I${PREFIX}/include/" pip3 install --upgrade wheel pillow + +if [[ -d "Moon-Userbot" ]]; then + cd Moon-Userbot +elif [[ -f ".env.dist" ]] && [[ -f "main.py" ]] && [[ -d "modules" ]]; then + : +else + git clone https://github.com/The-MoonTg-project/Moon-Userbot || exit 2 + cd Moon-Userbot || exit 2 +fi + +if [[ -f ".env" ]] && [[ -f "my_account.session" ]]; then + echo "It seems that Moon-Userbot is already installed. Exiting..." + exit +fi + +python3 -m pip install -U -r requirements.txt || exit 2 + +echo +echo "Enter API_ID and API_HASH" +echo "You can get it here -> https://my.telegram.org/apps" +echo "Leave empty to use defaults (please note that default keys significantly increases your ban chances)" +read -r -p "API_ID > " api_id + +if [[ $api_id = "" ]]; then + api_id="2040" + api_hash="b18441a1ff607e10a989891a5462e627" +else + read -r -p "API_HASH > " api_hash +fi + +echo +echo "SET PM PERMIT warn limit" +read -r -p "PM_LIMIT warn limit > " pm_limit + +if [[ $pm_limit = "" ]]; then + pm_limit="3" + echo "limit not provided by user set to default" +fi + +echo +echo "Enter APIFLASH_KEY for webshot plugin" +echo "You can get it here -> https://apiflash.com/dashboard/access_keys" +read -r -p "APIFLASH_KEY > " apiflash_key + +if [[ $apiflash_key = "" ]]; then + echo "NOTE: API Not set you'll not be able to use .webshot plugin" +fi + +echo +echo "Enter RMBG_KEY for remove background module" +echo "You can get it here -> https://www.remove.bg/dashboard#api-key" +read -r -p "RMBG_KEY > " rmbg_key + +if [[ $rmbg_key = "" ]]; then + echo "NOTE: API Not set you'll not be able to use remove background modules" +fi + +echo +echo "Enter GEMINI_KEY if you want to use AI" +echo "NOTE: Don't Use unless you've enough storage in your device" +echo "MIN. REQ. STORAGE: 128GB" +echo "You can get it here -> https://makersuite.google.com/app/apikey" +read -r -p "GEMINI_KEY > " gemini_key + +if [[ $gemini_key = "" ]]; then + echo "NOTE: API Not set you'll not be able to use Gemini AI modules" +fi + +echo +echo "Enter COHERE_KEY if you want to use AI" +echo "You can get it here -> https://dashboard.cohere.com/api-keys" +read -r -p "COHERE_KEY > " cohere_key + +if [[ $cohere_key = "" ]]; then + echo "NOTE: API Not set you'll not be able to use Coral AI modules" +fi + +echo +echo "Enter VT_KEY for VirusTotal" +echo "You can get it here -> https://www.virustotal.com/" +read -r -p "VT_KEY > " vt_key + +if [[ $vt_key = "" ]]; then + echo "NOTE: API Not set you'll not be able to use VirusTotal module" +fi + +echo "Choose database type:" +echo "[1] MongoDB (your url)" +echo "[2] Sqlite" +read -r -p "> " db_type + +if [[ $db_type = 1 ]]; then + echo "Please enter db_url" + echo "You can get it here -> https://telegra.ph/How-to-get-Mongodb-URL-and-login-in-telegram-08-01" + read -r -p "> " db_url + db_name=Moon_Userbot + db_type=mongodb +else + db_name=db.sqlite3 + db_type=sqlite3 +fi + +cat > .env << EOL +API_ID=${api_id} +API_HASH=${api_hash} + +STRINGSESSION= + +# sqlite/sqlite3 or mongo/mongodb +DATABASE_TYPE=${db_type} +# file name for sqlite3, database name for mongodb +DATABASE_NAME=${db_name} + +# only for mongodb +DATABASE_URL=${db_url} + +APIFLASH_KEY=${apiflash_key} +RMBG_KEY=${rmbg_key} +VT_KEY=${vt_key} +GEMINI_KEY=${gemini_key} +COHERE_KEY=${cohere_key} +PM_LIMIT=${pm_limit} +EOL + +python3 install.py 3 || exit 3 + +echo +echo "============================" +echo "Great! Moon-Userbot installed successfully!" +echo "Start with: \"cd Moon-Userbot && python3 main.py\"" +echo "============================" diff --git a/traefik/.env.example b/traefik/.env.example deleted file mode 100644 index cf72c84..0000000 --- a/traefik/.env.example +++ /dev/null @@ -1,3 +0,0 @@ -# =================================== -# Traefik envs -EMAIL=bobrovod@national.shitposting.agency diff --git a/traefik/compose.yaml b/traefik/compose.yaml deleted file mode 100644 index 7df6ea5..0000000 --- a/traefik/compose.yaml +++ /dev/null @@ -1,81 +0,0 @@ -services: - traefik: - image: traefik:v3.7.4 - container_name: traefik - restart: unless-stopped - command: - # API - - "--api.insecure=true" - - "--api.dashboard=true" - - # Providers - - "--providers.docker=true" - - "--providers.docker.exposedbydefault=false" - - "--providers.docker.network=proxy" - - "--providers.file.directory=/etc/traefik/dynamic" - - "--providers.file.watch=true" - - # EntryPoints - - "--entryPoints.web.address=:80" - - "--entryPoints.web.http.redirections.entryPoint.scheme=https" - - "--entryPoints.web.http.redirections.entryPoint.to=websecure" - - "--entryPoints.websecure.address=:443" - - "--entryPoints.websecure.http.middlewares=error-pages@docker" - - "--entryPoints.websecure.http.tls=true" - - "--entryPoints.ssh.address=:2221" - - # Let's Encrypt - - "--certificatesresolvers.letsencrypt.acme.email=${EMAIL}" - - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" - - "--certificatesresolvers.letsencrypt.acme.httpchallenge=true" - - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web" - # # STAGING - # - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" - - # Cloudflare - - "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" - - "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22" - # Logging - - "--log.level=INFO" - - "--log.filePath=/var/log/traefik/traefik.log" - - "--log.format=json" - - "--accesslog=true" - - "--accesslog.filepath=/var/log/traefik/access.log" - labels: - - "traefik.enable=true" - - # Prod Router (Dash) - - "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.forust.xyz`)" - - "traefik.http.routers.traefik-dashboard.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard.middlewares=security-chain@file" - - "traefik.http.routers.traefik-dashboard.service=api@internal" - - "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.traefik-dashboard-local.rule=Host(`traefik.workstation.internal`)" - - "traefik.http.routers.traefik-dashboard-local.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard-local.service=api@internal" - - "traefik.http.routers.traefik-dashboard-local.tls=true" - # Dev Router - - "traefik.http.routers.traefik-dashboard-dev.rule=Host(`traefik.gigaforust.internal`)" - - "traefik.http.routers.traefik-dashboard-dev.entrypoints=websecure" - - "traefik.http.routers.traefik-dashboard-dev.service=api@internal" - - "traefik.http.routers.traefik-dashboard-dev.tls=true" - - # Glance Metadata - - glance.name=Traefik - - glance.url=https://traefik.forust.xyz/ - - glance.description=Traefik is a modern reverse proxy and load balancer - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - - ./dynamic:/etc/traefik/dynamic:ro - - ./certs:/certs:ro - - ./logs:/var/log/traefik - - ./letsencrypt:/letsencrypt - ports: - - "80:80" - - "443:443" - networks: - - proxy -networks: - proxy: - external: true diff --git a/traefik/dynamic/dynamic.yml b/traefik/dynamic/dynamic.yml deleted file mode 100644 index 7e569bd..0000000 --- a/traefik/dynamic/dynamic.yml +++ /dev/null @@ -1,4 +0,0 @@ -http: - serversTransports: - insecureTransport: - insecureSkipVerify: true diff --git a/traefik/dynamic/fileservers.yml.example b/traefik/dynamic/fileservers.yml.example deleted file mode 100644 index 8bafc4c..0000000 --- a/traefik/dynamic/fileservers.yml.example +++ /dev/null @@ -1,23 +0,0 @@ -http: - routers: - fs1-public: - rule: "Host(`fs1.domain.xyz`)" - entrypoints: - - websecure - service: fs1 - middlewares: - - security-chain@file - tls: {} - - fs1-workstation: - rule: "Host(`fs1.workstation.internal`)" - entrypoints: - - websecure - service: fs1 - tls: {} - - services: - fs1: - loadBalancer: - servers: - - url: "http://127.0.0.1:3923" # Copyparty port example diff --git a/traefik/dynamic/middlewares.yml b/traefik/dynamic/middlewares.yml deleted file mode 100644 index f513e03..0000000 --- a/traefik/dynamic/middlewares.yml +++ /dev/null @@ -1,87 +0,0 @@ -http: - middlewares: - # HTTPS Redirect - redirect-https: - redirectScheme: - scheme: https - permanent: true - # Cloudflare IP Whitelist - cloudflare-ipwhitelist: - ipWhiteList: - sourceRange: - - "173.245.48.0/20" - - "103.21.244.0/22" - - "103.22.200.0/22" - - "103.31.4.0/22" - - "141.101.64.0/18" - - "108.162.192.0/18" - - "190.93.240.0/20" - - "188.114.96.0/20" - - "197.234.240.0/22" - - "198.41.128.0/17" - - "162.158.0.0/15" - - "104.16.0.0/13" - - "104.24.0.0/14" - - "172.64.0.0/13" - - "131.0.72.0/22" - - # Authentik + secure headers - security-chain: - chain: - middlewares: - - authentik@file - - security-headers@file - authentik: - forwardAuth: - address: "http://authentik-server:9000/outpost.goauthentik.io/auth/traefik" - trustForwardHeader: true - maxResponseBodySize: 1048576 - authResponseHeaders: - - X-authentik-username - - X-authentik-groups - - X-authentik-email - - X-authentik-name - - X-authentik-uid - - X-authentik-jwt - - X-authentik-meta-jwks - - X-authentik-meta-outpost - - X-authentik-meta-provider - - X-authentik-meta-app - - X-authentik-meta-version - - # Security Headers - security-headers: - headers: - browserXssFilter: true - contentTypeNosniff: true - forceSTSHeader: true - stsIncludeSubdomains: true - stsPreload: true - stsSeconds: 31536000 - customFrameOptionsValue: "SAMEORIGIN" - customResponseHeaders: - X-Content-Type-Options: "nosniff" - Referrer-Policy: "strict-origin-when-cross-origin" - - # Nextcloud specific headers - nextcloud-secure-headers: - headers: - hostsProxyHeaders: - - "X-Forwarded-Host" - - "X-Forwarded-Proto" - referrerPolicy: "same-origin" - customFrameOptionsValue: "SAMEORIGIN" - - # Rate limiting - rate-limit: - rateLimit: - average: 100 - burst: 50 - period: 1m - - # Nextcloud chain - nextcloud-chain: - chain: - middlewares: - - nextcloud-secure-headers@file - - security-headers@file diff --git a/traefik/dynamic/nextcloud-apache.yml b/traefik/dynamic/nextcloud-apache.yml deleted file mode 100644 index ffd51a2..0000000 --- a/traefik/dynamic/nextcloud-apache.yml +++ /dev/null @@ -1,39 +0,0 @@ -http: - routers: - # Nextcloud prod - nextcloud: - rule: "Host(`nextcloud.forust.xyz`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: - certresolver: letsencrypt - - # Nextcloud dev - nextcloud-local: - rule: "Host(`nextcloud.workstation.internal`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: {} - - # Nextcloud dev - nextcloud-dev: - rule: "Host(`nextcloud.gigaforust.internal`)" - entrypoints: - - websecure - service: nextcloud - middlewares: - - nextcloud-chain - tls: {} - - services: - # Nextcloud Main - nextcloud: - loadBalancer: - servers: - - url: "http://nextcloud-aio-apache:11000" diff --git a/traefik/dynamic/redirect.yml b/traefik/dynamic/redirect.yml deleted file mode 100644 index 334325f..0000000 --- a/traefik/dynamic/redirect.yml +++ /dev/null @@ -1,8 +0,0 @@ -http: - routers: - acme-challenge-exempt: - rule: "PathPrefix(`/.well-known/acme-challenge`)" - entryPoints: - - web - service: noop@internal - priority: 100 diff --git a/traefik/dynamic/tls.yml b/traefik/dynamic/tls.yml deleted file mode 100644 index 69d1ca6..0000000 --- a/traefik/dynamic/tls.yml +++ /dev/null @@ -1,21 +0,0 @@ -# TLS Configuration -tls: - certificates: - # Cloudflare Origin CA *.forust.xyz - # - certFile: /certs/cloudflare.pem - # keyFile: /certs/cloudflare.key - # stores: - # default: - # defaultCertificate: - # # Fallback local certificate - # certFile: /certs/local.pem - # keyFile: /certs/local-key.pem - - options: - default: - minVersion: VersionTLS12 - sniStrict: false - cipherSuites: - - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 diff --git a/traefik/k8s/ingress.yaml b/traefik/k8s/ingress.yaml deleted file mode 100644 index e957454..0000000 --- a/traefik/k8s/ingress.yaml +++ /dev/null @@ -1,34 +0,0 @@ ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: dashboard-prod - namespace: traefik -spec: - entryPoints: - - websecure - routes: - - match: Host(`traefik.forust.xyz`) - kind: Rule - middlewares: - - name: "crowdsec-crowdsec-bouncer@kubernetescrd" - services: - - name: api@internal - kind: TraefikService - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: dashboard-local - namespace: traefik -spec: - entryPoints: - - websecure - routes: - - match: Host(`traefik.workstation.internal`) || Host(`traefik.gigaforust.internal`) - kind: Rule - services: - - name: api@internal - kind: TraefikService diff --git a/traefik/k8s/local-tls.yaml.example b/traefik/k8s/local-tls.yaml.example deleted file mode 100644 index f1b86c1..0000000 --- a/traefik/k8s/local-tls.yaml.example +++ /dev/null @@ -1,8 +0,0 @@ -apiVersion: v1 -data: - tls.crt: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUVWRENDQXJ5Z0F3SUJBZ0lSQUpkbVJpaHoxNWNpeXczUU5wTVgxVGd3RFFZSktvWklodmNOQVFFTEJRQXcKZFRFZU1Cd0dBMVVFQ2hNVmJXdGpaWEowSUdSbGRtVnNiM0J0Wlc1MElFTkJNU1V3SXdZRFZRUUxEQnhtYjNKMQpjM1JBWjJsbllXWnZjblZ6ZENBb1RYSkdiM0oxYzNRcE1Td3dLZ1lEVlFRRERDTnRhMk5sY25RZ1ptOXlkWE4wClFHZHBaMkZtYjNKMWMzUWdLRTF5Um05eWRYTjBLVEFlRncweU5URXlNRFV4TWpVME16UmFGdzB5T0RBek1EVXgKTWpVME16UmFNRkF4SnpBbEJnTlZCQW9USG0xclkyVnlkQ0JrWlhabGJHOXdiV1Z1ZENCalpYSjBhV1pwWTJGMApaVEVsTUNNR0ExVUVDd3djWm05eWRYTjBRR2RwWjJGbWIzSjFjM1FnS0UxeVJtOXlkWE4wS1RDQ0FTSXdEUVlKCktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQUx1c2dJNUJuQVJnZzVTS1hpR29PT3dXSkFocXBXSUQKOWg4cGtUMjFTZzhxM3haeU80anZjUEVWbmpOVzRnRFQ0WUhFdjNIZDdZY25IRFdOWG5qSW9LdUd2N2lMOUQyagpLMllNUGtXOGM4RmhJRzFycTJmNTR5YXYxbm1QVmtoYVk2WkJRdzRVT0ZCKzRYRHprbDJTZXcxQnBCV3JIQmZmCitYT2gzUUllcFRiZGdGalhsS0VQdEZuR0YrZFhyZmZmZm5Ucm80ejJ4QVpYeUdSb2pqZTZSbi9tMjJYeHZVRXYKcnhnUE5sM2Y4N0grZXpmZXgrQndMZWNSbVVhQkU0MEFmOHpobSs3T0JvZ1psQU4vZGU2TnZrT1JWdjRydGdIWgpaczFacGYzYmxqTGpwUmh2NGNwYVhPam44bW9UaEEzNUsrRVVUY3hBaUVZSE5pVHZFY2tMZXFjQ0F3RUFBYU9CCmd6Q0JnREFPQmdOVkhROEJBZjhFQkFNQ0JhQXdFd1lEVlIwbEJBd3dDZ1lJS3dZQkJRVUhBd0V3SHdZRFZSMGoKQkJnd0ZvQVVYU25JWGM5cU55TktibDhEZjZ4T3V3UHZFQW93T0FZRFZSMFJCREV3TDRJVktpNW5hV2RoWm05eQpkWE4wTG1sdWRHVnlibUZzZ2hZcUxuZHZjbXR6ZEdGMGFXOXVMbWx1ZEdWeWJtRnNNQTBHQ1NxR1NJYjNEUUVCCkN3VUFBNElCZ1FETVdmMXUzQm5IRnV4TlpRN29CYnZBQk5Xb0xlOTlSb2N6TFk2c2dzZkdCZFFIWXdvUzMxT2EKZXgwT0ZYVXZQb2N2NWRIdWdtSlpvUlN0NDU1QndZRzVuTUM0VTJhY0NzNm53TjJGK2paa3BNL2xCUEdtek1WWgpvM0lNc1M0VU5IYzZXRGllRWp3Y1J1Q005bldWQnRrTmE4RTdsdWJxZk5SclhZTjB4L0YzWGpud2RWUS8wT0l0CnZvc2ZyRTRzMVJUdEhmY3VQWnhBZW9NSVdFN0pSQjRMOHhabHU5T0RZeituRGwva3BOTDkwVThHdXQwWFFucHgKY1RScVhnNC9nT1RKOUFlUnMwMHIrQU9wV1V3ZExFTmZnQVZ4Z0MrZ3FWVmFMV0NlVkFTWi9UNFJNWmt6K0YxeQpZRU5PR2o2L09hdm5wd25Wd21GcVRya1lHWURqU3RNWW9GZHBrWHB1VjY1dkg3Qnk4cHkyNmpMWFZ3VUpsUjQyClFUMGZnV0RDVFcxZTEwQ0o4N2NZMVNFQ3N2T2kwWVM0ZmtUTHJCM0FsMEhoQVZiWkFicmRSSHErMWQySzhtSTIKcUVpOXVPVnZZckZneHFtcWxIRUR1cFJMM095bWJ6bnRQeFZabXpzMmRiRXNVd0lxUXo0VFRiTzU4ZWs0eVI2dApQdG5hNyttNzJIMD0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo= - tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2QUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktZd2dnU2lBZ0VBQW9JQkFRQzdySUNPUVp3RVlJT1UKaWw0aHFEanNGaVFJYXFWaUEvWWZLWkU5dFVvUEt0OFdjanVJNzNEeEZaNHpWdUlBMCtHQnhMOXgzZTJISnh3MQpqVjU0eUtDcmhyKzRpL1E5b3l0bURENUZ2SFBCWVNCdGE2dG4rZU1tcjlaNWoxWklXbU9tUVVNT0ZEaFFmdUZ3Cjg1SmRrbnNOUWFRVnF4d1gzL2x6b2QwQ0hxVTIzWUJZMTVTaEQ3Ulp4aGZuVjYzMzMzNTA2Nk9NOXNRR1Y4aGsKYUk0M3VrWi81dHRsOGIxQkw2OFlEelpkMy9PeC9uczMzc2ZnY0MzbkVabEdnUk9OQUgvTTRadnV6Z2FJR1pRRApmM1h1amI1RGtWYitLN1lCMldiTldhWDkyNVl5NDZVWWIrSEtXbHpvNS9KcUU0UU4rU3ZoRkUzTVFJaEdCellrCjd4SEpDM3FuQWdNQkFBRUNnZ0VBR1dmNDMwa3lmY1V6Q3VEUWJXNEFoclZZbTJzVWlIaFEwVmNuT2x0WWMwVXkKZmk5b3I5ajZTU2pNQ2RjeE1UdDl3N3VHenp0ajlHL3UrYkR4b1JoSDUvQ044OFcrZ3JtY0hteDU0YWJwWkx4cApUMmJNQlg5bXFNNFZFcys4dG43R0RyTzNDQ2prbHNFV2M5d0ZiSFZ3QXhObnl5Y20zblNMcUlPNUQ1aG1kVTY3CmlRR1c3bEd3WGJicWlTbUZ1NjIyaERNWWE0Z3RYUnB5cXkrZlpjcGNES29iSXpJRjBCcmhQRDN5RytCellhaFYKVVpIZVBlRm9mTjQvOXI4MTVmOWE3MndPSFBJa0Z6NWdNSlBIREdkUDh1aHdrdzlUOEV2NHAvT0lLZlJTYmc2cgp3VVdlMVowNFUzL0VvenkyczRwYURua1VYVXBoRytINzhnTnVta2VIQVFLQmdRREpOM0VSMURjMHplWEo0Um5oCmI3TldzY1RDV09sS2FkVGN2YlFFY3ViRnVjZzE3Vkc4UzNrVjhSYStmam9zVGNpN0hmR1dZSUc2MS90dXNXYTgKTDVJVUNRNnJlSFhpempLM1BsK0oyOW9TM1RhbjZDdDlxOEtaalYwMXZKTXEzYk1FQVg5VUVwL2JTbHdPUWVqeApOZkZrZ2JFMkRmSEJRVTY3SVlsQ2lVMy9FUUtCZ1FEdXhTWGd3UTRYT1h6T1hrMzlVZE56TVB3S2tDUFhMZkgxCjRGbElTUXZHU05QY1FmK0t0WVVvL1hGNXF2RkkxTUNEa1JBT01FMWZCcFQ4OW9wbEVLUXQ3M3RLc1BoUHhzSHgKem5sL2t0MVRZdUZrcDZtdk9wOHpLdHZqRGVoRGoxUmJZTVN3OFZJNkNlSTRqcDZoSmdZQVhhNURwbFkxM3dFegpHMnhCV1dyT053S0JnRDQ3UEdna056TEluMUlSb1h4OG5IMnFtMHowVEtxc0VwNTJ1VzlLemg3Rzk2bmVEUkpzClVUVzlDNmk4eDJjZVkzK2pvaTA5Q2NKc3ZDR2ZFa1FQZm1GZDc4Q09qLzhtZ0w3MXRueWZsMUdRUXpBbytSSjAKQmNxUjB3c2ttR3VBaDdZc2RHSGZKTHVnd3RuUi9xWmpaWllOR202NlgxUEN6QVBLT25adW1zWFJBb0dBY2pURQphRnVQRk5hL2Rxb3UzWkxzOC9VaktSNUd0bDdZbEFYS1RzUDVxTThlM3dTR2FWOW5vUEtRKzJRall2VzRXM1hnCld4a0plUUZaOUNFc2t5akJqdU0wY3NaeER4OS9sdHh1eXZHQmdhZ1RMU1R4Q2NxZGFVai93ZnZkODZxejY3MCsKZDNBR0o1S2dyUEt5dDF2TGxSTTZEa3VhZ3N2MjgrNFMrckw5WGtNQ2dZQjQ0d0IzdUs4N0lsK3F0dGFNdG5SQwp5NkcwNlBsUkZlbGF6THoxc2F0aC9Gam1JUm5rd2tqcUEzRFRwV04wM1RWRStqdDRQY2h5VkQ2cWw0S3JJQzZMCjdKWDc4Qm1Yc1NGTkxZZ3h4NUNCejJUU0wvODA1amdVRnVmZGg5TjlWZm9YWnJKSldSakpqTTg4UU5xTmxNSWMKbVVobkMzU01kajdkdDVMSU9pNE5NUT09Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0K -kind: Secret -metadata: - name: local-tls -type: kubernetes.io/tls diff --git a/traefik/k8s/traefik-dynamic-configmap.yaml b/traefik/k8s/traefik-dynamic-configmap.yaml deleted file mode 100644 index abbae31..0000000 --- a/traefik/k8s/traefik-dynamic-configmap.yaml +++ /dev/null @@ -1,124 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: traefik ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: traefik-dynamic - namespace: traefik -data: - dynamic.yml: | - http: - serversTransports: - insecureTransport: - insecureSkipVerify: true - middlewares.yml: | - http: - middlewares: - # HTTPS Redirect - redirect-https: - redirectScheme: - scheme: https - permanent: true - # Cloudflare IP Whitelist - cloudflare-ipwhitelist: - ipWhiteList: - sourceRange: - - "173.245.48.0/20" - - "103.21.244.0/22" - - "103.22.200.0/22" - - "103.31.4.0/22" - - "141.101.64.0/18" - - "108.162.192.0/18" - - "190.93.240.0/20" - - "188.114.96.0/20" - - "197.234.240.0/22" - - "198.41.128.0/17" - - "162.158.0.0/15" - - "104.16.0.0/13" - - "104.24.0.0/14" - - "172.64.0.0/13" - - "131.0.72.0/22" - - # Authentik + secure headers - security-chain: - chain: - middlewares: - - authentik@file - - security-headers@file - authentik: - forwardAuth: - address: "http://authentik-server-service.authentik.svc:9000/outpost.goauthentik.io/auth/traefik" - trustForwardHeader: true - maxResponseBodySize: 1048576 - authResponseHeaders: - - X-authentik-username - - X-authentik-groups - - X-authentik-email - - X-authentik-name - - X-authentik-uid - - X-authentik-jwt - - X-authentik-meta-jwks - - X-authentik-meta-outpost - - X-authentik-meta-provider - - X-authentik-meta-app - - X-authentik-meta-version - - # Security Headers - security-headers: - headers: - browserXssFilter: true - contentTypeNosniff: true - forceSTSHeader: true - stsIncludeSubdomains: true - stsPreload: true - stsSeconds: 31536000 - customFrameOptionsValue: "SAMEORIGIN" - customResponseHeaders: - X-Content-Type-Options: "nosniff" - Referrer-Policy: "strict-origin-when-cross-origin" - - # Nextcloud specific headers - nextcloud-secure-headers: - headers: - hostsProxyHeaders: - - "X-Forwarded-Host" - - "X-Forwarded-Proto" - referrerPolicy: "same-origin" - customFrameOptionsValue: "SAMEORIGIN" - - # Rate limiting - rate-limit: - rateLimit: - average: 100 - burst: 50 - period: 1m - - # Nextcloud chain - nextcloud-chain: - chain: - middlewares: - - nextcloud-secure-headers@file - - security-headers@file - - # Error pages - error-pages: - errors: - status: - - "400" - - "402-599" - service: error-pages-service.error-pages@kubernetescrd - query: /{status}.html - tls.yml: | - # TLS Configuration - tls: - options: - default: - minVersion: VersionTLS12 - sniStrict: false - cipherSuites: - - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 - - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 - - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305 diff --git a/traefik/k8s/traefik-values.yaml b/traefik/k8s/traefik-values.yaml deleted file mode 100644 index b8d0543..0000000 --- a/traefik/k8s/traefik-values.yaml +++ /dev/null @@ -1,148 +0,0 @@ -# Auto-generated by forust | See https://github.com/traefik/traefik-helm-chart/blob/master/values.yaml -hostNetwork: false - -securityContext: - capabilities: - add: - - NET_BIND_SERVICE - -podSecurityContext: ~ - -service: - enabled: true - type: LoadBalancer - annotations: - metallb.io/loadBalancerIPs: "192.168.80.2" - spec: - externalTrafficPolicy: Local -api: - dashboard: true - insecure: true - -updateStrategy: - type: Recreate - -deployment: - initContainers: - - name: volume-permissions - image: busybox:latest - command: - - sh - - -c - - "mkdir -p /data/letsencrypt && touch /data/letsencrypt/acme.json && chmod 600 /data/letsencrypt/acme.json" - volumeMounts: - - name: data - mountPath: /data - -providers: - kubernetesIngress: - enabled: false - kubernetesCRD: - enabled: true - kubernetesGateway: - enabled: false - file: - enabled: false - -# Entrypoints -ports: - web: - port: 80 - http: - redirections: - entryPoint: - to: websecure - scheme: https - permanent: true - websecure: - port: 443 - http: - tls: - enabled: true - ssh: - port: 2221 - exposedPort: 2221 - protocol: TCP - expose: - default: true - minecraft-tcp: - port: 25565 - exposedPort: 25565 - protocol: TCP - expose: - default: true - minecraft-udp: - port: 19132 - exposedPort: 19132 - protocol: UDP - expose: - default: true - metrics: - port: 9100 - exposedPort: 9100 - protocol: TCP - expose: - default: false - -metrics: - prometheus: - enabled: true - entryPoint: metrics - serviceMonitor: - enabled: true - additionalLabels: - release: prometheus-stack - namespace: prometheus - -ingressRoute: - dashboard: - enabled: false - -persistence: - enabled: true - size: 100Mi - path: /data - -certificatesResolvers: - letsencrypt-staging: - acme: - email: bobrovod@national.shitposting.agency - storage: /data/letsencrypt/acme.json - caServer: https://acme-staging-v02.api.letsencrypt.org/directory - httpChallenge: - entryPoint: web - letsencrypt: - acme: - email: bobrovod@national.shitposting.agency - storage: /data/letsencrypt/acme.json - caServer: https://acme-v02.api.letsencrypt.org/directory - httpChallenge: - entryPoint: web - -volumes: - - name: traefik-dynamic - mountPath: /etc/traefik/dynamic - type: configMap - - name: crowdsec-bouncer-secrets - mountPath: /etc/traefik/secrets - type: secret - -additionalArguments: - - "--providers.file.directory=/etc/traefik/dynamic" - - "--providers.file.watch=true" - - "--providers.kubernetesCRD.allowCrossNamespace=true" - - "--entryPoints.websecure.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1" - - "--entryPoints.web.forwardedHeaders.trustedIPs=173.245.48.0/20,103.21.244.0/22,103.22.200.0/22,103.31.4.0/22,141.101.64.0/18,108.162.192.0/18,190.93.240.0/20,188.114.96.0/20,197.234.240.0/22,198.41.128.0/17,162.158.0.0/15,104.16.0.0/13,104.24.0.0/14,172.64.0.0/13,131.0.72.0/22,192.168.1.1,192.168.1.0/24,192.168.88.0/24,192.168.88.1" - -logs: - general: - level: INFO - access: - enabled: true - format: common - -experimental: - plugins: - crowdsec-bouncer: - moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin - version: v1.3.3 diff --git a/uptime-kuma/compose.yaml b/uptime-kuma/compose.yaml deleted file mode 100644 index f90b718..0000000 --- a/uptime-kuma/compose.yaml +++ /dev/null @@ -1,32 +0,0 @@ -services: - uptime-kuma: - image: louislam/uptime-kuma:2 - container_name: uptime-kuma - restart: unless-stopped - volumes: - - data:/app/data - # ports: - # - "3001:3001" - labels: - - "traefik.enable=true" - - "traefik.http.services.uptime-kuma.loadbalancer.server.port=3001" - - # Prod Router - - "traefik.http.routers.uptime-kuma.rule=Host(`uptime.forust.xyz`)" - - "traefik.http.routers.uptime-kuma.entrypoints=websecure" - - "traefik.http.routers.uptime-kuma.tls.certresolver=letsencrypt" - # Local Router - - "traefik.http.routers.uptime-kuma-local.rule=Host(`uptime.workstation.internal`)" - - "traefik.http.routers.uptime-kuma-local.entrypoints=websecure" - - "traefik.http.routers.uptime-kuma-local.tls=true" - # Dev Router - - "traefik.http.routers.uptime-kuma-dev.rule=Host(`uptime.gigaforust.internal`)" - - "traefik.http.routers.uptime-kuma-dev.entrypoints=websecure" - - "traefik.http.routers.uptime-kuma-dev.tls=true" - networks: - - proxy -volumes: - data: -networks: - proxy: - external: true diff --git a/uptime-kuma/k8s/ingress.yaml b/uptime-kuma/k8s/ingress.yaml deleted file mode 100644 index 189dfd7..0000000 --- a/uptime-kuma/k8s/ingress.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: uptime-kuma-prod - namespace: uptime-kuma -spec: - entryPoints: - - websecure - routes: - - match: Host(`uptime.forust.xyz`) - kind: Rule - middlewares: - - name: crowdsec-crowdsec-bouncer@kubernetescrd - services: - - name: uptime-kuma-service - port: 3001 - tls: - certResolver: letsencrypt ---- -apiVersion: traefik.io/v1alpha1 -kind: IngressRoute -metadata: - name: uptime-kuma-local - namespace: uptime-kuma -spec: - entryPoints: - - websecure - routes: - - match: Host(`uptime.workstation.internal`) || Host(`uptime.gigaforust.internal`) - kind: Rule - services: - - name: uptime-kuma-service - port: 3001 diff --git a/uptime-kuma/k8s/namespace.yaml b/uptime-kuma/k8s/namespace.yaml deleted file mode 100644 index 92c7bb2..0000000 --- a/uptime-kuma/k8s/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: uptime-kuma diff --git a/uptime-kuma/k8s/uptime-kuma.yaml b/uptime-kuma/k8s/uptime-kuma.yaml deleted file mode 100644 index 8f28bd2..0000000 --- a/uptime-kuma/k8s/uptime-kuma.yaml +++ /dev/null @@ -1,59 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - name: uptime-kuma-service - namespace: uptime-kuma -spec: - selector: - app: uptime-kuma - ports: - - port: 3001 - targetPort: 3001 ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: uptime-kuma-deployment - namespace: uptime-kuma -spec: - replicas: 1 - selector: - matchLabels: - app: uptime-kuma - template: - metadata: - labels: - app: uptime-kuma - spec: - containers: - - name: uptime-kuma - image: louislam/uptime-kuma:2 - resources: - limits: - memory: "3Gi" - cpu: "1" - requests: - memory: "512Mi" - cpu: "100m" - ports: - - containerPort: 3001 - volumeMounts: - - name: uptime-kuma-data - mountPath: /app/data - volumes: - - name: uptime-kuma-data - persistentVolumeClaim: - claimName: uptime-kuma-pvc ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: uptime-kuma-pvc - namespace: uptime-kuma -spec: - resources: - requests: - storage: 2Gi - volumeMode: Filesystem - accessModes: - - ReadWriteOnce diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..bc0a8b2 --- /dev/null +++ b/utils/config.py @@ -0,0 +1,31 @@ +import os + +import environs + +env = environs.Env() +try: + env.read_env('./.env') +except FileNotFoundError: + print('No .env file found, using os.environ.') + +api_id = int(os.getenv('API_ID', env.int('API_ID'))) +api_hash = os.getenv('API_HASH', env.str('API_HASH')) + +STRINGSESSION = os.getenv('STRINGSESSION', env.str('STRINGSESSION')) + +second_session = os.getenv('SECOND_SESSION', env.str('SECOND_SESSION', '')) + +db_type = os.getenv('DATABASE_TYPE', env.str('DATABASE_TYPE')) +db_url = os.getenv('DATABASE_URL', env.str('DATABASE_URL', '')) +db_name = os.getenv('DATABASE_NAME', env.str('DATABASE_NAME')) + +apiflash_key = os.getenv('APIFLASH_KEY', env.str('APIFLASH_KEY')) +rmbg_key = os.getenv('RMBG_KEY', env.str('RMBG_KEY', '')) +vt_key = os.getenv('VT_KEY', env.str('VT_KEY', '')) +gemini_key = os.getenv('GEMINI_KEY', env.str('GEMINI_KEY', '')) +cohere_key = os.getenv('COHERE_KEY', env.str('COHERE_KEY', '')) + +pm_limit = int(os.getenv('PM_LIMIT', env.int('PM_LIMIT', 4))) + +test_server = bool(os.getenv('TEST_SERVER', env.bool('TEST_SERVER', False))) +modules_repo_branch = os.getenv('MODULES_REPO_BRANCH', env.str('MODULES_REPO_BRANCH', 'master')) diff --git a/utils/conv.py b/utils/conv.py new file mode 100644 index 0000000..b56e909 --- /dev/null +++ b/utils/conv.py @@ -0,0 +1,183 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio +from collections import OrderedDict + +from pyrogram import Client, filters, types +from pyrogram.enums.parse_mode import ParseMode +from pyrogram.handlers import MessageHandler + + +class _TrueFilter(filters.Filter): + async def __call__(self, client: Client, update: types.Message): + return True + + +class Conversation: + _locks: dict[int, asyncio.Lock] = {} + + def __init__( + self, + client: Client, + chat: str | int, + timeout: float = 5, + delete_at_end=True, + exclusive=True, + ): + self.client = client + self.chat = chat + self.timeout = timeout + self.delete_at_end = delete_at_end + self.exclusive = exclusive + + self._chat_id = 0 + self._message_ids = [] + self._handler_object = None + self._chat_unique_lock: asyncio.Lock | None = None + self._waiters: dict[asyncio.Event, filters.Filter] = {} + self._responses: dict[asyncio.Event, types.Message] = {} + self._pending_updates: list[types.Message] = [] + + async def __aenter__(self): + self._chat_id = (await self.client.get_chat(self.chat)).id + + if self._chat_id in self._locks: + self._chat_unique_lock = self._locks[self._chat_id] + else: + self._chat_unique_lock = self._locks[self._chat_id] = asyncio.Lock() + + if self.exclusive: + await self._chat_unique_lock.acquire() + + self._handler_object = MessageHandler(self._handler, filters.chat(self._chat_id)) + + if -999 not in self.client.dispatcher.groups: + new_groups = OrderedDict(self.client.dispatcher.groups) + new_groups[-999] = [] + self.client.dispatcher.groups = new_groups + + self.client.dispatcher.groups[-999].append(self._handler_object) + + await asyncio.sleep(0) + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + self.client.dispatcher.groups[-999].remove(self._handler_object) + + if self.delete_at_end: + await self.client.delete_messages(self._chat_id, self._message_ids) + + if self.exclusive: + self._chat_unique_lock.release() + + async def _handler(self, _, message: types.Message): + for event, message_filter in self._waiters.items(): + if await message_filter(self.client, message): + self._responses[event] = message + event.set() + break + else: + self._pending_updates.append(message) + message.continue_propagation() + + async def get_response( + self, + message_filter: filters.Filter | None = None, + timeout: float = None, + ) -> types.Message: + if timeout is None: + timeout = self.timeout + if message_filter is None: + message_filter = _TrueFilter() + + for message in self._pending_updates: + if await message_filter(self.client, message): + self._pending_updates.remove(message) + break + else: + message = await self._wait_message(message_filter, timeout) + + self._message_ids.append(message.id) + return message + + async def _wait_message(self, message_filter: filters.Filter | None, timeout: float) -> types.Message: + event = asyncio.Event() + self._waiters[event] = message_filter + + try: + await asyncio.wait_for(event.wait(), timeout=timeout) + except TimeoutError as e: + raise TimeoutError from e + finally: + self._waiters.pop(event) + + return self._responses.pop(event) + + async def send_message( + self, + text: str, + parse_mode: str | None = ParseMode.HTML, + entities: list[types.MessageEntity] = None, + disable_web_page_preview: bool = None, + disable_notification: bool = None, + reply_to_message_id: int = None, + schedule_date: int = None, + ) -> types.Message: + """Send text messages. + + Parameters: + text (``str``): + Text of the message to be sent. + + parse_mode (``str``, *optional*): + By default, texts are parsed using HTML style. + Pass "markdown" or "md" to enable Markdown-style parsing. + Pass None to completely disable style parsing. + + entities (List of :obj:`~pyrogram.types.MessageEntity`): + List of special entities that appear in message text, which can be specified instead of *parse_mode*. + + disable_web_page_preview (``bool``, *optional*): + Disables link previews for links in this message. + + disable_notification (``bool``, *optional*): + Sends the message silently. + Users will receive a notification with no sound. + + reply_to_message_id (``int``, *optional*): + If the message is a reply, ID of the original message. + + schedule_date (``int``, *optional*): + Date when the message will be automatically sent. Unix time. + + Returns: + :obj:`~pyrogram.types.Message`: On success, the sent text message is returned. + """ + + sent = await self.client.send_message( + chat_id=self._chat_id, + text=text, + parse_mode=parse_mode, + entities=entities, + disable_web_page_preview=disable_web_page_preview, + disable_notification=disable_notification, + reply_to_message_id=reply_to_message_id, + schedule_date=schedule_date, + ) + self._message_ids.append(sent.id) + return sent diff --git a/utils/db.py b/utils/db.py new file mode 100644 index 0000000..90a2dc2 --- /dev/null +++ b/utils/db.py @@ -0,0 +1,237 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import json +import re +import sqlite3 +import threading + +import pymongo +from dns import resolver + +from utils import config + +resolver.default_resolver = resolver.Resolver(configure=False) +resolver.default_resolver.nameservers = ['1.1.1.1'] + + +class Database: + def get(self, module: str, variable: str, default=None): + """Get value from database""" + raise NotImplementedError + + def set(self, module: str, variable: str, value): + """Set key in database""" + raise NotImplementedError + + def remove(self, module: str, variable: str): + """Remove key from database""" + raise NotImplementedError + + def get_collection(self, module: str) -> dict: + """Get database for selected module""" + raise NotImplementedError + + def close(self): + """Close the database""" + raise NotImplementedError + + +class MongoDatabase(Database): + def __init__(self, url, name): + self._client = pymongo.MongoClient(url) + self._database = self._client[name] + + def set(self, module: str, variable: str, value): + if not isinstance(module, str) or not isinstance(variable, str): + raise ValueError('Module and variable must be strings') + self._database[module].replace_one({'var': variable}, {'var': variable, 'val': value}, upsert=True) + + def get(self, module: str, variable: str, default=None): + if not isinstance(module, str) or not isinstance(variable, str): + raise ValueError('Module and variable must be strings') + doc = self._database[module].find_one({'var': variable}) + return default if doc is None else doc['val'] + + def get_collection(self, module: str): + if not isinstance(module, str): + raise ValueError('Module must be a string') + return {item['var']: item['val'] for item in self._database[module].find()} + + def remove(self, module: str, variable: str): + if not isinstance(module, str) or not isinstance(variable, str): + raise ValueError('Module and variable must be strings') + self._database[module].delete_one({'var': variable}) + + def close(self): + self._client.close() + + def add_chat_history(self, user_id, message): + chat_history = self.get_chat_history(user_id, default=[]) + chat_history.append(message) + self.set(f'core.cohere.user_{user_id}', 'chat_history', chat_history) + + def get_chat_history(self, user_id, default=None): + if default is None: + default = [] + return self.get(f'core.cohere.user_{user_id}', 'chat_history', default=[]) + + def addaiuser(self, user_id): + chatai_users = self.get('core.chatbot', 'chatai_users', default=[]) + if user_id not in chatai_users: + chatai_users.append(user_id) + self.set('core.chatbot', 'chatai_users', chatai_users) + + def remaiuser(self, user_id): + chatai_users = self.get('core.chatbot', 'chatai_users', default=[]) + if user_id in chatai_users: + chatai_users.remove(user_id) + self.set('core.chatbot', 'chatai_users', chatai_users) + + def getaiusers(self): + return self.get('core.chatbot', 'chatai_users', default=[]) + + +class SqliteDatabase(Database): + def __init__(self, file): + self._conn = sqlite3.connect(file, check_same_thread=False) + self._conn.row_factory = sqlite3.Row + self._cursor = self._conn.cursor() + self._lock = threading.Lock() + + @staticmethod + def _parse_row(row: sqlite3.Row): + if row['type'] == 'bool': + return row['val'] == '1' + if row['type'] == 'int': + return int(row['val']) + if row['type'] == 'str': + return row['val'] + return json.loads(row['val']) + + def _execute(self, module: str, *args, **kwargs) -> sqlite3.Cursor: + pattern = r'^(core|custom)' + if not re.match(pattern, module): + raise ValueError(f'Invalid module name format: {module}') + + self._lock.acquire() + try: + cursor = self._conn.cursor() + return cursor.execute(*args, **kwargs) + except sqlite3.OperationalError as e: + if str(e).startswith('no such table'): + sql = f""" + CREATE TABLE IF NOT EXISTS '{module}' ( + var TEXT UNIQUE NOT NULL, + val TEXT NOT NULL, + type TEXT NOT NULL + ) + """ + cursor = self._conn.cursor() + cursor.execute(sql) + self._conn.commit() + return cursor.execute(*args, **kwargs) + raise e from None + finally: + self._lock.release() + + def get(self, module: str, variable: str, default=None): + sql = f"SELECT * FROM '{module}' WHERE var=?" + cur = self._execute(module, sql, (variable,)) + + row = cur.fetchone() + if row is None: + return default + return self._parse_row(row) + + def set(self, module: str, variable: str, value) -> bool: + sql = f""" + INSERT INTO '{module}' VALUES ( ?, ?, ? ) + ON CONFLICT (var) DO + UPDATE SET val=?, type=? WHERE var=? + """ + + if isinstance(value, bool): + val = '1' if value else '0' + typ = 'bool' + elif isinstance(value, str): + val = value + typ = 'str' + elif isinstance(value, int): + val = str(value) + typ = 'int' + else: + val = json.dumps(value) + typ = 'json' + + self._execute(module, sql, (variable, val, typ, val, typ, variable)) + self._conn.commit() + + return True + + def remove(self, module: str, variable: str): + sql = f"DELETE FROM '{module}' WHERE var=?" + self._execute(module, sql, (variable,)) + self._conn.commit() + + def get_collection(self, module: str) -> dict: + pattern = r'^(core|custom)' + if not re.match(pattern, module): + raise ValueError(f'Invalid module name format: {module}') + + sql = f"SELECT * FROM '{module}'" + cur = self._execute(module, sql) + + collection = {} + for row in cur: + collection[row['var']] = self._parse_row(row) + + return collection + + def close(self): + self._conn.commit() + self._conn.close() + + def add_chat_history(self, user_id, message): + chat_history = self.get_chat_history(user_id, default=[]) + chat_history.append(message) + self.set(f'core.cohere.user_{user_id}', 'chat_history', chat_history) + + def get_chat_history(self, user_id, default=None): + if default is None: + default = [] + return self.get(f'core.cohere.user_{user_id}', 'chat_history', default=[]) + + def addaiuser(self, user_id): + chatai_users = self.get('core.chatbot', 'chatai_users', default=[]) + if user_id not in chatai_users: + chatai_users.append(user_id) + self.set('core.chatbot', 'chatai_users', chatai_users) + + def remaiuser(self, user_id): + chatai_users = self.get('core.chatbot', 'chatai_users', default=[]) + if user_id in chatai_users: + chatai_users.remove(user_id) + self.set('core.chatbot', 'chatai_users', chatai_users) + + def getaiusers(self): + return self.get('core.chatbot', 'chatai_users', default=[]) + + +if config.db_type in ['mongo', 'mongodb']: + db = MongoDatabase(config.db_url, config.db_name) +else: + db = SqliteDatabase(config.db_name) diff --git a/utils/handlers.py b/utils/handlers.py new file mode 100644 index 0000000..99a73d5 --- /dev/null +++ b/utils/handlers.py @@ -0,0 +1,1172 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import re +from datetime import datetime, timedelta + +from pyrogram import Client +from pyrogram.enums import ChatType +from pyrogram.errors import ( + ChatAdminRequired, + PeerIdInvalid, + RPCError, + UserAdminInvalid, + UsernameInvalid, +) +from pyrogram.raw import functions, types +from pyrogram.types import ( + ChatPermissions, + ChatPrivileges, + InputMediaAudio, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + Message, +) +from pyrogram.utils import ( + MAX_CHANNEL_ID, + MAX_USER_ID, + MIN_CHANNEL_ID, + MIN_CHAT_ID, + get_channel_id, +) + +from utils.db import db +from utils.misc import prefix +from utils.scripts import format_exc, text + + +async def check_username_or_id(data: str | int) -> str: + data = str(data) + if not data.isdigit() and data[0] == '-' and not data[1:].isdigit() or not data.isdigit() and data[0] != '-': + return 'channel' + peer_id = int(data) + if peer_id < 0: + if peer_id >= MIN_CHAT_ID: + return 'chat' + + if MIN_CHANNEL_ID <= peer_id < MAX_CHANNEL_ID: + return 'channel' + elif 0 < peer_id <= MAX_USER_ID: + return 'user' + + raise ValueError(f'Peer id invalid: {peer_id}') + + +async def get_user_and_name(message): + if message.reply_to_message.from_user: + return ( + message.reply_to_message.from_user.id, + message.reply_to_message.from_user.first_name, + ) + if message.reply_to_message.sender_chat: + return ( + message.reply_to_message.sender_chat.id, + message.reply_to_message.sender_chat.title, + ) + + +class BanHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.channel = None + self.user_id = None + self.name = None + + async def handle_ban(self): + if self.message.reply_to_message: + await self.handle_reply_ban() + elif not self.message.reply_to_message: + await self.handle_non_reply_ban() + else: + await self.message.edit('Unsupported') + + async def handle_reply_ban(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_ban, self.name = await get_user_and_name(self.message) + await self.ban_user(user_for_ban) + + async def handle_non_reply_ban(self): + if self.message.chat.type not in ['private', 'channel'] and len(self.cause.split()) > 1: + user_to_ban = await self.get_user_to_ban() + if user_to_ban: + self.name = user_to_ban.first_name if getattr(user_to_ban, 'first_name', None) else user_to_ban.title + await self.ban_user(user_to_ban.id) + + async def get_user_to_ban(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + async def ban_user(self, user_id): + try: + await self.client.ban_chat_member(self.message.chat.id, user_id) + self.channel = await self.client.resolve_peer(self.message.chat.id) + self.user_id = await self.client.resolve_peer(user_id) + await self.handle_additional_actions() + await self.edit_message() + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + + async def handle_additional_actions(self): + if 'report_spam' in self.cause.lower().split(): + await self.client.invoke( + functions.channels.ReportSpam( + channel=self.channel, + participant=self.user_id, + id=[self.message.reply_to_message.id], + ) + ) + if 'delete_history' in self.cause.lower().split(): + await self.client.invoke( + functions.channels.DeleteParticipantHistory(channel=self.channel, participant=self.user_id) + ) + + async def edit_message(self): + text_c = ''.join(f' {_}' for _ in self.cause.split() if _.lower() not in ['delete_history', 'report_spam']) + await self.message.edit( + f'{self.name} banned!' + + f'\n{"Cause: " + text_c.split(maxsplit=1)[1] + "" if len(text_c.split()) > 1 else ""}' + ) + + +class UnbanHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.channel = None + self.user_id = None + self.name = None + + async def handle_unban(self): + if self.message.reply_to_message: + await self.handle_reply_unban() + elif not self.message.reply_to_message: + await self.handle_non_reply_unban() + else: + await self.message.edit('Unsupported') + + async def handle_reply_unban(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_unban, self.name = await get_user_and_name(self.message) + await self.unban_user(user_for_unban) + + async def handle_non_reply_unban(self): + if self.message.chat.type not in ['private', 'channel'] and len(self.cause.split()) > 1: + user_to_unban = await self.get_user_to_unban() + if user_to_unban: + self.name = ( + user_to_unban.first_name if getattr(user_to_unban, 'first_name', None) else user_to_unban.title + ) + await self.unban_user(user_to_unban.id) + + async def get_user_to_unban(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + async def unban_user(self, user_id): + try: + await self.client.unban_chat_member(self.message.chat.id, user_id) + self.channel = await self.client.resolve_peer(self.message.chat.id) + self.user_id = await self.client.resolve_peer(user_id) + await self.edit_message() + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + + async def edit_message(self): + text_c = ''.join(f' {_}' for _ in self.cause.split() if _.lower() not in ['delete_history', 'report_spam']) + await self.message.edit( + f'{self.name} unbanned!' + + f'\n{"Cause: " + text_c.split(maxsplit=1)[1] + "" if len(text_c.split()) > 1 else ""}' + ) + + +class KickHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.channel = None + self.user_id = None + self.name = None + + async def handle_kick(self): + if self.message.reply_to_message: + await self.handle_reply_kick() + elif not self.message.reply_to_message: + await self.handle_non_reply_kick() + else: + await self.message.edit('Unsupported') + + async def handle_reply_kick(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if self.message.reply_to_message.from_user: + self.name = self.message.reply_to_message.from_user.first_name + await self.kick_user(self.message.reply_to_message.from_user.id) + else: + await self.message.edit('Reply on user msg') + + async def handle_non_reply_kick(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if len(self.cause.split()) > 1: + user_to_kick = await self.get_user_to_kick() + if user_to_kick: + self.name = ( + user_to_kick.first_name if getattr(user_to_kick, 'first_name', None) else user_to_kick.title + ) + await self.kick_user(user_to_kick.id) + else: + await self.message.edit('user_id or username') + + async def get_user_to_kick(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + async def kick_user(self, user_id): + try: + await self.client.ban_chat_member( + self.message.chat.id, + user_id, + datetime.now() + timedelta(minutes=1), + ) + self.channel = await self.client.resolve_peer(self.message.chat.id) + self.user_id = await self.client.resolve_peer(user_id) + await self.handle_additional_actions() + await self.client.unban_chat_member(self.message.chat.id, user_id) + await self.edit_message() + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + + async def handle_additional_actions(self): + if 'report_spam' in self.cause.lower().split(): + await self.client.invoke( + functions.channels.ReportSpam( + channel=self.channel, + participant=self.user_id, + id=[self.message.reply_to_message.id], + ) + ) + if 'delete_history' in self.cause.lower().split(): + await self.client.invoke( + functions.channels.DeleteParticipantHistory(channel=self.channel, participant=self.user_id) + ) + + async def edit_message(self): + text_c = ''.join(f' {_}' for _ in self.cause.split() if _.lower() not in ['delete_history', 'report_spam']) + await self.message.edit( + f'{self.name} kicked!' + + f'\n{"Cause: " + text_c.split(maxsplit=1)[1] + "" if len(text_c.split()) > 1 else ""}' + ) + + +class KickDeletedAccountsHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.chat_id = message.chat.id + self.kicked_count = 0 + + async def kick_deleted_accounts(self): + await self.message.edit('Kicking deleted accounts...') + try: + async for member in self.client.get_chat_members(self.chat_id): + if member.user.is_deleted: + await self.kick_member(member.user.id) + self.kicked_count += 1 + except Exception as e: + return await self.message.edit(format_exc(e)) + await self.message.edit( + f'Successfully kicked {self.kicked_count} deleted account(s)', + ) + + async def kick_member(self, user_id): + try: + await self.client.ban_chat_member(self.chat_id, user_id, datetime.now() + timedelta(seconds=31)) + except Exception as e: + await self.message.edit(f'Failed to kick user {user_id}: {format_exc(e)}') + + +class TimeMuteHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.chat_id = message.chat.id + self.tmuted_users = db.get('core.ats', f'c{self.chat_id}', []) + + async def handle_tmute(self): + if self.message.reply_to_message: + await self.handle_reply_tmute() + elif not self.message.reply_to_message: + await self.handle_non_reply_tmute() + else: + await self.message.edit('Unsupported') + + async def handle_reply_tmute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_tmute, name = await get_user_and_name(self.message) + if user_for_tmute in self.tmuted_users: + await self.message.edit(f'{name} already in tmute') + else: + self.tmuted_users.append(user_for_tmute) + db.set('core.ats', f'c{self.chat_id}', self.tmuted_users) + await self.message.edit( + f'{name} in tmute' + + f'\n{"Cause: " + self.cause.split(maxsplit=1)[1] + "" if len(self.cause.split()) > 1 else ""}', + ) + + async def handle_non_reply_tmute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if len(self.cause.split()) > 1: + user_to_tmute = await self.get_user_to_tmute() + if user_to_tmute: + name = ( + user_to_tmute.first_name if getattr(user_to_tmute, 'first_name', None) else user_to_tmute.title + ) + if user_to_tmute.id not in self.tmuted_users: + self.tmuted_users.append(user_to_tmute.id) + db.set('core.ats', f'c{self.chat_id}', self.tmuted_users) + await self.message.edit( + f'{name} in tmute' + + f'\n{"Cause: " + self.cause.split(maxsplit=2)[2] + "" if len(self.cause.split()) > 2 else ""}', + ) + else: + await self.message.edit( + f'{name} already in tmute', + ) + else: + await self.message.edit('user_id or username') + + async def get_user_to_tmute(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + +class TimeUnmuteHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.chat_id = message.chat.id + self.tmuted_users = db.get('core.ats', f'c{self.chat_id}', []) + + async def handle_tunmute(self): + if self.message.reply_to_message: + await self.handle_reply_tunmute() + elif not self.message.reply_to_message: + await self.handle_non_reply_tunmute() + else: + await self.message.edit('Unsupported') + + async def handle_reply_tunmute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_tunmute, name = await get_user_and_name(self.message) + if user_for_tunmute not in self.tmuted_users: + await self.message.edit(f'{name} not in tmute') + else: + self.tmuted_users.remove(user_for_tunmute) + db.set('core.ats', f'c{self.chat_id}', self.tmuted_users) + await self.message.edit( + f'{name} tunmuted' + + f'\n{"Cause: " + self.cause.split(maxsplit=1)[1] + "" if len(self.cause.split()) > 1 else ""}', + ) + + async def handle_non_reply_tunmute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if len(self.cause.split()) > 1: + user_to_tunmute = await self.get_user_to_tunmute() + if user_to_tunmute: + name = ( + user_to_tunmute.first_name + if getattr(user_to_tunmute, 'first_name', None) + else user_to_tunmute.title + ) + if user_to_tunmute.id not in self.tmuted_users: + await self.message.edit( + f'{name} not in tmute', + ) + else: + self.tmuted_users.remove(user_to_tunmute.id) + db.set('core.ats', f'c{self.chat_id}', self.tmuted_users) + await self.message.edit( + f'{name} tunmuted' + + f'\n{"Cause: " + self.cause.split(maxsplit=2)[2] + "" if len(self.cause.split()) > 2 else ""}', + ) + else: + await self.message.edit('user_id or username') + + async def get_user_to_tunmute(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + +class TimeMuteUsersHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.chat_id = message.chat.id + self.tmuted_users = db.get('core.ats', f'c{self.chat_id}', []) + + async def list_tmuted_users(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + text = f'All users {self.message.chat.title} who are now in tmute\n\n' + count = 0 + for user in self.tmuted_users: + try: + name = await self.get_user_name(user) + if name: + count += 1 + text += f'{count}. {name}\n' + except PeerIdInvalid: + pass + if count == 0: + await self.message.edit('No users in tmute') + else: + text += f'\nTotal users in tmute {count}' + await self.message.edit(text) + else: + await self.message.edit('Unsupported') + + async def get_user_name(self, user_id): + try: + _name_ = await self.client.get_chat(user_id) + if await check_username_or_id(_name_.id) == 'channel': + channel = await self.client.invoke( + functions.channels.GetChannels( + id=[ + types.InputChannel( + channel_id=get_channel_id(_name_.id), + access_hash=0, + ) + ] + ) + ) + return channel.chats[0].title + if await check_username_or_id(_name_.id) == 'user': + user = await self.client.get_users(_name_.id) + return user.first_name + except PeerIdInvalid: + return None + + +class UnmuteHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.chat_id = message.chat.id + self.permissions = message.chat.permissions + + async def handle_unmute(self): + if self.message.reply_to_message: + await self.handle_reply_unmute() + elif not self.message.reply_to_message: + await self.handle_non_reply_unmute() + else: + await self.message.edit('Unsupported') + + async def handle_reply_unmute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_unmute = self.message.reply_to_message.from_user + if user_for_unmute: + try: + await self.unmute_user(user_for_unmute.id) + await self.message.edit( + f'{user_for_unmute.first_name} unmuted' + + f'\n{"Cause: " + self.cause.split(" ", maxsplit=1)[1] + "" if len(self.cause.split()) > 1 else ""}' + ) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('Reply on user msg') + + async def handle_non_reply_unmute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if len(self.cause.split()) > 1: + user_to_unmute = await self.get_user_to_unmute() + if user_to_unmute: + try: + await self.unmute_user(user_to_unmute.id) + await self.message.edit( + f'{user_to_unmute.first_name} unmuted!' + + f'\n{"Cause: " + self.cause.split(" ", maxsplit=2)[2] + "" if len(self.cause.split()) > 2 else ""}' + ) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('User is not found') + else: + await self.message.edit('user_id or username') + + async def get_user_to_unmute(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + async def unmute_user(self, user_id): + try: + await self.client.restrict_chat_member( + self.chat_id, + user_id, + self.permissions, + datetime.now() + timedelta(seconds=30), + ) + except Exception as e: + await self.message.edit(format_exc(e)) + + +class MuteHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.chat_id = message.chat.id + + async def handle_mute(self): + if self.message.reply_to_message: + await self.handle_reply_mute() + elif not self.message.reply_to_message: + await self.handle_non_reply_mute() + else: + await self.message.edit('Unsupported') + + async def handle_reply_mute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_mute = self.message.reply_to_message.from_user + if user_for_mute: + mute_seconds = self.calculate_mute_seconds() + try: + await self.mute_user(user_for_mute.id, mute_seconds) + await self.message.edit(self.construct_mute_message(user_for_mute, mute_seconds)) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('Reply on user msg') + + async def handle_non_reply_mute(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if len(self.cause.split()) > 1: + user_to_mute = await self.get_user_to_mute() + if user_to_mute: + mute_seconds = self.calculate_mute_seconds() + try: + await self.mute_user(user_to_mute.id, mute_seconds) + await self.message.edit(self.construct_mute_message(user_to_mute, mute_seconds)) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('User is not found') + else: + await self.message.edit('user_id or username') + + async def get_user_to_mute(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + def calculate_mute_seconds(self): + mute_seconds: int = 0 + for character in 'mhdw': + match = re.search(rf'(\d+|(\d+\.\d+)){character}', self.message.text) + if match: + value = float(match.string[match.start() : match.end() - 1]) + if character == 'm': + mute_seconds += int(value * 60) + if character == 'h': + mute_seconds += int(value * 3600) + if character == 'd': + mute_seconds += int(value * 86400) + if character == 'w': + mute_seconds += int(value * 604800) + return mute_seconds + + async def mute_user(self, user_id, mute_seconds): + try: + if mute_seconds > 30: + await self.client.restrict_chat_member( + self.chat_id, + user_id, + ChatPermissions(), + datetime.now() + timedelta(seconds=mute_seconds), + ) + else: + await self.client.restrict_chat_member( + self.chat_id, + user_id, + ChatPermissions(), + ) + except Exception as e: + await self.message.edit(format_exc(e)) + + def construct_mute_message(self, user, mute_seconds): + mute_time: dict[str, int] = { + 'days': mute_seconds // 86400, + 'hours': mute_seconds % 86400 // 3600, + 'minutes': mute_seconds % 86400 % 3600 // 60, + } + message_text = ( + f'{user.first_name} was muted for' + f' {((str(mute_time["days"]) + " day") if mute_time["days"] > 0 else "") + ("s" if mute_time["days"] > 1 else "")}' + f' {((str(mute_time["hours"]) + " hour") if mute_time["hours"] > 0 else "") + ("s" if mute_time["hours"] > 1 else "")}' + f' {((str(mute_time["minutes"]) + " minute") if mute_time["minutes"] > 0 else "") + ("s" if mute_time["minutes"] > 1 else "")}' + + f'\n{"Cause: " + self.cause.split(" ", maxsplit=2)[2] + "" if len(self.cause.split()) > 2 else ""}' + ) + message_text = ' '.join(message_text.split()) + return message_text + + +class DemoteHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.chat_id = message.chat.id + self.common_privileges_demote = { + 'is_anonymous': False, + 'can_manage_chat': False, + 'can_change_info': False, + 'can_post_messages': False, + 'can_edit_messages': False, + 'can_delete_messages': False, + 'can_manage_video_chats': False, + 'can_restrict_members': False, + 'can_invite_users': False, + 'can_pin_messages': False, + 'can_promote_members': False, + } + + async def handle_demote(self): + if self.message.reply_to_message: + await self.handle_reply_demote() + elif not self.message.reply_to_message: + await self.handle_non_reply_demote() + else: + await self.message.edit('Unsupported') + + async def handle_reply_demote(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_demote = self.message.reply_to_message.from_user + if user_for_demote: + try: + await self.demote_user(user_for_demote.id) + await self.message.edit(self.construct_demote_message(user_for_demote)) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('Reply on user msg') + + async def handle_non_reply_demote(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if len(self.cause.split()) > 1: + user_to_demote = await self.get_user_to_demote() + if user_to_demote: + try: + await self.demote_user(user_to_demote.id) + await self.message.edit(self.construct_demote_message(user_to_demote)) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('User is not found') + else: + await self.message.edit('user_id or username not provided!') + + async def get_user_to_demote(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + async def demote_user(self, user_id): + try: + await self.client.promote_chat_member( + self.chat_id, + user_id, + privileges=ChatPrivileges(**self.common_privileges_demote), + ) + except UserAdminInvalid: + raise # noqa: B904 + except ChatAdminRequired: + raise # noqa: B904 + except Exception as e: + await self.message.edit(format_exc(e)) + + def construct_demote_message(self, user): + return ( + f'{user.first_name} demoted!' + + f'\n{"Cause: " + self.cause.split(" ", maxsplit=2)[2] + "" if len(self.cause.split()) > 2 else ""}' + ) + + +class PromoteHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.chat_id = message.chat.id + self.common_privileges_promote = { + 'can_delete_messages': True, + 'can_restrict_members': True, + 'can_invite_users': True, + 'can_pin_messages': True, + } + + async def handle_promote(self): + if self.message.reply_to_message: + await self.handle_reply_promote() + elif not self.message.reply_to_message: + await self.handle_non_reply_promote() + else: + await self.message.edit('Unsupported') + + async def handle_reply_promote(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + user_for_promote = self.message.reply_to_message.from_user + promote_title = self.message.text.split(maxsplit=1)[1] if len(self.message.text.split()) > 1 else None + if promote_title and len(promote_title) > 16: + promote_title = promote_title[:16] + if user_for_promote: + try: + await self.promote_user(user_for_promote.id, promote_title) + await self.message.edit(self.construct_promote_message(user_for_promote)) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('Reply on user msg') + + async def handle_non_reply_promote(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if len(self.cause.split()) > 1: + user_to_promote = await self.get_user_to_promote() + promote_title = ' '.join(self.cause.split(' ')[2:]) if len(self.cause.split(' ')) > 2 else None + if promote_title and len(promote_title) > 16: + promote_title = promote_title[:16] + if user_to_promote: + try: + await self.promote_user(user_to_promote.id, promote_title) + await self.message.edit(self.construct_promote_message(user_to_promote)) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('User is not found') + else: + await self.message.edit('user_id or username not provided!') + + async def get_user_to_promote(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + async def promote_user(self, user_id, title): + try: + await self.client.promote_chat_member( + self.chat_id, + user_id, + privileges=ChatPrivileges(**self.common_privileges_promote), + title=title, + ) + if len(self.cause.split()) > 1 and self.message.chat.type == 'group': + await self.client.set_administrator_title( + self.chat_id, + user_id, + self.cause.split(maxsplit=1)[1], + ) + except UserAdminInvalid: + raise # noqa: B904 + except ChatAdminRequired: + raise # noqa: B904 + except Exception as e: + await self.message.edit(format_exc(e)) + + def construct_promote_message(self, user): + return ( + f'{user.first_name} promoted!' + + f'\n{"Title: " + self.cause.split(" ", maxsplit=1)[1] + "" if len(self.cause.split()) > 1 else ""}' + ) + + +class AntiChannelsHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.chat_id = message.chat.id + self.prefix = prefix + + async def handle_anti_channels(self): + if self.message.chat.type not in [ChatType.GROUP, ChatType.SUPERGROUP]: + await self.message.edit('Not supported in non-group chats') + return + + command = self.message.command + if len(command) == 1: + await self.toggle_anti_channels_status() + elif command[1] in ['enable', 'on', '1', 'yes', 'true']: + await self.enable_anti_channels() + elif command[1] in ['disable', 'off', '0', 'no', 'false']: + await self.disable_anti_channels() + else: + await self.message.edit(f'Usage: {self.prefix}antich [enable|disable]') + + async def toggle_anti_channels_status(self): + current_status = db.get('core.ats', f'antich{self.chat_id}', False) + new_status = not current_status + db.set('core.ats', f'antich{self.chat_id}', new_status) + if new_status: + await self.message.edit('Blocking channels in this chat enabled.') + else: + await self.message.edit('Blocking channels in this chat disabled.') + + async def enable_anti_channels(self): + db.set('core.ats', f'antich{self.chat_id}', True) + group = await self.client.get_chat(self.chat_id) + if group.linked_chat: + db.set('core.ats', f'linked{self.chat_id}', group.linked_chat.id) + else: + db.set('core.ats', f'linked{self.chat_id}', 0) + await self.message.edit('Blocking channels in this chat enabled.') + + async def disable_anti_channels(self): + db.set('core.ats', f'antich{self.chat_id}', False) + await self.message.edit('Blocking channels in this chat disabled.') + + +class DeleteHistoryHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.cause = text(message) + self.chat_id = message.chat.id + self.prefix = prefix + + async def handle_delete_history(self): + if self.message.chat.type not in [ChatType.PRIVATE, ChatType.CHANNEL]: + if self.message.reply_to_message: + await self.handle_reply_delete_history() + elif not self.message.reply_to_message: + await self.handle_non_reply_delete_history() + else: + await self.message.edit('Unsupported') + + async def handle_reply_delete_history(self): + if self.message.reply_to_message.from_user: + try: + user_for_delete, name = await get_user_and_name(self.message) + await self.delete_user_history(user_for_delete, name) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + else: + await self.message.edit('Reply on user msg') + + async def handle_non_reply_delete_history(self): + if len(self.cause.split()) > 1: + try: + user_to_delete = await self.get_user_to_delete() + if user_to_delete: + name = ( + user_to_delete.first_name + if getattr(user_to_delete, 'first_name', None) + else user_to_delete.title + ) + await self.delete_user_history(user_to_delete.id, name) + else: + await self.message.edit('User is not found') + except PeerIdInvalid: + await self.message.edit('User is not found') + except UsernameInvalid: + await self.message.edit('User is not found') + except IndexError: + await self.message.edit('User is not found') + else: + await self.message.edit('user_id or username') + + async def get_user_to_delete(self): + user_type = await check_username_or_id(self.cause.split(' ')[1]) + if user_type == 'channel': + return await self.client.get_chat(self.cause.split(' ')[1]) + if user_type == 'user': + return await self.client.get_users(self.cause.split(' ')[1]) + await self.message.edit('Invalid user type') + return None + + async def delete_user_history(self, user_id, name): + try: + channel = await self.client.resolve_peer(self.chat_id) + user_id = await self.client.resolve_peer(user_id) + await self.client.invoke(functions.channels.DeleteParticipantHistory(channel=channel, participant=user_id)) + await self.message.edit( + f'History from {name} was deleted!' + + f'\n{"Cause: " + self.cause.split(" ", maxsplit=1)[1] + "" if len(self.cause.split()) > 1 else ""}' + ) + except UserAdminInvalid: + await self.message.edit('No rights') + except ChatAdminRequired: + await self.message.edit('No rights') + except Exception as e: + await self.message.edit(format_exc(e)) + + +class AntiRaidHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.chat_id = message.chat.id + self.prefix = prefix + + async def handle_antiraid(self): + command = self.message.command + if len(command) > 1: + if command[1] == 'on': + await self.enable_antiraid() + elif command[1] == 'off': + await self.disable_antiraid() + else: + await self.toggle_antiraid() + + async def enable_antiraid(self): + db.set('core.ats', f'antiraid{self.chat_id}', True) + group = await self.client.get_chat(self.chat_id) + if group.linked_chat: + db.set('core.ats', f'linked{self.chat_id}', group.linked_chat.id) + else: + db.set('core.ats', f'linked{self.chat_id}', 0) + await self.message.edit(f'Anti-raid mode enabled!\nDisable with: {self.prefix}antiraid off') + + async def disable_antiraid(self): + db.set('core.ats', f'antiraid{self.chat_id}', False) + await self.message.edit('Anti-raid mode disabled') + + async def toggle_antiraid(self): + current_status = db.get('core.ats', f'antiraid{self.chat_id}', False) + new_status = not current_status + db.set('core.ats', f'antiraid{self.chat_id}', new_status) + if new_status: + group = await self.client.get_chat(self.chat_id) + if group.linked_chat: + db.set('core.ats', f'linked{self.chat_id}', group.linked_chat.id) + else: + db.set('core.ats', f'linked{self.chat_id}', 0) + await self.message.edit( + f'Anti-raid mode enabled!\nDisable with: {self.prefix}antiraid off' + ) + else: + await self.message.edit('Anti-raid mode disabled') + + +class NoteSendHandler: + def __init__(self, client: Client, message: Message): + self.client = client + self.message = message + self.chat_id = message.chat.id + self.prefix = prefix + + async def handle_note_send(self): + if len(self.message.text.split()) >= 2: + await self.message.edit('Loading...') + + note_name = self.message.text.split(maxsplit=1)[1] + find_note = db.get('core.notes', f'note{note_name}', False) + if find_note: + try: + await self.send_note(find_note) + except RPCError: + await self.message.edit( + 'Sorry, but this note is unavailable.\n\n' + f'You can delete this note with ' + f'{self.prefix}clear {note_name}' + ) + else: + await self.message.edit('There is no such note') + else: + await self.message.edit( + f'Example: {self.prefix}note note_name', + ) + + async def send_note(self, find_note): + if find_note.get('MEDIA_GROUP'): + await self.message.delete() + await self.send_media_group(find_note) + else: + await self.message.delete() + await self.copy_message(find_note) + + async def send_media_group(self, find_note): + messages_grouped = await self.client.get_media_group(int(find_note['CHAT_ID']), int(find_note['MESSAGE_ID'])) + media_grouped_list = self.prepare_media_group(messages_grouped) + if self.message.reply_to_message: + await self.client.send_media_group( + self.message.chat.id, + media_grouped_list, + reply_to_message_id=self.message.reply_to_message.id, + ) + else: + await self.client.send_media_group(self.message.chat.id, media_grouped_list) + + async def copy_message(self, find_note): + if self.message.reply_to_message: + await self.client.copy_message( + self.message.chat.id, + int(find_note['CHAT_ID']), + int(find_note['MESSAGE_ID']), + reply_to_message_id=self.message.reply_to_message.id, + ) + else: + await self.client.copy_message( + self.message.chat.id, + int(find_note['CHAT_ID']), + int(find_note['MESSAGE_ID']), + ) + + def prepare_media_group(self, messages_grouped): + media_grouped_list = [] + for _ in messages_grouped: + if _.photo: + media_grouped_list.append(self.prepare_photo(_)) + elif _.video: + media_grouped_list.append(self.prepare_video(_)) + elif _.audio: + media_grouped_list.append(self.prepare_audio(_)) + elif _.document: + media_grouped_list.append(self.prepare_document(_)) + return media_grouped_list + + @staticmethod + def prepare_photo(message): + if message.caption: + return InputMediaPhoto(message.photo.file_id, message.caption.markdown) + return InputMediaPhoto(message.photo.file_id) + + @staticmethod + def prepare_video(message): + if message.caption: + if message.video.thumbs: + return InputMediaVideo( + message.video.file_id, + message.video.thumbs[0].file_id, + message.caption.markdown, + ) + return InputMediaVideo(message.video.file_id, message.caption.markdown) + if message.video.thumbs: + return InputMediaVideo(message.video.file_id, message.video.thumbs[0].file_id) + return InputMediaVideo(message.video.file_id) + + @staticmethod + def prepare_audio(message): + if message.caption: + return InputMediaAudio(message.audio.file_id, message.caption.markdown) + return InputMediaAudio(message.audio.file_id) + + @staticmethod + def prepare_document(message): + if message.caption: + if message.document.thumbs: + return InputMediaDocument( + message.document.file_id, + message.document.thumbs[0].file_id, + message.caption.markdown, + ) + return InputMediaDocument(message.document.file_id, message.caption.markdown) + if message.document.thumbs: + return InputMediaDocument(message.document.file_id, message.document.thumbs[0].file_id) + return InputMediaDocument(message.document.file_id) diff --git a/utils/misc.py b/utils/misc.py new file mode 100644 index 0000000..9d346cb --- /dev/null +++ b/utils/misc.py @@ -0,0 +1,33 @@ +from sys import version_info + +import git + +from .db import db + +__all__ = [ + 'modules_help', + 'requirements_list', + 'python_version', + 'prefix', + 'gitrepo', + 'userbot_version', +] + + +modules_help = {} +requirements_list = [] + +python_version = f'{version_info[0]}.{version_info[1]}.{version_info[2]}' + +prefix = db.get('core.main', 'prefix', '.') + +try: + gitrepo = git.Repo('.') +except (git.exc.InvalidGitRepositoryError, git.exc.NoSuchPathError): + gitrepo = None + +if gitrepo is not None and len(gitrepo.tags) > 0: + commits_since_tag = list(gitrepo.iter_commits(f'{gitrepo.tags[-1].name}..HEAD')) + userbot_version = f'2.5.{len(commits_since_tag)}' +else: + userbot_version = '2.5.0' diff --git a/utils/module.py b/utils/module.py new file mode 100644 index 0000000..61bfb9c --- /dev/null +++ b/utils/module.py @@ -0,0 +1,76 @@ +import logging +from pathlib import Path +from typing import Optional + +from pyrogram import Client + +from utils.misc import modules_help +from utils.scripts import load_module + + +class ModuleManager: + _instance: Optional['ModuleManager'] = None + + def __init__(self): + self.success_modules = 0 + self.failed_modules = 0 + self.help_navigator = None + + @classmethod + def get_instance(cls) -> 'ModuleManager': + if cls._instance is None: + cls._instance = ModuleManager() + return cls._instance + + async def load_modules(self, app: Client): + """Load all modules and initialize help navigator""" + for path in Path('modules').rglob('*.py'): + try: + await load_module(path.stem, app, core='custom_modules' not in path.parent.parts) + except Exception: + logging.warning("Can't import module %s", path.stem, exc_info=True) + self.failed_modules += 1 + else: + self.success_modules += 1 + + logging.info('Imported %d modules', self.success_modules) + if self.failed_modules: + logging.warning('Failed to import %d modules', self.failed_modules) + + self.help_navigator = HelpNavigator() + return self.help_navigator + + +class HelpNavigator: + def __init__(self): + self.current_page = 1 + self.module_list = list(modules_help.keys()) + self.total_pages = (len(modules_help) + 9) // 10 + logging.info('Initialized HelpNavigator with %d modules', len(self.module_list)) + + async def send_page(self, message): + from utils.misc import prefix + + start_index = (self.current_page - 1) * 10 + end_index = start_index + 10 + page_modules = self.module_list[start_index:end_index] + text = 'Help\n' + text += f'For more help on how to use a command, type {prefix}help [module]\n\n' + text += f'Help Page No: {self.current_page}/{self.total_pages}\n\n' + for module_name in page_modules: + commands = modules_help[module_name] + text += f'• {module_name.title()}: {", ".join([f"{prefix + cmd_name.split()[0]}" for cmd_name in commands])}\n' + text += f'\nThe number of modules in the userbot: {len(modules_help)}' + await message.edit(text, disable_web_page_preview=True) + + def next_page(self) -> bool: + if self.current_page < self.total_pages: + self.current_page += 1 + return True + return False + + def prev_page(self) -> bool: + if self.current_page > 1: + self.current_page -= 1 + return True + return False diff --git a/utils/rentry.py b/utils/rentry.py new file mode 100644 index 0000000..32c6aae --- /dev/null +++ b/utils/rentry.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +# @source: https://github.com/radude/rentry/blob/master/rentry.py +import asyncio +import http.cookiejar +import urllib.parse +import urllib.request +from datetime import datetime, timedelta +from http.cookies import SimpleCookie +from json import loads as json_loads +from uuid import uuid4 + +from utils.db import db + +BASE_PROTOCOL = 'https://' +BASE_URL = 'rentry.co' + +_headers = {'Referer': f'{BASE_PROTOCOL}{BASE_URL}'} + + +class UrllibClient: + """Simple HTTP Session Client, keeps cookies.""" + + def __init__(self): + self.cookie_jar = http.cookiejar.CookieJar() + self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(self.cookie_jar)) + urllib.request.install_opener(self.opener) + + def get(self, url, headers=None): + if headers is None: + headers = {} + request = urllib.request.Request(url, headers=headers) # noqa: S310 + return self._request(request) + + def post(self, url, data=None, headers=None): + if headers is None: + headers = {} + postdata = urllib.parse.urlencode(data).encode() + request = urllib.request.Request(url, postdata, headers) # noqa: S310 + return self._request(request) + + def _request(self, request): + response = self.opener.open(request) + response.status_code = response.getcode() + response.data = response.read().decode('utf-8') + return response + + +def raw(url: str): + client = UrllibClient() + return json_loads(client.get(f'{BASE_PROTOCOL}{BASE_URL}/api/raw/{url}').data) + + +def new(text: str, edit_code: str = '', url: str = ''): + client, cookie = UrllibClient(), SimpleCookie() + + cookie.load(vars(client.get(f'{BASE_PROTOCOL}{BASE_URL}'))['headers']['Set-Cookie']) + csrftoken = cookie['csrftoken'].value + + payload = { + 'csrfmiddlewaretoken': csrftoken, + 'url': url, + 'edit_code': edit_code, + 'text': text, + } + + return json_loads(client.post(f'{BASE_PROTOCOL}{BASE_URL}' + '/api/new', payload, headers=_headers).data) + + +def edit(url_short: str, edit_code: str, text: str): + client, cookie = UrllibClient(), SimpleCookie() + + cookie.load(vars(client.get(f'{BASE_PROTOCOL}{BASE_URL}'))['headers']['Set-Cookie']) + csrftoken = cookie['csrftoken'].value + + payload = {'csrfmiddlewaretoken': csrftoken, 'edit_code': edit_code, 'text': text} + + return json_loads(client.post(f'{BASE_PROTOCOL}{BASE_URL}/api/edit/{url_short}', payload, headers=_headers).data) + + +def delete(url_short: str, edit_code: str): + client, cookie = UrllibClient(), SimpleCookie() + cookie.load(vars(client.get(f'{BASE_PROTOCOL}{BASE_URL}'))['headers']['Set-Cookie']) + csrftoken = cookie['csrftoken'].value + payload = {'csrfmiddlewaretoken': csrftoken, 'edit_code': edit_code} + return json_loads( + client.post( + f'{BASE_PROTOCOL}{BASE_URL}/api/delete/{url_short}', + payload, + headers=_headers, + ).data + ) + + +async def paste( + text: str, + return_edit: bool = False, + edit_bin: bool = False, + edit_code: str = None, + url: str = None, + permanent: bool = False, +) -> str | tuple[str, str]: + """Pastes some text to rentry bin. + args: + text: Input text to paste + return_edit: If it should return edit code also + edit_bin: If this request is to edit an already existing bin + edit_code: Only required if edit_bin is True. It is the edit code used to edit bin. + url: Only required if edit_bin is True. It is the url on which the bin is located. + permanent: If the pasted content should not be deleted automatically + + returns: + The url of the paste or return a tuple containing url and edit code + """ + if not str(text): + return + + if edit_bin: + if not (url and edit_code): + raise ValueError('Please provide both, url and edit code') + response = edit(url_short=url, edit_code=edit_code, text=text) + else: + response = new(text=text) + + if response.get('status') != '200': + raise RuntimeError( + f'paste task terminated with status: {response.get("status")}\n' + f'Message: {response.get("content", "No message provided")}' + ) + + url = response['url'] + edit_code = response['edit_code'] + + if not permanent: + short_url = response['url_short'] + time_now = datetime.now() + ftime = time_now.strftime('%d %I:%M:%S %p %Y') + + print(f'URL: {url} - Edit Code: {edit_code} - Time: {ftime}') + + rallUrls = db.get('core.rentry', 'urls', default={'allUrls': {}}) + entry_id = str(uuid4()) + rallUrls['allUrls'][entry_id] = { + 'url': short_url, + 'edit_code': edit_code, + 'time': ftime, + } + db.set('core.rentry', 'urls', rallUrls) + + if return_edit: + return (url, edit_code) + return url + + +async def rentry_cleanup_job(): + """Periodically checks and deletes rentry pastes older than 24 hours""" + while True: + try: + rallUrls = db.get('core.rentry', 'urls', default={'allUrls': {}}) + now = datetime.now() + deleted_count = 0 + error_count = 0 + + for entry_id, entry in list(rallUrls['allUrls'].items()): + url = entry['url'] + entry_time = datetime.strptime(entry['time'], '%d %I:%M:%S %p %Y') + + if now - entry_time > timedelta(days=1): + try: + delete(url, entry['edit_code']) + del rallUrls['allUrls'][entry_id] + deleted_count += 1 + print(f'[#] Deleted expired rentry paste: {url}') + except Exception as e: + error_count += 1 + print(f'[!] Failed to delete rentry paste {url}: {str(e)}') + + if deleted_count or error_count: + print(f'[*] Cleanup summary: {deleted_count} deleted, {error_count} failed') + + if deleted_count: + db.set('core.rentry', 'urls', rallUrls) + + except Exception as e: + print(f'[!] Error in rentry cleanup job: {str(e)}') + + await asyncio.sleep(12 * 60 * 60) diff --git a/utils/scripts.py b/utils/scripts.py new file mode 100644 index 0000000..d30cf55 --- /dev/null +++ b/utils/scripts.py @@ -0,0 +1,510 @@ +# Moon-Userbot - telegram userbot +# Copyright (C) 2020-present Moon Userbot Organization +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. + +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import asyncio +import importlib +import math +import os +import re +import shlex +import subprocess +import sys +import tempfile +import time +import traceback +from io import BytesIO +from types import ModuleType + +import psutil +from PIL import Image +from pyrogram import Client, errors, filters +from pyrogram.errors import FloodWait, MessageNotModified, UserNotParticipant +from pyrogram.types import Message + +from utils.db import db + +from .misc import modules_help, prefix, requirements_list + +META_COMMENTS = re.compile(r'^ *# *meta +(\S+) *: *(.*?)\s*$', re.MULTILINE) +interact_with_to_delete = [] + + +def time_formatter(milliseconds: int) -> str: + """Time Formatter""" + seconds, milliseconds = divmod(int(milliseconds), 1000) + minutes, seconds = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + days, hours = divmod(hours, 24) + tmp = ( + ((str(days) + ' day(s), ') if days else '') + + ((str(hours) + ' hour(s), ') if hours else '') + + ((str(minutes) + ' minute(s), ') if minutes else '') + + ((str(seconds) + ' second(s), ') if seconds else '') + + ((str(milliseconds) + ' millisecond(s), ') if milliseconds else '') + ) + return tmp[:-2] + + +def humanbytes(size): + """Convert Bytes To Bytes So That Human Can Read It""" + if not size: + return '' + power = 2**10 + raised_to_pow = 0 + dict_power_n = {0: '', 1: 'Ki', 2: 'Mi', 3: 'Gi', 4: 'Ti'} + while size > power: + size /= power + raised_to_pow += 1 + return str(round(size, 2)) + ' ' + dict_power_n[raised_to_pow] + 'B' + + +async def edit_or_send_as_file( + tex: str, + message: Message, + client: Client, + caption: str = 'Result!', + file_name: str = 'result', +): + """Send As File If Len Of Text Exceeds Tg Limit Else Edit Message""" + if not tex: + await message.edit('Wait, What?') + return + if len(tex) > 1024: + await message.edit('OutPut is Too Large, Sending As File!') + with tempfile.NamedTemporaryFile('w', delete=False, suffix='.txt', prefix=f'{file_name}_') as fn: + fn.write(tex) + temp_path = fn.name + try: + await client.send_document(message.chat.id, temp_path, caption=caption) + await message.delete() + finally: + if os.path.exists(temp_path): + os.remove(temp_path) + return + return await message.edit(tex) + + +def get_text(message: Message) -> None | str: + """Extract Text From Commands""" + text_to_return = message.text + if message.text is None: + return None + if ' ' in text_to_return: + try: + return message.text.split(None, 1)[1] + except IndexError: + return None + else: + return None + + +async def progress(current, total, message, start, type_of_ps, file_name=None): + """Progress Bar For Showing Progress While Uploading / Downloading File - Normal""" + now = time.time() + diff = now - start + if round(diff % 10.00) == 0 or current == total: + percentage = current * 100 / total + speed = current / diff + elapsed_time = round(diff) * 1000 + if elapsed_time == 0: + return + time_to_completion = round((total - current) / speed) * 1000 + estimated_total_time = elapsed_time + time_to_completion + progress_str = f'{"".join(["▰" for i in range(math.floor(percentage / 10))])}' + progress_str += f'{"".join(["▱" for i in range(10 - math.floor(percentage / 10))])}' + progress_str += f'{round(percentage, 2)}%\n' + tmp = f'{progress_str}{humanbytes(current)} of {humanbytes(total)}\n' + tmp += f'ETA: {time_formatter(estimated_total_time)}' + if file_name: + try: + await message.edit(f'{type_of_ps}\nFile Name: {file_name}\n{tmp}') + except FloodWait as e: + await asyncio.sleep(e.x) + except MessageNotModified: + pass + else: + try: + await message.edit(f'{type_of_ps}\n{tmp}') + except FloodWait as e: + await asyncio.sleep(e.x) + except MessageNotModified: + pass + + +async def run_cmd(prefix: str) -> tuple[str, str, int, int]: + """Run Commands""" + args = shlex.split(prefix) + process = await asyncio.create_subprocess_exec( + *args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + return ( + stdout.decode('utf-8', 'replace').strip(), + stderr.decode('utf-8', 'replace').strip(), + process.returncode, + process.pid, + ) + + +def mediainfo(media): + xx = str((str(media)).split('(', maxsplit=1)[0]) + m = '' + if xx == 'MessageMediaDocument': + mim = media.document.mime_type + if mim == 'application/x-tgsticker': + m = 'sticker animated' + elif 'image' in mim: + if mim == 'image/webp': + m = 'sticker' + elif mim == 'image/gif': + m = 'gif as doc' + else: + m = 'pic as doc' + elif 'video' in mim: + if 'DocumentAttributeAnimated' in str(media): + m = 'gif' + elif 'DocumentAttributeVideo' in str(media): + i = str(media.document.attributes[0]) + if 'supports_streaming=True' in i: + m = 'video' + m = 'video as doc' + else: + m = 'video' + elif 'audio' in mim: + m = 'audio' + else: + m = 'document' + elif xx == 'MessageMediaPhoto': + m = 'pic' + elif xx == 'MessageMediaWebPage': + m = 'web' + return m + + +async def edit_or_reply(message, txt): + """Edit Message If It's From Self, Else Reply To Message""" + if not message: + return + if message.from_user and message.from_user.is_self: + return await message.edit(txt) + return await message.reply(txt) + + +def text(message: Message) -> str: + """Find text in `Message` object""" + return message.text if message.text else message.caption + + +def restart() -> None: + music_bot_pid = db.get('custom.musicbot', 'music_bot_pid', None) + if music_bot_pid is not None: + try: + music_bot_process = psutil.Process(music_bot_pid) + music_bot_process.terminate() + except psutil.NoSuchProcess: + print('Music bot is not running.') + os.execvp(sys.executable, [sys.executable, 'main.py']) # skipcq # noqa: S606 + + +def format_exc(e: Exception, suffix='') -> str: + traceback.print_exc() + err = traceback.format_exc() + if isinstance(e, errors.RPCError): + return ( + f'Telegram API error!\n' + f'[{e.CODE} {e.ID or e.NAME}] — {e.MESSAGE.format(value=e.value)}\n\n{suffix}' + ) + return f'Error!\n{err}' + + +def with_reply(func): + async def wrapped(client: Client, message: Message): + if not message.reply_to_message: + await message.edit('Reply to message is required') + else: + return await func(client, message) + + return wrapped + + +def is_admin(func): + async def wrapped(client: Client, message: Message): + try: + chat_member = await client.get_chat_member(message.chat.id, message.from_user.id) + if chat_member.status in ('administrator', 'creator'): + return await func(client, message) + await message.edit('You need to be an admin to perform this action.') + except UserNotParticipant: + await message.edit('You need to be a participant in the chat to perform this action.') + + return wrapped + + +async def interact_with(message: Message) -> Message: + """ + Check history with bot and return bot's response + + Example: + .. code-block:: python + bot_msg = await interact_with(await bot.send_message("@BotFather", "/start")) + :param message: already sent message to bot + :return: bot's response + """ + + await asyncio.sleep(1) + # noinspection PyProtectedMember + response = [msg async for msg in message._client.get_chat_history(message.chat.id, limit=1)] + seconds_waiting = 0 + + while response[0].from_user.is_self: + seconds_waiting += 1 + if seconds_waiting >= 5: + raise RuntimeError("bot didn't answer in 5 seconds") + + await asyncio.sleep(1) + # noinspection PyProtectedMember + response = [msg async for msg in message._client.get_chat_history(message.chat.id, limit=1)] + + interact_with_to_delete.append(message.id) + interact_with_to_delete.append(response[0].id) + + return response[0] + + +def format_module_help(module_name: str, full=True): + commands = modules_help[module_name] + + help_text = f'Help for |{module_name}|\n\nUsage:\n' if full else 'Usage:\n' + + for command, desc in commands.items(): + cmd = command.split(maxsplit=1) + args = ' ' + cmd[1] + '' if len(cmd) > 1 else '' + help_text += f'{prefix}{cmd[0]}{args} — {desc}\n' + + return help_text + + +def format_small_module_help(module_name: str, full=True): + commands = modules_help[module_name] + + help_text = f'Help for |{module_name}|\n\nCommands list:\n' if full else 'Commands list:\n' + for command, _desc in commands.items(): + cmd = command.split(maxsplit=1) + args = ' ' + cmd[1] + '' if len(cmd) > 1 else '' + help_text += f'{prefix}{cmd[0]}{args}\n' + help_text += f'\nGet full usage: {prefix}help {module_name}' + + return help_text + + +def import_library(library_name: str, package_name: str = None): + """ + Loads a library, or installs it in ImportError case + :param library_name: library name (import example...) + :param package_name: package name in PyPi (pip install example) + :return: loaded module + """ + if package_name is None: + package_name = library_name + requirements_list.append(package_name) + + try: + return importlib.import_module(library_name) + except ImportError as exc: + completed = subprocess.run( # noqa: S603 + [sys.executable, '-m', 'pip', 'install', '--upgrade', package_name], + check=True, + ) + if completed.returncode != 0: + raise AssertionError( + f'Failed to install library {package_name} (pip exited with code {completed.returncode})' + ) from exc + return importlib.import_module(library_name) + + +def uninstall_library(package_name: str): + """ + Uninstalls a library + :param package_name: package name in PyPi (pip uninstall example) + """ + completed = subprocess.run([sys.executable, '-m', 'pip', 'uninstall', '-y', package_name], check=True) # noqa: S603 + if completed.returncode != 0: + raise AssertionError( + f'Failed to uninstall library {package_name} (pip exited with code {completed.returncode})' + ) + + +def resize_image(input_img, output=None, img_type='PNG', size: int = 512, size2: int = None): + if output is None: + output = BytesIO() + output.name = f'sticker.{img_type.lower()}' + + with Image.open(input_img) as img: + # We used to use thumbnail(size) here, but it returns with a *max* dimension of 512,512 + # rather than making one side exactly 512, so we have to calculate dimensions manually :( + if size2 is not None: + size = (size, size2) + elif img.width == img.height: + size = (size, size) + elif img.width < img.height: + size = (max(size * img.width // img.height, 1), size) + else: + size = (size, max(size * img.height // img.width, 1)) + + img.resize(size).save(output, img_type) + + return output + + +def resize_new_image(image_path, output_path, desired_width=None, desired_height=None): + """ + Resize an image to the desired dimensions while maintaining the aspect ratio. + + Args: + image_path (str): Path to the input image file. + output_path (str): Path to save the resized image. + desired_width (int, optional): Desired width in pixels. If not provided, the aspect ratio will be maintained. + desired_height (int, optional): Desired height in pixels. If not provided, the aspect ratio will be maintained. + """ + image = Image.open(image_path) + + width, height = image.size + + aspect_ratio = width / height + + if desired_width and desired_height: + new_width, new_height = desired_width, desired_height + elif desired_height: + new_width, new_height = int(desired_height * aspect_ratio), desired_height + else: + new_width, new_height = 150, 150 + + resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + + resized_image.save(output_path) + if os.path.exists(image_path): + os.remove(image_path) + + +async def load_module( + module_name: str, + client: Client, + message: Message = None, + core=False, +) -> ModuleType: + if module_name in modules_help and not core: + await unload_module(module_name, client) + + path = f'modules.{"custom_modules." if not core else ""}{module_name}' + + with open(f'{path.replace(".", "/")}.py', encoding='utf-8') as f: + code = f.read() + meta = parse_meta_comments(code) + + packages = meta.get('requires', '').split() + requirements_list.extend(packages) + + try: + module = importlib.import_module(path) + except ImportError as e: + if core: + # Core modules shouldn't raise ImportError + raise + + if not packages: + raise + + if message: + await message.edit(f'Installing requirements: {" ".join(packages)}') + + proc = await asyncio.create_subprocess_exec( + sys.executable, + '-m', + 'pip', + 'install', + '-U', + *packages, + ) + try: + await asyncio.wait_for(proc.wait(), timeout=120) + except TimeoutError: + if message: + await message.edit('Timeout while installed requirements.' + 'Try to install them manually') + raise TimeoutError('timeout while installing requirements') from e + + if proc.returncode != 0: + if message: + await message.edit( + f'Failed to install requirements (pip exited with code {proc.returncode}). ' + f'Check logs for futher info', + ) + raise RuntimeError('failed to install requirements') from e + + module = importlib.import_module(path) + + for _name, obj in vars(module).items(): + if isinstance(getattr(obj, 'handlers', []), list): + for handler, group in getattr(obj, 'handlers', []): + client.add_handler(handler, group) + + module.__meta__ = meta + + return module + + +async def unload_module(module_name: str, client: Client) -> bool: + path = 'modules.custom_modules.' + module_name + if path not in sys.modules: + return False + + module = importlib.import_module(path) + + for _name, obj in vars(module).items(): + for handler, group in getattr(obj, 'handlers', []): + client.remove_handler(handler, group) + + del modules_help[module_name] + del sys.modules[path] + + return True + + +def no_prefix(handler): + def func(_, __, message): + return bool(message.text and not message.text.startswith(handler)) + + return filters.create(func) + + +def parse_meta_comments(code: str) -> dict[str, str]: + try: + groups = META_COMMENTS.search(code).groups() + except AttributeError: + return {} + + return {groups[i]: groups[i + 1] for i in range(0, len(groups), 2)} + + +def reply_check(message: Message): + reply_id = None + + if message.reply_to_message: + reply_id = message.reply_to_message.id + + elif not message.from_user.is_self: + reply_id = message.id + + return reply_id diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..245870b --- /dev/null +++ b/uv.lock @@ -0,0 +1,1019 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/ce/3b83ebba6b3207a7135e5fcaba49706f8a4b6008153b4e30540c982fae26/aiohttp-3.13.2.tar.gz", hash = "sha256:40176a52c186aefef6eb3cad2cdd30cd06e3afbe88fe8ab2af9c0b90f228daca", size = 7837994, upload-time = "2025-10-28T20:59:39.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/78/7e90ca79e5aa39f9694dcfd74f4720782d3c6828113bb1f3197f7e7c4a56/aiohttp-3.13.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7519bdc7dfc1940d201651b52bf5e03f5503bda45ad6eacf64dda98be5b2b6be", size = 732139, upload-time = "2025-10-28T20:57:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/1f59215ab6853fbaa5c8495fa6cbc39edfc93553426152b75d82a5f32b76/aiohttp-3.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:088912a78b4d4f547a1f19c099d5a506df17eacec3c6f4375e2831ec1d995742", size = 490082, upload-time = "2025-10-28T20:57:04.784Z" }, + { url = "https://files.pythonhosted.org/packages/68/7b/fe0fe0f5e05e13629d893c760465173a15ad0039c0a5b0d0040995c8075e/aiohttp-3.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5276807b9de9092af38ed23ce120539ab0ac955547b38563a9ba4f5b07b95293", size = 489035, upload-time = "2025-10-28T20:57:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/d2/04/db5279e38471b7ac801d7d36a57d1230feeee130bbe2a74f72731b23c2b1/aiohttp-3.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1237c1375eaef0db4dcd7c2559f42e8af7b87ea7d295b118c60c36a6e61cb811", size = 1720387, upload-time = "2025-10-28T20:57:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/31/07/8ea4326bd7dae2bd59828f69d7fdc6e04523caa55e4a70f4a8725a7e4ed2/aiohttp-3.13.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:96581619c57419c3d7d78703d5b78c1e5e5fc0172d60f555bdebaced82ded19a", size = 1688314, upload-time = "2025-10-28T20:57:10.693Z" }, + { url = "https://files.pythonhosted.org/packages/48/ab/3d98007b5b87ffd519d065225438cc3b668b2f245572a8cb53da5dd2b1bc/aiohttp-3.13.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2713a95b47374169409d18103366de1050fe0ea73db358fc7a7acb2880422d4", size = 1756317, upload-time = "2025-10-28T20:57:12.563Z" }, + { url = "https://files.pythonhosted.org/packages/97/3d/801ca172b3d857fafb7b50c7c03f91b72b867a13abca982ed6b3081774ef/aiohttp-3.13.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:228a1cd556b3caca590e9511a89444925da87d35219a49ab5da0c36d2d943a6a", size = 1858539, upload-time = "2025-10-28T20:57:14.623Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0d/4764669bdf47bd472899b3d3db91fffbe925c8e3038ec591a2fd2ad6a14d/aiohttp-3.13.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac6cde5fba8d7d8c6ac963dbb0256a9854e9fafff52fbcc58fdf819357892c3e", size = 1739597, upload-time = "2025-10-28T20:57:16.399Z" }, + { url = "https://files.pythonhosted.org/packages/c4/52/7bd3c6693da58ba16e657eb904a5b6decfc48ecd06e9ac098591653b1566/aiohttp-3.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2bef8237544f4e42878c61cef4e2839fee6346dc60f5739f876a9c50be7fcdb", size = 1555006, upload-time = "2025-10-28T20:57:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/48/30/9586667acec5993b6f41d2ebcf96e97a1255a85f62f3c653110a5de4d346/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:16f15a4eac3bc2d76c45f7ebdd48a65d41b242eb6c31c2245463b40b34584ded", size = 1683220, upload-time = "2025-10-28T20:57:20.241Z" }, + { url = "https://files.pythonhosted.org/packages/71/01/3afe4c96854cfd7b30d78333852e8e851dceaec1c40fd00fec90c6402dd2/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:bb7fb776645af5cc58ab804c58d7eba545a97e047254a52ce89c157b5af6cd0b", size = 1712570, upload-time = "2025-10-28T20:57:22.253Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/22799d8e720f4697a9e66fd9c02479e40a49de3de2f0bbe7f9f78a987808/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e1b4951125ec10c70802f2cb09736c895861cd39fd9dcb35107b4dc8ae6220b8", size = 1733407, upload-time = "2025-10-28T20:57:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/34/cb/90f15dd029f07cebbd91f8238a8b363978b530cd128488085b5703683594/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:550bf765101ae721ee1d37d8095f47b1f220650f85fe1af37a90ce75bab89d04", size = 1550093, upload-time = "2025-10-28T20:57:26.257Z" }, + { url = "https://files.pythonhosted.org/packages/69/46/12dce9be9d3303ecbf4d30ad45a7683dc63d90733c2d9fe512be6716cd40/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fe91b87fc295973096251e2d25a811388e7d8adf3bd2b97ef6ae78bc4ac6c476", size = 1758084, upload-time = "2025-10-28T20:57:28.349Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/0932b558da0c302ffd639fc6362a313b98fdf235dc417bc2493da8394df7/aiohttp-3.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0c8e31cfcc4592cb200160344b2fb6ae0f9e4effe06c644b5a125d4ae5ebe23", size = 1716987, upload-time = "2025-10-28T20:57:30.233Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/f5bd1a75003daed099baec373aed678f2e9b34f2ad40d85baa1368556396/aiohttp-3.13.2-cp313-cp313-win32.whl", hash = "sha256:0740f31a60848d6edb296a0df827473eede90c689b8f9f2a4cdde74889eb2254", size = 425859, upload-time = "2025-10-28T20:57:32.105Z" }, + { url = "https://files.pythonhosted.org/packages/5d/28/a8a9fc6957b2cee8902414e41816b5ab5536ecf43c3b1843c10e82c559b2/aiohttp-3.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:a88d13e7ca367394908f8a276b89d04a3652044612b9a408a0bb22a5ed976a1a", size = 452192, upload-time = "2025-10-28T20:57:34.166Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/e2abae1bd815f01c957cbf7be817b3043304e1c87bad526292a0410fdcf9/aiohttp-3.13.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2475391c29230e063ef53a66669b7b691c9bfc3f1426a0f7bcdf1216bdbac38b", size = 735234, upload-time = "2025-10-28T20:57:36.415Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/1ee62dde9b335e4ed41db6bba02613295a0d5b41f74a783c142745a12763/aiohttp-3.13.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f33c8748abef4d8717bb20e8fb1b3e07c6adacb7fd6beaae971a764cf5f30d61", size = 490733, upload-time = "2025-10-28T20:57:38.205Z" }, + { url = "https://files.pythonhosted.org/packages/1a/aa/7a451b1d6a04e8d15a362af3e9b897de71d86feac3babf8894545d08d537/aiohttp-3.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae32f24bbfb7dbb485a24b30b1149e2f200be94777232aeadba3eecece4d0aa4", size = 491303, upload-time = "2025-10-28T20:57:40.122Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/209958dbb9b01174870f6a7538cd1f3f28274fdbc88a750c238e2c456295/aiohttp-3.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d7f02042c1f009ffb70067326ef183a047425bb2ff3bc434ead4dd4a4a66a2b", size = 1717965, upload-time = "2025-10-28T20:57:42.28Z" }, + { url = "https://files.pythonhosted.org/packages/08/aa/6a01848d6432f241416bc4866cae8dc03f05a5a884d2311280f6a09c73d6/aiohttp-3.13.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93655083005d71cd6c072cdab54c886e6570ad2c4592139c3fb967bfc19e4694", size = 1667221, upload-time = "2025-10-28T20:57:44.869Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/36c1992432d31bbc789fa0b93c768d2e9047ec8c7177e5cd84ea85155f36/aiohttp-3.13.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0db1e24b852f5f664cd728db140cf11ea0e82450471232a394b3d1a540b0f906", size = 1757178, upload-time = "2025-10-28T20:57:47.216Z" }, + { url = "https://files.pythonhosted.org/packages/ac/b4/8e940dfb03b7e0f68a82b88fd182b9be0a65cb3f35612fe38c038c3112cf/aiohttp-3.13.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b009194665bcd128e23eaddef362e745601afa4641930848af4c8559e88f18f9", size = 1838001, upload-time = "2025-10-28T20:57:49.337Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ef/39f3448795499c440ab66084a9db7d20ca7662e94305f175a80f5b7e0072/aiohttp-3.13.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c038a8fdc8103cd51dbd986ecdce141473ffd9775a7a8057a6ed9c3653478011", size = 1716325, upload-time = "2025-10-28T20:57:51.327Z" }, + { url = "https://files.pythonhosted.org/packages/d7/51/b311500ffc860b181c05d91c59a1313bdd05c82960fdd4035a15740d431e/aiohttp-3.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66bac29b95a00db411cd758fea0e4b9bdba6d549dfe333f9a945430f5f2cc5a6", size = 1547978, upload-time = "2025-10-28T20:57:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/31/64/b9d733296ef79815226dab8c586ff9e3df41c6aff2e16c06697b2d2e6775/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4ebf9cfc9ba24a74cf0718f04aac2a3bbe745902cc7c5ebc55c0f3b5777ef213", size = 1682042, upload-time = "2025-10-28T20:57:55.617Z" }, + { url = "https://files.pythonhosted.org/packages/3f/30/43d3e0f9d6473a6db7d472104c4eff4417b1e9df01774cb930338806d36b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a4b88ebe35ce54205c7074f7302bd08a4cb83256a3e0870c72d6f68a3aaf8e49", size = 1680085, upload-time = "2025-10-28T20:57:57.59Z" }, + { url = "https://files.pythonhosted.org/packages/16/51/c709f352c911b1864cfd1087577760ced64b3e5bee2aa88b8c0c8e2e4972/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:98c4fb90bb82b70a4ed79ca35f656f4281885be076f3f970ce315402b53099ae", size = 1728238, upload-time = "2025-10-28T20:57:59.525Z" }, + { url = "https://files.pythonhosted.org/packages/19/e2/19bd4c547092b773caeb48ff5ae4b1ae86756a0ee76c16727fcfd281404b/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:ec7534e63ae0f3759df3a1ed4fa6bc8f75082a924b590619c0dd2f76d7043caa", size = 1544395, upload-time = "2025-10-28T20:58:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/cf/87/860f2803b27dfc5ed7be532832a3498e4919da61299b4a1f8eb89b8ff44d/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5b927cf9b935a13e33644cbed6c8c4b2d0f25b713d838743f8fe7191b33829c4", size = 1742965, upload-time = "2025-10-28T20:58:03.972Z" }, + { url = "https://files.pythonhosted.org/packages/67/7f/db2fc7618925e8c7a601094d5cbe539f732df4fb570740be88ed9e40e99a/aiohttp-3.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:88d6c017966a78c5265d996c19cdb79235be5e6412268d7e2ce7dee339471b7a", size = 1697585, upload-time = "2025-10-28T20:58:06.189Z" }, + { url = "https://files.pythonhosted.org/packages/0c/07/9127916cb09bb38284db5036036042b7b2c514c8ebaeee79da550c43a6d6/aiohttp-3.13.2-cp314-cp314-win32.whl", hash = "sha256:f7c183e786e299b5d6c49fb43a769f8eb8e04a2726a2bd5887b98b5cc2d67940", size = 431621, upload-time = "2025-10-28T20:58:08.636Z" }, + { url = "https://files.pythonhosted.org/packages/fb/41/554a8a380df6d3a2bba8a7726429a23f4ac62aaf38de43bb6d6cde7b4d4d/aiohttp-3.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:fe242cd381e0fb65758faf5ad96c2e460df6ee5b2de1072fe97e4127927e00b4", size = 457627, upload-time = "2025-10-28T20:58:11Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8e/3824ef98c039d3951cb65b9205a96dd2b20f22241ee17d89c5701557c826/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f10d9c0b0188fe85398c61147bbd2a657d616c876863bfeff43376e0e3134673", size = 767360, upload-time = "2025-10-28T20:58:13.358Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0f/6a03e3fc7595421274fa34122c973bde2d89344f8a881b728fa8c774e4f1/aiohttp-3.13.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:e7c952aefdf2460f4ae55c5e9c3e80aa72f706a6317e06020f80e96253b1accd", size = 504616, upload-time = "2025-10-28T20:58:15.339Z" }, + { url = "https://files.pythonhosted.org/packages/c6/aa/ed341b670f1bc8a6f2c6a718353d13b9546e2cef3544f573c6a1ff0da711/aiohttp-3.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c20423ce14771d98353d2e25e83591fa75dfa90a3c1848f3d7c68243b4fbded3", size = 509131, upload-time = "2025-10-28T20:58:17.693Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f0/c68dac234189dae5c4bbccc0f96ce0cc16b76632cfc3a08fff180045cfa4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e96eb1a34396e9430c19d8338d2ec33015e4a87ef2b4449db94c22412e25ccdf", size = 1864168, upload-time = "2025-10-28T20:58:20.113Z" }, + { url = "https://files.pythonhosted.org/packages/8f/65/75a9a76db8364b5d0e52a0c20eabc5d52297385d9af9c35335b924fafdee/aiohttp-3.13.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:23fb0783bc1a33640036465019d3bba069942616a6a2353c6907d7fe1ccdaf4e", size = 1719200, upload-time = "2025-10-28T20:58:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/f5/55/8df2ed78d7f41d232f6bd3ff866b6f617026551aa1d07e2f03458f964575/aiohttp-3.13.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1a9bea6244a1d05a4e57c295d69e159a5c50d8ef16aa390948ee873478d9a5", size = 1843497, upload-time = "2025-10-28T20:58:24.672Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e0/94d7215e405c5a02ccb6a35c7a3a6cfff242f457a00196496935f700cde5/aiohttp-3.13.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0a3d54e822688b56e9f6b5816fb3de3a3a64660efac64e4c2dc435230ad23bad", size = 1935703, upload-time = "2025-10-28T20:58:26.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/1eeb63c3f9b2d1015a4c02788fb543141aad0a03ae3f7a7b669b2483f8d4/aiohttp-3.13.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a653d872afe9f33497215745da7a943d1dc15b728a9c8da1c3ac423af35178e", size = 1792738, upload-time = "2025-10-28T20:58:29.787Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/aaf1eea4c188e51538c04cc568040e3082db263a57086ea74a7d38c39e42/aiohttp-3.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56d36e80d2003fa3fc0207fac644216d8532e9504a785ef9a8fd013f84a42c61", size = 1624061, upload-time = "2025-10-28T20:58:32.529Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c2/3b6034de81fbcc43de8aeb209073a2286dfb50b86e927b4efd81cf848197/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:78cd586d8331fb8e241c2dd6b2f4061778cc69e150514b39a9e28dd050475661", size = 1789201, upload-time = "2025-10-28T20:58:34.618Z" }, + { url = "https://files.pythonhosted.org/packages/c9/38/c15dcf6d4d890217dae79d7213988f4e5fe6183d43893a9cf2fe9e84ca8d/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:20b10bbfbff766294fe99987f7bb3b74fdd2f1a2905f2562132641ad434dcf98", size = 1776868, upload-time = "2025-10-28T20:58:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/04/75/f74fd178ac81adf4f283a74847807ade5150e48feda6aef024403716c30c/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9ec49dff7e2b3c85cdeaa412e9d438f0ecd71676fde61ec57027dd392f00c693", size = 1790660, upload-time = "2025-10-28T20:58:41.507Z" }, + { url = "https://files.pythonhosted.org/packages/e7/80/7368bd0d06b16b3aba358c16b919e9c46cf11587dc572091031b0e9e3ef0/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:94f05348c4406450f9d73d38efb41d669ad6cd90c7ee194810d0eefbfa875a7a", size = 1617548, upload-time = "2025-10-28T20:58:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4b/a6212790c50483cb3212e507378fbe26b5086d73941e1ec4b56a30439688/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:fa4dcb605c6f82a80c7f95713c2b11c3b8e9893b3ebd2bc9bde93165ed6107be", size = 1817240, upload-time = "2025-10-28T20:58:45.787Z" }, + { url = "https://files.pythonhosted.org/packages/ff/f7/ba5f0ba4ea8d8f3c32850912944532b933acbf0f3a75546b89269b9b7dde/aiohttp-3.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf00e5db968c3f67eccd2778574cf64d8b27d95b237770aa32400bd7a1ca4f6c", size = 1762334, upload-time = "2025-10-28T20:58:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/7e/83/1a5a1856574588b1cad63609ea9ad75b32a8353ac995d830bf5da9357364/aiohttp-3.13.2-cp314-cp314t-win32.whl", hash = "sha256:d23b5fe492b0805a50d3371e8a728a9134d8de5447dce4c885f5587294750734", size = 464685, upload-time = "2025-10-28T20:58:50.642Z" }, + { url = "https://files.pythonhosted.org/packages/9f/4d/d22668674122c08f4d56972297c51a624e64b3ed1efaa40187607a7cb66e/aiohttp-3.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:ff0a7b0a82a7ab905cbda74006318d1b12e37c797eb1b0d4eb3e316cf47f658f", size = 498093, upload-time = "2025-10-28T20:58:52.782Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "environs" +version = "14.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/75/06801d5beeb398ed3903167af9376bb81c4ac41c44a53d45193065ebb1a8/environs-14.5.0.tar.gz", hash = "sha256:f7b8f6fcf3301bc674bc9c03e39b5986d116126ffb96764efd34c339ed9464ee", size = 35426, upload-time = "2025-11-02T21:30:36.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f3/6961beb9a1e77d01dee1dd48f00fb3064429c8abcfa26aa863eb7cb2b6dd/environs-14.5.0-py3-none-any.whl", hash = "sha256:1abd3e3a5721fb09797438d6c902bc2f35d4580dfaffe68b8ee588b67b504e13", size = 17202, upload-time = "2025-11-02T21:30:35.186Z" }, +] + +[[package]] +name = "flask" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + +[[package]] +name = "humanize" +version = "4.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b6/43/50033d25ad96a7f3845f40999b4778f753c3901a11808a584fed7c00d9f5/humanize-4.14.0.tar.gz", hash = "sha256:2fa092705ea640d605c435b1ca82b2866a1b601cdf96f076d70b79a855eba90d", size = 82939, upload-time = "2025-10-15T13:04:51.214Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/5b/9512c5fb6c8218332b530f13500c6ff5f3ce3342f35e0dd7be9ac3856fd3/humanize-4.14.0-py3-none-any.whl", hash = "sha256:d57701248d040ad456092820e6fde56c930f17749956ac47f4f655c0c547bfff", size = 132092, upload-time = "2025-10-15T13:04:49.404Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "marshmallow" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/2c/e40834adb0bb6f21d7372ad90e616eda82116d4f090d93c29ceb2366cdaf/marshmallow-4.1.0.tar.gz", hash = "sha256:daa9862f74e2f7864980d25c29b4ea72944cde48aa17537e3bd5797a4ae62d71", size = 220619, upload-time = "2025-11-01T15:40:37.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/df/081ea8c41696d598e7cea4f101e49da718a9b6c9dcaaad4e76dfc11a022c/marshmallow-4.1.0-py3-none-any.whl", hash = "sha256:9901660499be3b880dc92d6b5ee0b9a79e94265b7793f71021f92040c07129f1", size = 48286, upload-time = "2025-11-01T15:40:35.542Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, + { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, + { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, + { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, + { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, + { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, + { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, + { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, + { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, + { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, + { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, + { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, + { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, + { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, + { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, + { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, + { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, + { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, + { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, + { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, + { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, + { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, + { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, + { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, + { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, + { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, + { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, + { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, + { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, + { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, + { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, + { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, + { url = "https://files.pythonhosted.org/packages/00/a3/67f183VOE_STREET_ID0f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, + { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, + { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, + { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, + { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, + { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, + { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pillow" +version = "12.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/cace85a1b0c9775a9f8f5d5423c8261c858760e2466c79b2dd184638b056/pillow-12.0.0.tar.gz", hash = "sha256:87d4f8125c9988bfbed67af47dd7a953e2fc7b0cc1e7800ec6d2080d490bb353", size = 47008828, upload-time = "2025-10-15T18:24:14.008Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/f2/de993bb2d21b33a98d031ecf6a978e4b61da207bef02f7b43093774c480d/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0869154a2d0546545cde61d1789a6524319fc1897d9ee31218eae7a60ccc5643", size = 4045493, upload-time = "2025-10-15T18:22:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b6/bc8d0c4c9f6f111a783d045310945deb769b806d7574764234ffd50bc5ea/pillow-12.0.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a7921c5a6d31b3d756ec980f2f47c0cfdbce0fc48c22a39347a895f41f4a6ea4", size = 4120461, upload-time = "2025-10-15T18:22:27.286Z" }, + { url = "https://files.pythonhosted.org/packages/5d/57/d60d343709366a353dc56adb4ee1e7d8a2cc34e3fbc22905f4167cfec119/pillow-12.0.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1ee80a59f6ce048ae13cda1abf7fbd2a34ab9ee7d401c46be3ca685d1999a399", size = 3576912, upload-time = "2025-10-15T18:22:28.751Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/a0a31467e3f83b94d37568294b01d22b43ae3c5d85f2811769b9c66389dd/pillow-12.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c50f36a62a22d350c96e49ad02d0da41dbd17ddc2e29750dbdba4323f85eb4a5", size = 5249132, upload-time = "2025-10-15T18:22:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/83/06/48eab21dd561de2914242711434c0c0eb992ed08ff3f6107a5f44527f5e9/pillow-12.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5193fde9a5f23c331ea26d0cf171fbf67e3f247585f50c08b3e205c7aeb4589b", size = 4650099, upload-time = "2025-10-15T18:22:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/69ed99fd46a8dba7c1887156d3572fe4484e3f031405fcc5a92e31c04035/pillow-12.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bde737cff1a975b70652b62d626f7785e0480918dece11e8fef3c0cf057351c3", size = 6230808, upload-time = "2025-10-15T18:22:34.337Z" }, + { url = "https://files.pythonhosted.org/packages/ea/94/8fad659bcdbf86ed70099cb60ae40be6acca434bbc8c4c0d4ef356d7e0de/pillow-12.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a6597ff2b61d121172f5844b53f21467f7082f5fb385a9a29c01414463f93b07", size = 8037804, upload-time = "2025-10-15T18:22:36.402Z" }, + { url = "https://files.pythonhosted.org/packages/20/39/c685d05c06deecfd4e2d1950e9a908aa2ca8bc4e6c3b12d93b9cafbd7837/pillow-12.0.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b817e7035ea7f6b942c13aa03bb554fc44fea70838ea21f8eb31c638326584e", size = 6345553, upload-time = "2025-10-15T18:22:38.066Z" }, + { url = "https://files.pythonhosted.org/packages/38/57/755dbd06530a27a5ed74f8cb0a7a44a21722ebf318edbe67ddbd7fb28f88/pillow-12.0.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4f1231b7dec408e8670264ce63e9c71409d9583dd21d32c163e25213ee2a344", size = 7037729, upload-time = "2025-10-15T18:22:39.769Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/7e94f4c41d238615674d06ed677c14883103dce1c52e4af16f000338cfd7/pillow-12.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e51b71417049ad6ab14c49608b4a24d8fb3fe605e5dfabfe523b58064dc3d27", size = 6459789, upload-time = "2025-10-15T18:22:41.437Z" }, + { url = "https://files.pythonhosted.org/packages/9c/14/4448bb0b5e0f22dd865290536d20ec8a23b64e2d04280b89139f09a36bb6/pillow-12.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d120c38a42c234dc9a8c5de7ceaaf899cf33561956acb4941653f8bdc657aa79", size = 7130917, upload-time = "2025-10-15T18:22:43.152Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/16c6926cc1c015845745d5c16c9358e24282f1e588237a4c36d2b30f182f/pillow-12.0.0-cp313-cp313-win32.whl", hash = "sha256:4cc6b3b2efff105c6a1656cfe59da4fdde2cda9af1c5e0b58529b24525d0a098", size = 6302391, upload-time = "2025-10-15T18:22:44.753Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2a/dd43dcfd6dae9b6a49ee28a8eedb98c7d5ff2de94a5d834565164667b97b/pillow-12.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:4cf7fed4b4580601c4345ceb5d4cbf5a980d030fd5ad07c4d2ec589f95f09905", size = 7007477, upload-time = "2025-10-15T18:22:46.838Z" }, + { url = "https://files.pythonhosted.org/packages/77/f0/72ea067f4b5ae5ead653053212af05ce3705807906ba3f3e8f58ddf617e6/pillow-12.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:9f0b04c6b8584c2c193babcccc908b38ed29524b29dd464bc8801bf10d746a3a", size = 2435918, upload-time = "2025-10-15T18:22:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5e/9046b423735c21f0487ea6cb5b10f89ea8f8dfbe32576fe052b5ba9d4e5b/pillow-12.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7fa22993bac7b77b78cae22bad1e2a987ddf0d9015c63358032f84a53f23cdc3", size = 5251406, upload-time = "2025-10-15T18:22:49.905Z" }, + { url = "https://files.pythonhosted.org/packages/12/66/982ceebcdb13c97270ef7a56c3969635b4ee7cd45227fa707c94719229c5/pillow-12.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f135c702ac42262573fe9714dfe99c944b4ba307af5eb507abef1667e2cbbced", size = 4653218, upload-time = "2025-10-15T18:22:51.587Z" }, + { url = "https://files.pythonhosted.org/packages/16/b3/81e625524688c31859450119bf12674619429cab3119eec0e30a7a1029cb/pillow-12.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c85de1136429c524e55cfa4e033b4a7940ac5c8ee4d9401cc2d1bf48154bbc7b", size = 6266564, upload-time = "2025-10-15T18:22:53.215Z" }, + { url = "https://files.pythonhosted.org/packages/98/59/dfb38f2a41240d2408096e1a76c671d0a105a4a8471b1871c6902719450c/pillow-12.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:38df9b4bfd3db902c9c2bd369bcacaf9d935b2fff73709429d95cc41554f7b3d", size = 8069260, upload-time = "2025-10-15T18:22:54.933Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3d/378dbea5cd1874b94c312425ca77b0f47776c78e0df2df751b820c8c1d6c/pillow-12.0.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d87ef5795da03d742bf49439f9ca4d027cde49c82c5371ba52464aee266699a", size = 6379248, upload-time = "2025-10-15T18:22:56.605Z" }, + { url = "https://files.pythonhosted.org/packages/84/b0/d525ef47d71590f162VOE_STREET_ID327acec75ae58c721dc071b17d8d652ca494d8/pillow-12.0.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aff9e4d82d082ff9513bdd6acd4f5bd359f5b2c870907d2b0a9c5e10d40c88fe", size = 7066043, upload-time = "2025-10-15T18:22:58.53Z" }, + { url = "https://files.pythonhosted.org/packages/61/2c/aced60e9cf9d0cde341d54bf7932c9ffc33ddb4a1595798b3a5150c7ec4e/pillow-12.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8d8ca2b210ada074d57fcee40c30446c9562e542fc46aedc19baf758a93532ee", size = 6490915, upload-time = "2025-10-15T18:23:00.582Z" }, + { url = "https://files.pythonhosted.org/packages/ef/26/69dcb9b91f4e59f8f34b2332a4a0a951b44f547c4ed39d3e4dcfcff48f89/pillow-12.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:99a7f72fb6249302aa62245680754862a44179b545ded638cf1fef59befb57ef", size = 7157998, upload-time = "2025-10-15T18:23:02.627Z" }, + { url = "https://files.pythonhosted.org/packages/61/2b/726235842220ca95fa441ddf55dd2382b52ab5b8d9c0596fe6b3f23dafe8/pillow-12.0.0-cp313-cp313t-win32.whl", hash = "sha256:4078242472387600b2ce8d93ade8899c12bf33fa89e55ec89fe126e9d6d5d9e9", size = 6306201, upload-time = "2025-10-15T18:23:04.709Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3d/2afaf4e840b2df71344ababf2f8edd75a705ce500e5dc1e7227808312ae1/pillow-12.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2c54c1a783d6d60595d3514f0efe9b37c8808746a66920315bfd34a938d7994b", size = 7013165, upload-time = "2025-10-15T18:23:06.46Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/3fa09aa5cf6ed04bee3fa575798ddf1ce0bace8edb47249c798077a81f7f/pillow-12.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:26d9f7d2b604cd23aba3e9faf795787456ac25634d82cd060556998e39c6fa47", size = 2437834, upload-time = "2025-10-15T18:23:08.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/2a/9a8c6ba2c2c07b71bec92cf63e03370ca5e5f5c5b119b742bcc0cde3f9c5/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:beeae3f27f62308f1ddbcfb0690bf44b10732f2ef43758f169d5e9303165d3f9", size = 4045531, upload-time = "2025-10-15T18:23:10.121Z" }, + { url = "https://files.pythonhosted.org/packages/84/54/836fdbf1bfb3d66a59f0189ff0b9f5f666cee09c6188309300df04ad71fa/pillow-12.0.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d4827615da15cd59784ce39d3388275ec093ae3ee8d7f0c089b76fa87af756c2", size = 4120554, upload-time = "2025-10-15T18:23:12.14Z" }, + { url = "https://files.pythonhosted.org/packages/0d/cd/16aec9f0da4793e98e6b54778a5fbce4f375c6646fe662e80600b8797379/pillow-12.0.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:3e42edad50b6909089750e65c91aa09aaf1e0a71310d383f11321b27c224ed8a", size = 3576812, upload-time = "2025-10-15T18:23:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b7/13957fda356dc46339298b351cae0d327704986337c3c69bb54628c88155/pillow-12.0.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e5d8efac84c9afcb40914ab49ba063d94f5dbdf5066db4482c66a992f47a3a3b", size = 5252689, upload-time = "2025-10-15T18:23:15.562Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f5/eae31a306341d8f331f43edb2e9122c7661b975433de5e447939ae61c5da/pillow-12.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:266cd5f2b63ff316d5a1bba46268e603c9caf5606d44f38c2873c380950576ad", size = 4650186, upload-time = "2025-10-15T18:23:17.379Z" }, + { url = "https://files.pythonhosted.org/packages/86/62/2a88339aa40c4c77e79108facbd307d6091e2c0eb5b8d3cf4977cfca2fe6/pillow-12.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58eea5ebe51504057dd95c5b77d21700b77615ab0243d8152793dc00eb4faf01", size = 6230308, upload-time = "2025-10-15T18:23:18.971Z" }, + { url = "https://files.pythonhosted.org/packages/c7/33/5425a8992bcb32d1cb9fa3dd39a89e613d09a22f2c8083b7bf43c455f760/pillow-12.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f13711b1a5ba512d647a0e4ba79280d3a9a045aaf7e0cc6fbe96b91d4cdf6b0c", size = 8039222, upload-time = "2025-10-15T18:23:20.909Z" }, + { url = "https://files.pythonhosted.org/packages/d8/61/3f5d3b35c5728f37953d3eec5b5f3e77111949523bd2dd7f31a851e50690/pillow-12.0.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6846bd2d116ff42cba6b646edf5bf61d37e5cbd256425fa089fee4ff5c07a99e", size = 6346657, upload-time = "2025-10-15T18:23:23.077Z" }, + { url = "https://files.pythonhosted.org/packages/3a/be/ee90a3d79271227e0f0a33c453531efd6ed14b2e708596ba5dd9be948da3/pillow-12.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c98fa880d695de164b4135a52fd2e9cd7b7c90a9d8ac5e9e443a24a95ef9248e", size = 7038482, upload-time = "2025-10-15T18:23:25.005Z" }, + { url = "https://files.pythonhosted.org/packages/44/34/a16b6a4d1ad727de390e9bd9f19f5f669e079e5826ec0f329010ddea492f/pillow-12.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3ed2a29a9e9d2d488b4da81dcb54720ac3104a20bf0bd273f1e4648aff5af9", size = 6461416, upload-time = "2025-10-15T18:23:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/b6/39/1aa5850d2ade7d7ba9f54e4e4c17077244ff7a2d9e25998c38a29749eb3f/pillow-12.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d034140032870024e6b9892c692fe2968493790dd57208b2c37e3fb35f6df3ab", size = 7131584, upload-time = "2025-10-15T18:23:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/bf/db/4fae862f8fad0167073a7733973bfa955f47e2cac3dc3e3e6257d10fab4a/pillow-12.0.0-cp314-cp314-win32.whl", hash = "sha256:1b1b133e6e16105f524a8dec491e0586d072948ce15c9b914e41cdadd209052b", size = 6400621, upload-time = "2025-10-15T18:23:32.06Z" }, + { url = "https://files.pythonhosted.org/packages/2b/24/b350c31543fb0107ab2599464d7e28e6f856027aadda995022e695313d94/pillow-12.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:8dc232e39d409036af549c86f24aed8273a40ffa459981146829a324e0848b4b", size = 7142916, upload-time = "2025-10-15T18:23:34.71Z" }, + { url = "https://files.pythonhosted.org/packages/0f/9b/0ba5a6fd9351793996ef7487c4fdbde8d3f5f75dbedc093bb598648fddf0/pillow-12.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:d52610d51e265a51518692045e372a4c363056130d922a7351429ac9f27e70b0", size = 2523836, upload-time = "2025-10-15T18:23:36.967Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/ceee0840aebc579af529b523d530840338ecf63992395842e54edc805987/pillow-12.0.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1979f4566bb96c1e50a62d9831e2ea2d1211761e5662afc545fa766f996632f6", size = 5255092, upload-time = "2025-10-15T18:23:38.573Z" }, + { url = "https://files.pythonhosted.org/packages/44/76/20776057b4bfd1aef4eeca992ebde0f53a4dce874f3ae693d0ec90a4f79b/pillow-12.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b2e4b27a6e15b04832fe9bf292b94b5ca156016bbc1ea9c2c20098a0320d6cf6", size = 4653158, upload-time = "2025-10-15T18:23:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/d9ff92ace07be8836b4e7e87e6a4c7a8318d47c2f1463ffcf121fc57d9cb/pillow-12.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fb3096c30df99fd01c7bf8e544f392103d0795b9f98ba71a8054bcbf56b255f1", size = 6267882, upload-time = "2025-10-15T18:23:42.434Z" }, + { url = "https://files.pythonhosted.org/packages/9f/7a/4f7ff87f00d3ad33ba21af78bfcd2f032107710baf8280e3722ceec28cda/pillow-12.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7438839e9e053ef79f7112c881cef684013855016f928b168b81ed5835f3e75e", size = 8071001, upload-time = "2025-10-15T18:23:44.29Z" }, + { url = "https://files.pythonhosted.org/packages/75/87/fcea108944a52dad8cca0715ae6247e271eb80459364a98518f1e4f480c1/pillow-12.0.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d5c411a8eaa2299322b647cd932586b1427367fd3184ffbb8f7a219ea2041ca", size = 6380146, upload-time = "2025-10-15T18:23:46.065Z" }, + { url = "https://files.pythonhosted.org/packages/91/52/0d31b5e571ef5fd111d2978b84603fce26aba1b6092f28e941cb46570745/pillow-12.0.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e091d464ac59d2c7ad8e7e08105eaf9dafbc3883fd7265ffccc2baad6ac925", size = 7067344, upload-time = "2025-10-15T18:23:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f4/2dd3d721f875f928d48e83bb30a434dee75a2531bca839bb996bb0aa5a91/pillow-12.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:792a2c0be4dcc18af9d4a2dfd8a11a17d5e25274a1062b0ec1c2d79c76f3e7f8", size = 6491864, upload-time = "2025-10-15T18:23:49.607Z" }, + { url = "https://files.pythonhosted.org/packages/30/4b/667dfcf3d61fc309ba5a15b141845cece5915e39b99c1ceab0f34bf1d124/pillow-12.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:afbefa430092f71a9593a99ab6a4e7538bc9eabbf7bf94f9VOE_STREET_IDd3503943edc4", size = 7158911, upload-time = "2025-10-15T18:23:51.351Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/16cabcc6426c32218ace36bf0d55955e813f2958afddbf1d391849fee9d1/pillow-12.0.0-cp314-cp314t-win32.whl", hash = "sha256:3830c769decf88f1289680a59d4f4c46c72573446352e2befec9a8512104fa52", size = 6408045, upload-time = "2025-10-15T18:23:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/e29aa0c9c666cf787628d3f0dcf379f4791fba79f4936d02f8b37165bdf8/pillow-12.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:905b0365b210c73afb0ebe9101a32572152dfd1c144c7e28968a331b9217b94a", size = 7148282, upload-time = "2025-10-15T18:23:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/c1/70/6b41bdcddf541b437bbb9f47f94d2db5d9ddef6c37ccab8c9107743748a4/pillow-12.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:99353a06902c2e43b43e8ff74ee65a7d90307d82370604746738a1e0661ccca7", size = 2525630, upload-time = "2025-10-15T18:23:57.149Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "psutil" +version = "7.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/93/0c49e776b8734fef56ec9c5c57f923922f2cf0497d62e0f419465f28f3d0/psutil-7.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0005da714eee687b4b8decd3d6cc7c6db36215c9e74e5ad2264b90c3df7d92dc", size = 239751, upload-time = "2025-11-02T12:25:58.161Z" }, + { url = "https://files.pythonhosted.org/packages/6f/8d/b31e39c769e70780f007969815195a55c81a63efebdd4dbe9e7a113adb2f/psutil-7.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:19644c85dcb987e35eeeaefdc3915d059dac7bd1167cdcdbf27e0ce2df0c08c0", size = 240368, upload-time = "2025-11-02T12:26:00.491Z" }, + { url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" }, + { url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" }, + { url = "https://files.pythonhosted.org/packages/a6/82/62d68066e13e46a5116df187d319d1724b3f437ddd0f958756fc052677f4/psutil-7.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:18349c5c24b06ac5612c0428ec2a0331c26443d259e2a0144a9b24b4395b58fa", size = 249642, upload-time = "2025-11-02T12:26:07.447Z" }, + { url = "https://files.pythonhosted.org/packages/df/ad/c1cd5fe965c14a0392112f68362cfceb5230819dbb5b1888950d18a11d9f/psutil-7.1.3-cp313-cp313t-win_arm64.whl", hash = "sha256:c525ffa774fe4496282fb0b1187725793de3e7c6b29e41562733cae9ada151ee", size = 245518, upload-time = "2025-11-02T12:26:09.719Z" }, + { url = "https://files.pythonhosted.org/packages/2e/bb/6670bded3e3236eb4287c7bcdc167e9fae6e1e9286e437f7111caed2f909/psutil-7.1.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b403da1df4d6d43973dc004d19cee3b848e998ae3154cc8097d139b77156c353", size = 239843, upload-time = "2025-11-02T12:26:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/b8/66/853d50e75a38c9a7370ddbeefabdd3d3116b9c31ef94dc92c6729bc36bec/psutil-7.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad81425efc5e75da3f39b3e636293360ad8d0b49bed7df824c79764fb4ba9b8b", size = 240369, upload-time = "2025-11-02T12:26:14.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1d/5774a91607035ee5078b8fd747686ebec28a962f178712de100d00b78a32/psutil-7.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:3792983e23b69843aea49c8f5b8f115572c5ab64c153bada5270086a2123c7e7", size = 250466, upload-time = "2025-11-02T12:26:21.183Z" }, + { url = "https://files.pythonhosted.org/packages/00/ca/e426584bacb43a5cb1ac91fae1937f478cd8fbe5e4ff96574e698a2c77cd/psutil-7.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:31d77fcedb7529f27bb3a0472bea9334349f9a04160e8e6e5020f22c59893264", size = 245756, upload-time = "2025-11-02T12:26:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/46b9154a800253e7ecff5aaacdf8ebf43db99de4a2dfa18575b02548654e/psutil-7.1.3-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2bdbcd0e58ca14996a42adf3621a6244f1bb2e2e528886959c72cf1e326677ab", size = 238359, upload-time = "2025-11-02T12:26:25.284Z" }, + { url = "https://files.pythonhosted.org/packages/68/3a/9f93cff5c025029a36d9a92fef47220ab4692ee7f2be0fba9f92813d0cb8/psutil-7.1.3-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc31fa00f1fbc3c3802141eede66f3a2d51d89716a194bf2cd6fc68310a19880", size = 239171, upload-time = "2025-11-02T12:26:27.23Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" }, + { url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/c3ed1a622b6ae2fd3c945a366e64eb35247a31e4db16cf5095e269e8eb3c/psutil-7.1.3-cp37-abi3-win_amd64.whl", hash = "sha256:f39c2c19fe824b47484b96f9692932248a54c43799a84282cfe58d05a6449efd", size = 247633, upload-time = "2025-11-02T12:26:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ad/33b2ccec09bf96c2b2ef3f9a6f66baac8253d7565d8839e024a6b905d45d/psutil-7.1.3-cp37-abi3-win_arm64.whl", hash = "sha256:bd0d69cee829226a761e92f28140bec9a5ee9d5b4fb4b0cc589068dbfff559b1", size = 244608, upload-time = "2025-11-02T12:26:36.136Z" }, +] + +[[package]] +name = "pyaes" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" } + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pymediainfo-pyrofork" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/43/ebfd048e84bb264bb133d545312e35b49638bcd7d5ad973c023e0026a36b/pymediainfo_pyrofork-6.0.2.tar.gz", hash = "sha256:fce9402edfd1fa09aba7b3cac4c41ba7fcf6820e561b4db4f9c1a1a68c487c36", size = 446514, upload-time = "2024-10-08T14:31:39.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/16/7c2b2f969e84e5f196809c10da6c847c505fb722b9d636bf6f6bf8f2e919/pymediainfo_pyrofork-6.0.2-py2.py3-none-any.whl", hash = "sha256:674fa8e53de861635b9dc4f77c2ad712306a798bf28864952503bf328210c4c3", size = 9356, upload-time = "2024-10-08T14:31:37.054Z" }, +] + +[[package]] +name = "pymongo" +version = "4.15.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/7b/a709c85dc716eb85b69f71a4bb375cf1e72758a7e872103f27551243319c/pymongo-4.15.3.tar.gz", hash = "sha256:7a981271347623b5319932796690c2d301668ac3a1965974ac9f5c3b8a22cea5", size = 2470801, upload-time = "2025-10-07T21:57:50.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/a4/e1ce9d408a1c1bcb1554ff61251b108e16cefd7db91b33faa2afc92294de/pymongo-4.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a47a3218f7900f65bf0f36fcd1f2485af4945757360e7e143525db9d715d2010", size = 975329, upload-time = "2025-10-07T21:56:44.674Z" }, + { url = "https://files.pythonhosted.org/packages/74/3c/6796f653d22be43cc0b13c07dbed84133eebbc334ebed4426459b7250163/pymongo-4.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09440e78dff397b2f34a624f445ac8eb44c9756a2688b85b3bf344d351d198e1", size = 975129, upload-time = "2025-10-07T21:56:46.104Z" }, + { url = "https://files.pythonhosted.org/packages/88/33/22453dbfe11031e89c9cbdfde6405c03960daaf5da1b4dfdd458891846b5/pymongo-4.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:97f9babdb98c31676f97d468f7fe2dc49b8a66fb6900effddc4904c1450196c8", size = 1950979, upload-time = "2025-10-07T21:56:47.877Z" }, + { url = "https://files.pythonhosted.org/packages/ba/07/094598e403112e2410a3376fb7845c69e2ec2dfc5ab5cc00b29dc2d26559/pymongo-4.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71413cd8f091ae25b1fec3af7c2e531cf9bdb88ce4079470e64835f6a664282a", size = 1995271, upload-time = "2025-10-07T21:56:49.396Z" }, + { url = "https://files.pythonhosted.org/packages/47/9a/29e44f3dee68defc56e50ed7c9d3802ebf967ab81fefb175d8d729c0f276/pymongo-4.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76a8d4de8dceb69f6e06736198ff6f7e1149515ef946f192ff2594d2cc98fc53", size = 2086587, upload-time = "2025-10-07T21:56:50.896Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d5/e9ff16aa57f671349134475b904fd431e7b86e152b01a949aef4f254b2d5/pymongo-4.15.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:77353978be9fc9e5fe56369682efed0aac5f92a2a1570704d62b62a3c9e1a24f", size = 2070201, upload-time = "2025-10-07T21:56:52.425Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a3/820772c0b2bbb671f253cfb0bede4cf694a38fb38134f3993d491e23ec11/pymongo-4.15.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9897a837677e3814873d0572f7e5d53c23ce18e274f3b5b87f05fb6eea22615b", size = 1985260, upload-time = "2025-10-07T21:56:54.56Z" }, + { url = "https://files.pythonhosted.org/packages/6e/7b/365ac821aefad7e8d36a4bc472a94429449aade1ccb7805d9ca754df5081/pymongo-4.15.3-cp313-cp313-win32.whl", hash = "sha256:d66da207ccb0d68c5792eaaac984a0d9c6c8ec609c6bcfa11193a35200dc5992", size = 938122, upload-time = "2025-10-07T21:56:55.993Z" }, + { url = "https://files.pythonhosted.org/packages/80/f3/5ca27e1765fa698c677771a1c0e042ef193e207c15f5d32a21fa5b13d8c3/pymongo-4.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:52f40c4b8c00bc53d4e357fe0de13d031c4cddb5d201e1a027db437e8d2887f8", size = 962610, upload-time = "2025-10-07T21:56:57.397Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/42f0b6997324023e94939f8f32b9a8dd928499f4b5d7b4412905368686b5/pymongo-4.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:fb384623ece34db78d445dd578a52d28b74e8319f4d9535fbaff79d0eae82b3d", size = 944300, upload-time = "2025-10-07T21:56:58.969Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a3/d8aaf9c243ce1319bd2498004a9acccfcfb35a3ef9851abb856993d95255/pymongo-4.15.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dcff15b9157c16bc796765d4d3d151df669322acfb0357e4c3ccd056153f0ff4", size = 1029873, upload-time = "2025-10-07T21:57:00.759Z" }, + { url = "https://files.pythonhosted.org/packages/64/10/91fd7791425ed3b56cbece6c23a36fb2696706a695655d8ea829e5e23c3a/pymongo-4.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f681722c9f27e86c49c2e8a838e61b6ecf2285945fd1798bd01458134257834", size = 1029611, upload-time = "2025-10-07T21:57:02.488Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9c/d9cf8d8a181f96877bca7bdec3e6ce135879d5e3d78694ea465833c53a3f/pymongo-4.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2c96dde79bdccd167b930a709875b0cd4321ac32641a490aebfa10bdcd0aa99b", size = 2211827, upload-time = "2025-10-07T21:57:03.907Z" }, + { url = "https://files.pythonhosted.org/packages/c2/40/12703964305216c155284100124222eaa955300a07d426c6e0ba3c9cbade/pymongo-4.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d4ca446348d850ac4a5c3dc603485640ae2e7805dbb90765c3ba7d79129b37", size = 2264654, upload-time = "2025-10-07T21:57:05.41Z" }, + { url = "https://files.pythonhosted.org/packages/0f/70/bf3c18b5d0cae0b9714158b210b07b5891a875eb1c503271cfe045942fd3/pymongo-4.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0fd3de3a12ff0a8113a3f64cedb01f87397ab8eaaffa88d7f18ca66cd39385", size = 2371830, upload-time = "2025-10-07T21:57:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/21/6d/2dfaed2ae66304ab842d56ed9a1bd2706ca0ecf97975b328a5eeceb2a4c0/pymongo-4.15.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e84dec392cf5f72d365e0aac73f627b0a3170193ebb038c3f7e7df11b7983ee7", size = 2351878, upload-time = "2025-10-07T21:57:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/17/ed/fe46ff9adfa6dc11ad2e0694503adfc98f40583cfcc6db4dbaf582f0e357/pymongo-4.15.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d4b01a48369ea6d5bc83fea535f56279f806aa3e4991189f0477696dd736289", size = 2251356, upload-time = "2025-10-07T21:57:10.51Z" }, + { url = "https://files.pythonhosted.org/packages/12/c4/2e1a10b1e9bca9c106f2dc1b89d4ad70c63d387c194b3a1bfcca552b5a3f/pymongo-4.15.3-cp314-cp314-win32.whl", hash = "sha256:3561fa96c3123275ec5ccf919e595547e100c412ec0894e954aa0da93ecfdb9e", size = 992878, upload-time = "2025-10-07T21:57:12.119Z" }, + { url = "https://files.pythonhosted.org/packages/98/b5/14aa417a44ea86d4c31de83b26f6e6793f736cd60e7e7fda289ce5184bdf/pymongo-4.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:9df2db6bd91b07400879b6ec89827004c0c2b55fc606bb62db93cafb7677c340", size = 1021209, upload-time = "2025-10-07T21:57:13.686Z" }, + { url = "https://files.pythonhosted.org/packages/94/9f/1097c6824fa50a4ffb11ba5194d2a9ef68d5509dd342e32ddb697d2efe4e/pymongo-4.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:ff99864085d2c7f4bb672c7167680ceb7d273e9a93c1a8074c986a36dbb71cc6", size = 1000618, upload-time = "2025-10-07T21:57:15.212Z" }, + { url = "https://files.pythonhosted.org/packages/ad/31/37c76607a4f793f4491611741fa7a7c4238b956f48c4a9505cea0b5cf7ef/pymongo-4.15.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ffe217d2502f3fba4e2b0dc015ce3b34f157b66dfe96835aa64432e909dd0d95", size = 1086576, upload-time = "2025-10-07T21:57:16.742Z" }, + { url = "https://files.pythonhosted.org/packages/92/b2/6d17d279cdd293eeeb0c9d5baeb4f8cdebb45354fd81cfcef2d1c69303ab/pymongo-4.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:390c4954c774eda280898e73aea36482bf20cba3ecb958dbb86d6a68b9ecdd68", size = 1086656, upload-time = "2025-10-07T21:57:18.774Z" }, + { url = "https://files.pythonhosted.org/packages/55/fd/c5da8619beca207d7e6231f24ed269cb537c5311dad59fd9f2ef7d43204a/pymongo-4.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7dd2a49f088890ca08930bbf96121443b48e26b02b84ba0a3e1ae2bf2c5a9b48", size = 2531646, upload-time = "2025-10-07T21:57:20.63Z" }, + { url = "https://files.pythonhosted.org/packages/93/8f/66a7e12b874f41eb205f352b3a719e5a964b5ba103996f6ac45e80560111/pymongo-4.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f6feb678f26171f2a6b2cbb340949889154c7067972bd4cc129b62161474f08", size = 2603799, upload-time = "2025-10-07T21:57:22.591Z" }, + { url = "https://files.pythonhosted.org/packages/10/98/baf0d1f8016087500899cc4ae14e591f29b016c643e99ab332fcafe6f7bc/pymongo-4.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446417a34ff6c2411ce3809e17ce9a67269c9f1cb4966b01e49e0c590cc3c6b3", size = 2725238, upload-time = "2025-10-07T21:57:24.091Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/112d8d3882d6e842f501e166fbe08dfc2bc9a35f8773cbcaa804f7991043/pymongo-4.15.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cfa4a0a0f024a0336640e1201994e780a17bda5e6a7c0b4d23841eb9152e868b", size = 2704837, upload-time = "2025-10-07T21:57:25.626Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/043a9aac7b3fba5b8e216f48359bd18fdbe46a4d93b081786f773b25e997/pymongo-4.15.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b03db2fe37c950aff94b29ded5c349b23729bccd90a0a5907bbf807d8c77298", size = 2582294, upload-time = "2025-10-07T21:57:27.221Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fe/7a6a6b331d9f2024ab171028ab53d5d9026959b1d713fe170be591a4d9a8/pymongo-4.15.3-cp314-cp314t-win32.whl", hash = "sha256:e7cde58ef6470c0da922b65e885fb1ffe04deef81e526bd5dea429290fa358ca", size = 1043993, upload-time = "2025-10-07T21:57:28.727Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/bc64321711e19bd48ea3371f0082f10295c433833245d73e7606d3b9afbe/pymongo-4.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fae552767d8e5153ed498f1bca92d905d0d46311d831eefb0f06de38f7695c95", size = 1078481, upload-time = "2025-10-07T21:57:30.372Z" }, + { url = "https://files.pythonhosted.org/packages/39/31/2bb2003bb978eb25dfef7b5f98e1c2d4a86e973e63b367cc508a9308d31c/pymongo-4.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:47ffb068e16ae5e43580d5c4e3b9437f05414ea80c32a1e5cac44a835859c259", size = 1051179, upload-time = "2025-10-07T21:57:31.829Z" }, +] + +[[package]] +name = "pyrofork" +version = "2.3.68" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyaes" }, + { name = "pymediainfo-pyrofork" }, + { name = "pysocks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/6a/a35f0b5606d10c12dd4c6fadf0af5eb3b9f460a2eeefe88a3496d01a3881/pyrofork-2.3.68.tar.gz", hash = "sha256:12dcc7b78d2179b4e13fdbe31121d9235c4bf0089bc946c47ede62647f19229f", size = 501077, upload-time = "2025-07-10T12:59:52.02Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/8f/9fa88de00f6c02dcb77cbaa5a65d47f34a9bf2f804eb98d5183389634c3a/pyrofork-2.3.68-py3-none-any.whl", hash = "sha256:1a93c062682f0ed7e5fffd01b4f878e663cce0eea8a0df06c93f005768d8697a", size = 5043277, upload-time = "2025-07-10T12:59:49.935Z" }, +] + +[[package]] +name = "pysmartdl" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/4c/ed073b2373f115094a4a612431abe25b58e542bebd951557dcc881999ef9/pySmartDL-1.3.4.tar.gz", hash = "sha256:35275d1694f3474d33bdca93b27d3608265ffd42f5aeb28e56f38b906c0c35f4", size = 21467, upload-time = "2020-09-19T15:40:22.676Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/6a/582286ea74c54363cba30413214767904f0a239e12253c3817feaf78453f/pySmartDL-1.3.4-py3-none-any.whl", hash = "sha256:671c277ca710fb9b6603b19176f5c091041ec4ef6dcdb507c9a983a89ca35d31", size = 20000, upload-time = "2020-09-19T15:40:21.264Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" }, +] + +[[package]] +name = "tgcrypto" +version = "1.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/59/7cf5ced989e3139a791d5452d58cb8994de589576b80f9267ba76d794f6c/TgCrypto-1.2.5.tar.gz", hash = "sha256:9bc2cac6fb9a12ef5b08f3dd500174fe374d89b660cce981f57e3138559cb682", size = 37280, upload-time = "2022-11-11T19:55:11.436Z" } + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "userbot" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "beautifulsoup4" }, + { name = "click" }, + { name = "dnspython" }, + { name = "environs" }, + { name = "flask" }, + { name = "gitpython" }, + { name = "gunicorn" }, + { name = "humanize" }, + { name = "pillow" }, + { name = "psutil" }, + { name = "pygments" }, + { name = "pymongo" }, + { name = "pyrofork" }, + { name = "pysmartdl" }, + { name = "qrcode" }, + { name = "requests" }, + { name = "tgcrypto" }, + { name = "wheel" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=25.1.0" }, + { name = "aiohttp", specifier = ">=3.13.2" }, + { name = "beautifulsoup4", specifier = ">=4.14.2" }, + { name = "click", specifier = ">=8.3.0" }, + { name = "dnspython", specifier = ">=2.8.0" }, + { name = "environs", specifier = ">=14.5.0" }, + { name = "flask", specifier = ">=3.1.2" }, + { name = "gitpython", specifier = ">=3.1.45" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "humanize", specifier = ">=4.14.0" }, + { name = "pillow", specifier = ">=10.3.0" }, + { name = "psutil", specifier = ">=7.1.3" }, + { name = "pygments", specifier = ">=2.19.2" }, + { name = "pymongo", specifier = ">=4.15.3" }, + { name = "pyrofork", specifier = "==2.3.68" }, + { name = "pysmartdl", specifier = ">=1.3.4" }, + { name = "qrcode", specifier = ">=8.2" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "tgcrypto", specifier = ">=1.2.5" }, + { name = "wheel", specifier = ">=0.45.1" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" }, +] + +[[package]] +name = "wheel" +version = "0.45.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" }, +] + +[[package]] +name = "yarl" +version = "1.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" }, + { url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" }, + { url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" }, + { url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" }, + { url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" }, + { url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" }, + { url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" }, + { url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" }, + { url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" }, + { url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" }, + { url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" }, + { url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" }, + { url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" }, + { url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2VOE_STREET_ID99c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" }, + { url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" }, + { url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" }, + { url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" }, + { url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" }, + { url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" }, + { url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" }, + { url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" }, + { url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" }, + { url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" }, + { url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" }, + { url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" }, + { url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" }, + { url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" }, + { url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" }, + { url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" }, + { url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" }, +]