├── .dockerignore ├── .envrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── docker.yml │ ├── guide.yml │ └── main.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── docker └── install_mods.py ├── entrypoint.sh ├── flake.lock ├── flake.nix ├── guide ├── docs │ ├── assets │ │ ├── TGPy.png │ │ ├── chatgpt1.jpg │ │ ├── chatgpt2.jpg │ │ ├── example.mp4 │ │ ├── example.png │ │ └── icon.png │ ├── basics │ │ ├── asyncio.md │ │ ├── code.md │ │ ├── examples.md │ │ └── messages.md │ ├── extensibility │ │ ├── api.md │ │ ├── context.md │ │ ├── module_examples.md │ │ ├── modules.md │ │ └── transformers.md │ ├── index.md │ ├── installation.md │ ├── recipes │ │ ├── about.md │ │ ├── chatgpt.md │ │ ├── contacts.md │ │ ├── dice.md │ │ ├── editors.md │ │ └── reminders.md │ ├── reference │ │ ├── builtins.md │ │ ├── code_detection.md │ │ └── module_metadata.md │ └── stylesheets │ │ ├── code_blocks.css │ │ ├── custom_theme.css │ │ ├── home.css │ │ └── recipes.css ├── mkdocs.yml └── snippets │ └── arrow.md ├── nix ├── mkPackageAttrs.nix ├── mkPackageOverrides.nix └── treefmt.nix ├── poetry.lock ├── pyproject.toml └── tgpy ├── __init__.py ├── __main__.py ├── _core ├── __init__.py ├── eval_message.py ├── message_design.py ├── meval.py └── utils.py ├── _handlers └── __init__.py ├── api ├── __init__.py ├── config.py ├── directories.py ├── parse_code.py ├── parse_tgpy_message.py ├── tgpy_eval.py ├── transformers.py └── utils.py ├── context.py ├── main.py ├── modules.py ├── reactions_fix.py ├── std ├── client_fixes.py ├── compat.py ├── constants.py ├── module_manager.py ├── ping.py ├── postfix_await.py ├── prevent_eval.py ├── restart.py ├── star_imports.py └── update.py ├── utils.py └── version.py /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__/ 2 | venv/ 3 | .idea/ 4 | /.github/ 5 | /data/ 6 | /guide/site 7 | /guide/.cache 8 | flake.lock 9 | flake.nix 10 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | watch_file nix/*.nix 2 | use flake 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build docker image 2 | on: 3 | workflow_call: 4 | inputs: 5 | commit-hash: 6 | required: false 7 | type: string 8 | default: "" 9 | secrets: 10 | DOCKERHUB_USER: 11 | required: true 12 | DOCKERHUB_PASSWORD: 13 | required: true 14 | env: 15 | DOCKERHUB_REPO: tgpy/tgpy 16 | jobs: 17 | Build-docker-image: 18 | name: Build docker image 19 | concurrency: release-docker 20 | if: github.event_name == 'push' 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | with: 25 | fetch-depth: 0 26 | ref: ${{ inputs.commit-hash }} 27 | - name: Setup QEMU 28 | uses: docker/setup-qemu-action@v2 29 | - name: Setup Buildx 30 | uses: docker/setup-buildx-action@v2 31 | - name: Setup Buildx caching 32 | uses: actions/cache@v3 33 | with: 34 | path: /tmp/.buildx-cache 35 | key: ${{ runner.os }}-buildx-${{ github.sha }} 36 | restore-keys: | 37 | ${{ runner.os }}-buildx- 38 | - name: Set release flag 39 | if: github.ref == 'refs/heads/master' 40 | run: sed -i "s/\(IS_DEV_BUILD *= *\).*/\1False/" tgpy/version.py 41 | - name: Set branch tag 42 | if: github.ref != 'refs/heads/master' 43 | run: | 44 | BRANCH_TAG=$DOCKERHUB_REPO:$(git rev-parse --abbrev-ref HEAD) 45 | echo "IMAGE_TAGS=-t $BRANCH_TAG" >> $GITHUB_ENV 46 | - name: Set latest tag 47 | if: github.ref == 'refs/heads/master' 48 | run: | 49 | BRANCH_TAG=$DOCKERHUB_REPO:latest 50 | VERSION_TAG=$DOCKERHUB_REPO:v$(cat tgpy/version.py| grep __version__ | sed "s/^__version__ = '\(.*\)'$/\1/") 51 | echo "IMAGE_TAGS=-t $BRANCH_TAG -t $VERSION_TAG" >> $GITHUB_ENV 52 | - name: Login to Docker Hub 53 | uses: docker/login-action@v2 54 | with: 55 | username: ${{ secrets.DOCKERHUB_USER }} 56 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 57 | - name: Build and push image 58 | run: | 59 | docker buildx build \ 60 | --platform linux/amd64,linux/arm64 \ 61 | -t $DOCKERHUB_REPO:$(git rev-parse --short HEAD) \ 62 | $IMAGE_TAGS \ 63 | --cache-from "type=local,src=/tmp/.buildx-cache" \ 64 | --cache-to "type=local,dest=/tmp/.buildx-cache-new,mode=max" \ 65 | --push . 66 | rm -rf /tmp/.buildx-cache 67 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 68 | -------------------------------------------------------------------------------- /.github/workflows/guide.yml: -------------------------------------------------------------------------------- 1 | name: Build & deploy guide 2 | on: 3 | push: 4 | paths: 5 | - "guide/**" 6 | - ".github/workflows/guide.yml" 7 | pull_request: 8 | workflow_dispatch: 9 | jobs: 10 | deploy: 11 | name: Build & deploy guide 12 | runs-on: ubuntu-latest 13 | defaults: 14 | run: 15 | working-directory: guide 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Install poetry 21 | run: pipx install poetry~=2.0 22 | - name: Load dependency cache 23 | id: load-cache 24 | uses: actions/cache@v4 25 | with: 26 | path: .venv 27 | key: app-${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('poetry.lock') }} 28 | - name: Install dependencies 29 | run: | 30 | poetry config virtualenvs.in-project true 31 | poetry install --with guide 32 | if: steps.load-cache.outputs.cache-hit != 'true' 33 | - name: Build 34 | run: poetry run mkdocs build 35 | - name: Deploy to Netlify 36 | uses: nwtgck/actions-netlify@v3.0 37 | with: 38 | publish-dir: "./guide/site" 39 | production-branch: master 40 | github-token: ${{ secrets.GITHUB_TOKEN }} 41 | deploy-message: "Deploy from GitHub Actions" 42 | enable-pull-request-comment: false 43 | enable-commit-comment: false 44 | env: 45 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 46 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 47 | timeout-minutes: 1 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Lint & release project 2 | on: 3 | push: 4 | paths: 5 | - "pyproject.toml" 6 | - "poetry.lock" 7 | - "tgpy/**" 8 | - ".github/workflows/main.yml" 9 | - ".github/workflows/docker.yml" 10 | pull_request: 11 | paths: 12 | - "pyproject.toml" 13 | - "poetry.lock" 14 | - "tgpy/**" 15 | - ".github/workflows/main.yml" 16 | - ".github/workflows/docker.yml" 17 | workflow_dispatch: {} 18 | jobs: 19 | lint: 20 | runs-on: ubuntu-latest 21 | name: Lint 22 | steps: 23 | - uses: actions/checkout@v4 24 | - uses: DeterminateSystems/nix-installer-action@main 25 | - name: Run flake check 26 | run: nix flake check -L 27 | release: 28 | name: Release 29 | needs: lint 30 | concurrency: release 31 | if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 0 37 | - name: Install poetry 38 | run: pipx install poetry~=2.0 39 | - name: Load dependency cache 40 | id: load-cache 41 | uses: actions/cache@v4 42 | with: 43 | path: .venv 44 | key: app-${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('poetry.lock') }} 45 | - name: Install dependencies 46 | run: | 47 | poetry config virtualenvs.in-project true 48 | poetry install --with release 49 | if: steps.load-cache.outputs.cache-hit != 'true' 50 | - name: Create a release 51 | id: release-version 52 | env: 53 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 54 | run: | 55 | source .venv/bin/activate 56 | git config --global user.name "github-actions" 57 | git config --global user.email "action@github.com" 58 | python -m semantic_release version 59 | echo "version-tag=$(python -m semantic_release version --print-tag)" >> $GITHUB_OUTPUT 60 | - name: Publish package distributions to GitHub Releases 61 | env: 62 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | GH_TAG: ${{ steps.release-version.outputs.version-tag }} 64 | run: | 65 | source .venv/bin/activate 66 | python -m semantic_release -v publish --tag $GH_TAG 67 | - name: Store the distribution packages 68 | uses: actions/upload-artifact@v4 69 | with: 70 | name: python-package-distributions 71 | path: dist/ 72 | - name: Save release commit hash 73 | id: release-commit-hash 74 | run: echo "release-commit-hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT 75 | outputs: 76 | release-commit-hash: ${{ steps.release-commit-hash.outputs.release-commit-hash }} 77 | publish-to-pypi: 78 | name: Publish Python distribution to PyPI 79 | needs: release 80 | runs-on: ubuntu-latest 81 | environment: 82 | name: pypi 83 | url: https://pypi.org/p/tgpy 84 | steps: 85 | - name: Download all the dists 86 | uses: actions/download-artifact@v4 87 | with: 88 | name: python-package-distributions 89 | path: dist/ 90 | - name: Publish distribution to PyPI 91 | uses: pypa/gh-action-pypi-publish@release/v1 92 | with: 93 | password: ${{ secrets.PYPI_TOKEN }} 94 | build-dev-docker: 95 | needs: lint 96 | name: Build dev docker image 97 | if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref != 'refs/heads/master' 98 | uses: ./.github/workflows/docker.yml 99 | secrets: inherit 100 | build-release-docker: 101 | name: Build release docker image 102 | needs: release 103 | if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' 104 | uses: ./.github/workflows/docker.yml 105 | with: 106 | commit-hash: ${{ needs.release.outputs.release-commit-hash }} 107 | secrets: inherit 108 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.iml 3 | .vscode/ 4 | venv/ 5 | 6 | **/__pycache__/ 7 | 8 | dist/ 9 | *.egg-info 10 | 11 | *.session 12 | *.session-journal 13 | data/ 14 | config.yaml 15 | 16 | guide/site 17 | guide/.cache 18 | 19 | .direnv/ 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | 4 | ## v0.18.0 (2025-05-26) 5 | 6 | ### Bug Fixes 7 | 8 | - Inspect.getsource now works properly. Fixes #55 9 | ([`24217a7`](https://github.com/tm-a-t/TGPy/commit/24217a7aed79ac7113d83af8a7aad7895c304912)) 10 | 11 | - Modules docs url 12 | ([`96663ac`](https://github.com/tm-a-t/TGPy/commit/96663ac822b4d59f14d9846f124f1b8a15125c41)) 13 | 14 | - Replying cancel to a message from another user no longer produces an error. Fixes #50 15 | ([`99c8e09`](https://github.com/tm-a-t/TGPy/commit/99c8e09de61605f86670f64773a5391c61cb0891)) 16 | 17 | ### Build System 18 | 19 | - Use upstream poetry 20 | ([`6102229`](https://github.com/tm-a-t/TGPy/commit/6102229676a90974f4fdefbe8477c5b319831be5)) 21 | 22 | - ci: use poetry dependency groups - build: loosen dependency requirements 23 | 24 | - **docker**: Install poetry 2.0 from pypi 25 | ([`6af4028`](https://github.com/tm-a-t/TGPy/commit/6af40282c5a472f956b68f7ca404999756889403)) 26 | 27 | ### Chores 28 | 29 | - Add .vscode and *.iml to .gitignore 30 | ([`7bfef78`](https://github.com/tm-a-t/TGPy/commit/7bfef78919def6e18e07582e27dc4440704d6bf4)) 31 | 32 | ### Code Style 33 | 34 | - **python**: Replace black and isort with ruff 35 | ([`460afcb`](https://github.com/tm-a-t/TGPy/commit/460afcb7ac745001bac88a65ede7c9ea2256be0f)) 36 | 37 | Co-authored-by: Gleb Smirnov 38 | 39 | ### Continuous Integration 40 | 41 | - **fmt**: Use nix and treefmt for formatting and CI 42 | ([`b2f19e9`](https://github.com/tm-a-t/TGPy/commit/b2f19e956baca3e66aa467519ab76dd52377115b)) 43 | 44 | ### Documentation 45 | 46 | - Fix creation/modification dates 47 | ([`61f30b9`](https://github.com/tm-a-t/TGPy/commit/61f30b9c6bc734dbb245868f5193ee8d9afb1275)) 48 | 49 | ### Features 50 | 51 | - Add fixes for Apple and Android clients to std. Closes #54 52 | ([`3369104`](https://github.com/tm-a-t/TGPy/commit/3369104cdce8badcdfcf111e06000cc59989c4ef)) 53 | 54 | - Add star import fix to std 55 | ([`0fcac58`](https://github.com/tm-a-t/TGPy/commit/0fcac585e148b37514f5a678e2997c64c91998d8)) 56 | 57 | - Allow ast transformers to be NodeTransformer subclasses 58 | ([`835600b`](https://github.com/tm-a-t/TGPy/commit/835600bf3e81c260f238b1cfff7b545fd202d251)) 59 | 60 | - Tgpy.dev 🥺 61 | ([`cbc35e8`](https://github.com/tm-a-t/TGPy/commit/cbc35e8449e59bbf0bd78b554a22ed8fa3b646f4)) 62 | 63 | - **Telethon**: Layer 201 64 | ([`17a19a4`](https://github.com/tm-a-t/TGPy/commit/17a19a488efbbea8389d4403684c3b2f07028894)) 65 | 66 | 67 | ## v0.17.1 (2024-12-21) 68 | 69 | ### Bug Fixes 70 | 71 | - **build**: Fix docker build 72 | ([`8afb6f6`](https://github.com/tm-a-t/TGPy/commit/8afb6f6b0cede378bb8c6d4d3fef7a8c130cd967)) 73 | 74 | 75 | ## v0.17.0 (2024-12-21) 76 | 77 | ### Bug Fixes 78 | 79 | - **ci**: Fix semantic release config 80 | ([`98db29c`](https://github.com/tm-a-t/TGPy/commit/98db29c69054f0dfdaee0c91e5d8bdf27a4759c3)) 81 | 82 | - **docker**: Fix restart() failing to start tgpy in docker 83 | ([`5fcd14c`](https://github.com/tm-a-t/TGPy/commit/5fcd14cc33a4c525cbf096443f67ef1e15ac7c84)) 84 | 85 | ### Build System 86 | 87 | - **nix**: Make build use rev from nix 88 | ([`f7dfdde`](https://github.com/tm-a-t/TGPy/commit/f7dfdded228bbdd59728b98d6fb03b960f2fc93a)) 89 | 90 | - **nix**: Update flake.lock 91 | ([`aaa7591`](https://github.com/tm-a-t/TGPy/commit/aaa7591e1ab9078d7315507f5317bf97900ed4a5)) 92 | 93 | - Update nixpkgs in flake.lock - Fix nix evaluation - Update poetry dependencies 94 | 95 | ### Chores 96 | 97 | - Format .nix files 98 | ([`bac06a8`](https://github.com/tm-a-t/TGPy/commit/bac06a8dc7da0362983809870d83e30539b868d8)) 99 | 100 | ### Features 101 | 102 | - Switch to PEP621 compliant pyproject.toml and pyproject.nix 103 | ([`7c20075`](https://github.com/tm-a-t/TGPy/commit/7c200755d3c90c560a99b5836e663f6453aa941f)) 104 | 105 | - Rewrite pyproject.toml to be PEP621 compliant - Update flake.nix to use pyproject.nix - Package 106 | poetry from master, use it in actions 107 | 108 | 109 | ## v0.16.0 (2024-09-26) 110 | 111 | ### Bug Fixes 112 | 113 | - Ctx.is_manual_output is fixed 114 | ([`2e591f5`](https://github.com/tm-a-t/TGPy/commit/2e591f5ff84dd4434356772005e65474371acd55)) 115 | 116 | - Parse_tgpy_message no longer returns positive result for `TGPy error>` messages 117 | ([`818e47d`](https://github.com/tm-a-t/TGPy/commit/818e47dce360240b29e51676633d276480d9b8f8)) 118 | 119 | - Sending `cancel` in comments and topics now works correctly. Fix `cancel` throwing error when the 120 | message is not a TGPy message 121 | ([`1d3ba1a`](https://github.com/tm-a-t/TGPy/commit/1d3ba1ab583f7408f9f2854d22bfcf7fc7e96ae7)) 122 | 123 | ### Code Style 124 | 125 | - Reformat [skip ci] 126 | ([`2b09fb3`](https://github.com/tm-a-t/TGPy/commit/2b09fb378e8054e8379d9695b707ed0d71151d71)) 127 | 128 | ### Documentation 129 | 130 | - Describe loading of API secrets from env in guide 131 | ([`eb2a023`](https://github.com/tm-a-t/TGPy/commit/eb2a0230d4e0096d74781579dc9682f7ed9effd9)) 132 | 133 | ### Features 134 | 135 | - Telegram API ID and hash now can be loaded from environment 136 | ([`9c22347`](https://github.com/tm-a-t/TGPy/commit/9c223473c507257d25a77ecea566d830965d7c8f)) 137 | 138 | 139 | ## v0.15.1 (2024-05-06) 140 | 141 | ### Bug Fixes 142 | 143 | - Config is no longer erased when it fails to save. Also, the config is saved synchronously now 144 | ([`df539a3`](https://github.com/tm-a-t/TGPy/commit/df539a3dd32d73528e9800e2efe7b2099a5aa4a4)) 145 | 146 | - Restart now works correctly when tgpy is not in PYTHONPATH (e.g. in a container) 147 | ([`16d3830`](https://github.com/tm-a-t/TGPy/commit/16d383079b1bd48dabad8c7c562e5835f595d915)) 148 | 149 | - The correct data directory is now used when TGPY_DATA is set to a relative path and restart is 150 | called 151 | ([`f483d6c`](https://github.com/tm-a-t/TGPy/commit/f483d6c1e9b0c1bc0f79f0a31f477637d163d696)) 152 | 153 | 154 | ## v0.15.0 (2024-04-28) 155 | 156 | ### Continuous Integration 157 | 158 | - Release hotfix 159 | ([`8572721`](https://github.com/tm-a-t/TGPy/commit/8572721a486f110561201a4b186dc8d8d7abf773)) 160 | 161 | - **guide**: Add guide dependencies to pyproject.toml, build guide using nix 162 | ([`8686aa0`](https://github.com/tm-a-t/TGPy/commit/8686aa0c8bec91a1587deac32af5dcb442da8f2c)) 163 | 164 | ### Documentation 165 | 166 | - Reset page scale back to normal 167 | ([`20ec126`](https://github.com/tm-a-t/TGPy/commit/20ec1266f03d9f5d0e87deba93bfdf1d43cb1b5d)) 168 | 169 | Bigger scale was rather an experimental change and I'm tired of how it looks :) 170 | 171 | ### Features 172 | 173 | - Cd to DATA_DIR/workdir on tgpy start 174 | ([`f51dc84`](https://github.com/tm-a-t/TGPy/commit/f51dc8477ba116ad9132dd2a65c0f7630415c357)) 175 | 176 | - Real time progress feedback 177 | ([`8e85d7a`](https://github.com/tm-a-t/TGPy/commit/8e85d7a585f89c9c1dc0eba498390ad475e7c439)) 178 | 179 | When stdout.flush() or stderr.flush() is called, the current output will be displayed in the message 180 | that is being evaluated. The message with be updated at most once per 3 seconds. 181 | 182 | - Stop running message execution on `cancel`, add `stop` command to only stop execution without 183 | blacklisting the message 184 | ([`547c1c6`](https://github.com/tm-a-t/TGPy/commit/547c1c6d158e113f9ca63c8d0b87eb69a527d6af)) 185 | 186 | - Truncate exceptions ([#39](https://github.com/tm-a-t/TGPy/pull/39), 187 | [`739fbbc`](https://github.com/tm-a-t/TGPy/commit/739fbbcdc2f96ed54a10620f87049b0107c14cc6)) 188 | 189 | - **Telethon**: Layer 179 190 | ([`010f4ef`](https://github.com/tm-a-t/TGPy/commit/010f4ef4b17b40f46a99119777f216d1ee79debd)) 191 | 192 | ### Refactoring 193 | 194 | - Reactions_fix.update_hash is now called in edit_message 195 | ([`7e9d683`](https://github.com/tm-a-t/TGPy/commit/7e9d6834486819ee5ae6666f174fcdd28d51586f)) 196 | 197 | 198 | ## v0.14.1 (2024-03-09) 199 | 200 | ### Bug Fixes 201 | 202 | - Use cryptg-anyos again, because there are no official cp312-aarch64 binaries 203 | ([`50ca341`](https://github.com/tm-a-t/TGPy/commit/50ca3417c3bd3418bd66a02c52f35e5cf7d83b11)) 204 | 205 | 206 | ## v0.14.0 (2024-03-09) 207 | 208 | ### Bug Fixes 209 | 210 | - Tgpy error when editing MessageService, e.g. when deleting all messages in pm or beating your high 211 | score in games 212 | ([`5d6fb5e`](https://github.com/tm-a-t/TGPy/commit/5d6fb5e9f0b6556163c90c327a4d2ac6afe62b96)) 213 | 214 | ### Build System 215 | 216 | - Build docker image on python 3.12 217 | ([`eec91a9`](https://github.com/tm-a-t/TGPy/commit/eec91a92699f619c2972d6add13dd717236f9345)) 218 | 219 | ### Features 220 | 221 | - **docker**: Run specified commands on container creation. This feature can be used for example to 222 | persist installed packages between updates 223 | ([`456f503`](https://github.com/tm-a-t/TGPy/commit/456f5035ef8f0900750acee2a901cfdcea2e28b6)) 224 | 225 | 226 | ## v0.13.2 (2023-12-10) 227 | 228 | ### Bug Fixes 229 | 230 | - Update telethon (fix draft constructor), update all dependencies 231 | ([`a2b6064`](https://github.com/tm-a-t/TGPy/commit/a2b60641af3a0cab9fef08e95e07447b6c61432a)) 232 | 233 | 234 | ## v0.13.1 (2023-12-06) 235 | 236 | ### Bug Fixes 237 | 238 | - Use official cryptg (prebuilt wheels for Python 3.12), bump telethon (fixes usage of Draft), set 239 | minimum python version to 3.10 240 | ([`9e49739`](https://github.com/tm-a-t/TGPy/commit/9e497391f83dc6b333a6752f221d1221ea3d6cdb)) 241 | 242 | 243 | ## v0.13.0 (2023-12-05) 244 | 245 | ### Features 246 | 247 | - Support proxy 248 | ([`cd6bc90`](https://github.com/tm-a-t/TGPy/commit/cd6bc9086a818c45ee129ace88a0662980ad6c92)) 249 | 250 | 251 | ## v0.12.1 (2023-10-28) 252 | 253 | ### Bug Fixes 254 | 255 | - **Telethon**: New layer fix 256 | ([`8145bd3`](https://github.com/tm-a-t/TGPy/commit/8145bd3c58371e047114e834e2fbdba2de7ec575)) 257 | 258 | 259 | ## v0.12.0 (2023-10-28) 260 | 261 | ### Features 262 | 263 | - **Telethon**: Update to layer 166 264 | ([`a18dc0d`](https://github.com/tm-a-t/TGPy/commit/a18dc0d663362b50dfe7827ebac6ea2971220248)) 265 | 266 | 267 | ## v0.11.0 (2023-09-29) 268 | 269 | ### Features 270 | 271 | - **Telethon**: Fixes for the new layer and many more fixes from upstream 272 | ([`800fcd5`](https://github.com/tm-a-t/TGPy/commit/800fcd5f9799e82ed8a85155afd1c884b22355d3)) 273 | 274 | 275 | ## v0.10.0 (2023-09-25) 276 | 277 | ### Documentation 278 | 279 | - Fix transformer example 280 | ([`8007b25`](https://github.com/tm-a-t/TGPy/commit/8007b2587046550937cc63f7e5cd66f86e928f29)) 281 | 282 | Fix shell commands example in documentation (#35) 283 | 284 | --------- 285 | 286 | Co-authored-by: Artyom Ivanov 287 | 288 | - Recipes and new homepage 289 | ([`28e8eba`](https://github.com/tm-a-t/TGPy/commit/28e8ebae6f57642ce2dea7a70d4bd0bcfee94dc0)) 290 | 291 | - Revert narrowing container 292 | ([`bb0bff9`](https://github.com/tm-a-t/TGPy/commit/bb0bff98b058041a62f45fed260bf1f42ce32900)) 293 | 294 | ### Features 295 | 296 | - **Telethon**: Layer 164 297 | ([`2d0c186`](https://github.com/tm-a-t/TGPy/commit/2d0c1865fad08c8ee1f511f6749a22ede7374641)) 298 | 299 | 300 | ## v0.9.7 (2023-07-23) 301 | 302 | ### Bug Fixes 303 | 304 | - Consistent colors in setup across all terminals 305 | ([`faa625b`](https://github.com/tm-a-t/TGPy/commit/faa625bd2f4543b08ea7af361c8217cf006303d2)) 306 | 307 | 308 | ## v0.9.6 (2023-06-01) 309 | 310 | ### Bug Fixes 311 | 312 | - Strip device model 313 | ([`a775cc5`](https://github.com/tm-a-t/TGPy/commit/a775cc5a6415e92d023c421578f31bdd57a7a88d)) 314 | 315 | 316 | ## v0.9.5 (2023-06-01) 317 | 318 | ### Bug Fixes 319 | 320 | - Try to fix session termination issue by setting device_model and system_version to real values 321 | from the system 322 | ([`44e1c3d`](https://github.com/tm-a-t/TGPy/commit/44e1c3d8faf17f52ec289c3b6c69ae44ed75271e)) 323 | 324 | 325 | ## v0.9.4 (2023-05-05) 326 | 327 | ### Bug Fixes 328 | 329 | - Initial setup can now be interrupted with ctrl+c 330 | ([`e6253c7`](https://github.com/tm-a-t/TGPy/commit/e6253c7573f1eed9bbaefe739a967181a9c932e9)) 331 | 332 | - Initial setup prompts now work properly 333 | ([`8713dff`](https://github.com/tm-a-t/TGPy/commit/8713dff2f216bdadb9b07269c68f909b6867f681)) 334 | 335 | ### Code Style 336 | 337 | - Reformat [skip ci] 338 | ([`d33b9f4`](https://github.com/tm-a-t/TGPy/commit/d33b9f4ffa404708a50ed7643c0e6265df4f40bc)) 339 | 340 | ### Continuous Integration 341 | 342 | - Fix manual release workflow trigger 343 | ([`f44d8a3`](https://github.com/tm-a-t/TGPy/commit/f44d8a3ed5095bae093bd8b4eb26e55175e904ee)) 344 | 345 | 346 | ## v0.9.3 (2023-02-25) 347 | 348 | ### Bug Fixes 349 | 350 | - Deleting message no longer produces an error 351 | ([`ef317d9`](https://github.com/tm-a-t/TGPy/commit/ef317d98b9e817843eca8f0756147855217ccb3b)) 352 | 353 | 354 | ## v0.9.2 (2023-02-25) 355 | 356 | ### Bug Fixes 357 | 358 | - Message editing bug 359 | ([`3d1d566`](https://github.com/tm-a-t/TGPy/commit/3d1d566864381ea939143fae3fad607e03b5a548)) 360 | 361 | 362 | ## v0.9.1 (2023-02-25) 363 | 364 | ### Bug Fixes 365 | 366 | - Update from older versions 367 | ([`df11e8b`](https://github.com/tm-a-t/TGPy/commit/df11e8b0b8801b7c0e47f29b173964a7b63fa887)) 368 | 369 | ### Chores 370 | 371 | - Fix changelog 372 | ([`87438a4`](https://github.com/tm-a-t/TGPy/commit/87438a4b8a36317b0f36708559f09e3d668cb1ce)) 373 | 374 | 375 | ## v0.9.0 (2023-02-25) 376 | 377 | ### Build System 378 | 379 | - Use python-slim image instead of python-alpine 380 | ([`a34bd20`](https://github.com/tm-a-t/TGPy/commit/a34bd20e5a6bd97042c446faaa1b567669b6b32f)) 381 | 382 | ### Documentation 383 | 384 | - Document new features 385 | ([`f10b340`](https://github.com/tm-a-t/TGPy/commit/f10b340fc21cf5f7c492292621b0703fcad2e6a0)) 386 | 387 | - Non-flickery buttons on the index page 388 | ([`a783e1f`](https://github.com/tm-a-t/TGPy/commit/a783e1f0f26ab10796d98191336488f7cfda3c1b)) 389 | 390 | - Update changelog 391 | ([`21abcf0`](https://github.com/tm-a-t/TGPy/commit/21abcf04b2d239d3043f8dc31386e226502e1e96)) 392 | 393 | - Update pages 394 | ([`1a696e3`](https://github.com/tm-a-t/TGPy/commit/1a696e3c9e523bd0532f7a576aec196d6e6d447a)) 395 | 396 | - Update pages 397 | ([`937dc35`](https://github.com/tm-a-t/TGPy/commit/937dc35cd71216c474e2cb7de9056f61c07d1d5e)) 398 | 399 | - Update readme 400 | ([`e446344`](https://github.com/tm-a-t/TGPy/commit/e446344302aac9fafc057f9846dec7b9e902115d)) 401 | 402 | ### Features 403 | 404 | - Change in-message url to tgpy.tmat.me 405 | ([`8737ca9`](https://github.com/tm-a-t/TGPy/commit/8737ca92d600e0f57987426fa0ab9ba2fc655183)) 406 | 407 | - Move tokenize_string and untokenize_to_string to tgpy.api 408 | ([`7d8c3b2`](https://github.com/tm-a-t/TGPy/commit/7d8c3b2cadb46bd7447f9e908da1b9cadfe012e8)) 409 | 410 | 411 | ## v0.8.0 (2023-02-15) 412 | 413 | ### Bug Fixes 414 | 415 | - Don't stop on unhandled errors. They may happen when, for example, a module uses 416 | asyncio.create_task 417 | ([`3584f22`](https://github.com/tm-a-t/TGPy/commit/3584f223064902f8238f35715219289b6a16ea13)) 418 | 419 | ### Features 420 | 421 | - Wrap sys.stdout instead of print to capture output + properly use contextvars 422 | ([`7aa2015`](https://github.com/tm-a-t/TGPy/commit/7aa2015215ad621b405c6471add250bf11aa70c2)) 423 | 424 | 425 | ## v0.7.0 (2023-02-05) 426 | 427 | ### Bug Fixes 428 | 429 | - Handle entities properly when editing "//" message 430 | ([`6d989dc`](https://github.com/tm-a-t/TGPy/commit/6d989dc5349e026338857115d93f5c532f760578)) 431 | 432 | - Specify parse_mode in error handler to support markdown global parse mode 433 | ([`60cd81b`](https://github.com/tm-a-t/TGPy/commit/60cd81b9ca5cbb85ed72808e6ce3954bee455c3a)) 434 | 435 | - Use message.raw_text instead of message.text to detect // 436 | ([`a85b1c8`](https://github.com/tm-a-t/TGPy/commit/a85b1c834135f2b7c251ff8482afbab93f73002e)) 437 | 438 | ### Features 439 | 440 | - Update dependencies (layer 152) 441 | ([`234dc86`](https://github.com/tm-a-t/TGPy/commit/234dc86399c8545b07f55c1cddbcfbabcac2c372)) 442 | 443 | - Update telethon (new markdown parser, html parser fixes) 444 | ([`43dd76f`](https://github.com/tm-a-t/TGPy/commit/43dd76f0953e6ba2e77e9b5bc25f2748a36967ee)) 445 | 446 | 447 | ## v0.6.2 (2023-01-22) 448 | 449 | ### Bug Fixes 450 | 451 | - Fix IndentationError appearing for some non-code messages 452 | ([`599c84f`](https://github.com/tm-a-t/TGPy/commit/599c84f9ba4104d678476b07b090c54529246ab9)) 453 | 454 | - **docker**: Add /venv/bin to path 455 | ([`00be149`](https://github.com/tm-a-t/TGPy/commit/00be149b4ce827e97bb31b84f39cfa988a68d1ee)) 456 | 457 | ### Documentation 458 | 459 | - Update readme intro 460 | ([`c334669`](https://github.com/tm-a-t/TGPy/commit/c334669fcab8a6fc7a3f8330ae3217de21b97430)) 461 | 462 | 463 | ## v0.6.1 (2023-01-06) 464 | 465 | ### Bug Fixes 466 | 467 | - Update command saying "Already up to date" while in fact updating correctly 468 | ([`9b25fe6`](https://github.com/tm-a-t/TGPy/commit/9b25fe60809cfbb045bd605c0fa78f5ae19d57d0)) 469 | 470 | ### Build System 471 | 472 | - Update deps (telethon layer 151 and markup parsing/unparsing fixes) 473 | ([`d06f47e`](https://github.com/tm-a-t/TGPy/commit/d06f47e40acff01baf062adbffb126bff67ee0b2)) 474 | 475 | - Update telethon (layer 150) 476 | ([`b1379fc`](https://github.com/tm-a-t/TGPy/commit/b1379fc2c2ba540afee8ca6b524fb1a3136215ab)) 477 | 478 | ### Chores 479 | 480 | - Add workflow_dispatch event to main workflow 481 | ([`7812289`](https://github.com/tm-a-t/TGPy/commit/7812289775675a8cb5acb7ee3110ed034cb982af)) 482 | 483 | - Fix version 484 | ([`c783bfa`](https://github.com/tm-a-t/TGPy/commit/c783bfa6584190ca3ba07ddab14b029e0044a617)) 485 | 486 | ### Code Style 487 | 488 | - Reformat [skip ci] 489 | ([`9d76376`](https://github.com/tm-a-t/TGPy/commit/9d76376af266c536ff0a024adc365388d39849d4)) 490 | 491 | - Reformat [skip ci] 492 | ([`a542441`](https://github.com/tm-a-t/TGPy/commit/a542441794fadd5facb5a2b9c579a9583156d826)) 493 | 494 | 495 | ## v0.6.0 (2022-11-26) 496 | 497 | ### Bug Fixes 498 | 499 | - Ignore error when running code deletes the message itself 500 | ([`d022450`](https://github.com/tm-a-t/TGPy/commit/d0224502c5393dc423c9790e88fedc94bbb5afbf)) 501 | 502 | - Keep 'cancel' message when replying to other user (fixes #21) 503 | ([`057231d`](https://github.com/tm-a-t/TGPy/commit/057231d7a437d5ab4fca280b130714573904a67e)) 504 | 505 | ### Build System 506 | 507 | - Use cryptg-anyos, which provides prebuilt wheels for musllinux 508 | ([`7d85c71`](https://github.com/tm-a-t/TGPy/commit/7d85c718e46a1ed3896eaeada4fd2e32cab9b1b3)) 509 | 510 | ### Code Style 511 | 512 | - Reformat [skip ci] 513 | ([`bcf54a4`](https://github.com/tm-a-t/TGPy/commit/bcf54a48db3c954c769b8fe04cbb5dfb30354c2b)) 514 | 515 | - Reformat [skip ci] 516 | ([`9853be4`](https://github.com/tm-a-t/TGPy/commit/9853be43f77243b7eb483f923c26912456fac8be)) 517 | 518 | ### Documentation 519 | 520 | - Update readme 521 | ([`a321bbf`](https://github.com/tm-a-t/TGPy/commit/a321bbfe474e8ae97132a7f0f4d11c009495c482)) 522 | 523 | - add links to badges - change introduction 524 | 525 | ### Features 526 | 527 | - Use MessageEntityPre with language set to 'python' to enable syntax highlighting on supported 528 | clients (e.g. WebZ). Closes #24 529 | ([`5de6ded`](https://github.com/tm-a-t/TGPy/commit/5de6ded579c237f0221b6223dfd18254a2ebb1cd)) 530 | 531 | ### Refactoring 532 | 533 | - Remove pydantic 534 | ([`6256c89`](https://github.com/tm-a-t/TGPy/commit/6256c894a85254e8dc2409f0961eee43beaa6d93)) 535 | 536 | 537 | ## v0.5.1 (2022-08-09) 538 | 539 | ### Bug Fixes 540 | 541 | - Compatibility with python 3.9 542 | ([`f3c0468`](https://github.com/tm-a-t/TGPy/commit/f3c046847f9d7f549586c829dbec74238f264ed2)) 543 | 544 | - Restart() now edits message properly 545 | ([`60afa44`](https://github.com/tm-a-t/TGPy/commit/60afa44e7002fa87a5c0d2adb60c2065e885f1ec)) 546 | 547 | ### Chores 548 | 549 | - Fix readme 550 | ([`769b903`](https://github.com/tm-a-t/TGPy/commit/769b903cb952687b9c198889256636d5fcf6d2a2)) 551 | 552 | ### Continuous Integration 553 | 554 | - Build docker image with proper tgpy version 555 | ([`d0f4969`](https://github.com/tm-a-t/TGPy/commit/d0f4969f5447b47385aab805296195edb8ef7bf7)) 556 | 557 | - Tag docker images by release versions 558 | ([`f8710c7`](https://github.com/tm-a-t/TGPy/commit/f8710c77b4f3d40bcdd43a6bc6f954a2ffb10243)) 559 | 560 | 561 | ## v0.5.0 (2022-08-08) 562 | 563 | ### Bug Fixes 564 | 565 | - Apply autoawait pre_transform after regular code transformers 566 | ([`fde6291`](https://github.com/tm-a-t/TGPy/commit/fde62914540d43da01a02e547f2e62516f7cf52e)) 567 | 568 | - Message markup for utf-16 (closes #10) 569 | ([`20f48bc`](https://github.com/tm-a-t/TGPy/commit/20f48bc8e08490e85ff68bfe6feb9997eb8cbb29)) 570 | 571 | - Parsing of modules with triple quotes in code 572 | ([`485166d`](https://github.com/tm-a-t/TGPy/commit/485166d5c513e196c0db760c468599d3c6ab9581)) 573 | 574 | - Setting/removing reaction no longer triggers reevaluation 575 | ([`cf6e64e`](https://github.com/tm-a-t/TGPy/commit/cf6e64e82d1823202941610274a4ff38955c5cf1)) 576 | 577 | however, when TGPy is restarted you will need to edit old messages twice to reevaluate 578 | 579 | - Use custom telethon version without MessageBox as it's very buggy 580 | ([`9c7738e`](https://github.com/tm-a-t/TGPy/commit/9c7738e40cda69499974ceda711f56ca65782312)) 581 | 582 | ### Build System 583 | 584 | - Add guide dist directory to gitignore 585 | ([`40578e2`](https://github.com/tm-a-t/TGPy/commit/40578e2f2e069e47ca194fa3105e5d0a8b029d02)) 586 | 587 | - Add venv to .dockerignore 588 | ([`2f94d3c`](https://github.com/tm-a-t/TGPy/commit/2f94d3cf74648b1c601014509b9c18c3be232a91)) 589 | 590 | - Dockerize 591 | ([`f8cb28d`](https://github.com/tm-a-t/TGPy/commit/f8cb28dd54722c047934e41071cc32ddc6f81cdd)) 592 | 593 | ### Code Style 594 | 595 | - Reformat [skip ci] 596 | ([`373ee12`](https://github.com/tm-a-t/TGPy/commit/373ee12cfc7e1faa889dba4999d6f14aad10084d)) 597 | 598 | - Reformat [skip ci] 599 | ([`1204457`](https://github.com/tm-a-t/TGPy/commit/120445711a0aa64bf00480e270e4de258c2a0439)) 600 | 601 | ### Continuous Integration 602 | 603 | - Build docker image 604 | ([`9380141`](https://github.com/tm-a-t/TGPy/commit/938014176febd77488170b32026081fa95c5cdd2)) 605 | 606 | - Trigger main workflow on pyproject.toml and poetry.lock changes 607 | ([`30c08ce`](https://github.com/tm-a-t/TGPy/commit/30c08ce97659a5f896083777fd20e5f63e79c8df)) 608 | 609 | ### Documentation 610 | 611 | - Change readme assets. new video 612 | ([`d0b3565`](https://github.com/tm-a-t/TGPy/commit/d0b35657f4f98542eb6408fb2c21420e19034f5c)) 613 | 614 | - Fix some typos here and there in the guides ([#26](https://github.com/tm-a-t/TGPy/pull/26), 615 | [`9d6c8a8`](https://github.com/tm-a-t/TGPy/commit/9d6c8a83c054eb4e54866f120355c44de88458b2)) 616 | 617 | - Guide changes 618 | ([`b000c12`](https://github.com/tm-a-t/TGPy/commit/b000c12cbbbdaad5f2c02452dd6e2f9b9710e840)) 619 | 620 | - Guide changes 621 | ([`237a1cd`](https://github.com/tm-a-t/TGPy/commit/237a1cdd69cd5770d995f26305ae0d51d4db5dbd)) 622 | 623 | - Guide changes 624 | ([`43a0779`](https://github.com/tm-a-t/TGPy/commit/43a0779c45c0f01fa2c70ff8797332aa0b11a100)) 625 | 626 | - Guide changes 627 | ([`f432d30`](https://github.com/tm-a-t/TGPy/commit/f432d30a2a860f2a1969d9d7f9de55d34cdec140)) 628 | 629 | - Guide changes. many changes. 630 | ([`74ae524`](https://github.com/tm-a-t/TGPy/commit/74ae524b7fa5f554e02ace092fd9951663c78a95)) 631 | 632 | - Guide changes: 633 | ([`767473b`](https://github.com/tm-a-t/TGPy/commit/767473bdfdfebecc4e2998f0cc9b7bf7bdebecf9)) 634 | 635 | - Guide changes: 636 | ([`024bce3`](https://github.com/tm-a-t/TGPy/commit/024bce351a4784dc32cef66a7d6c4dcd146d2ffb)) 637 | 638 | - Guide changes: 639 | ([`9e7f832`](https://github.com/tm-a-t/TGPy/commit/9e7f832c5be8a0126efc1ae47afbbe6fc8ea9042)) 640 | 641 | - Guide color palette & guide changes 642 | ([`6320934`](https://github.com/tm-a-t/TGPy/commit/63209349449efe8ece23c4dcccce7585e24b1bd7)) 643 | 644 | - Guide text changes 645 | ([`903963a`](https://github.com/tm-a-t/TGPy/commit/903963a170ff5965c9044643c14b1ce41879cd15)) 646 | 647 | - Guide theme changes 648 | ([`33dcbb0`](https://github.com/tm-a-t/TGPy/commit/33dcbb0ae247e17b09d9deb817d3b7ba733d8fb9)) 649 | 650 | - New video on docs page 651 | ([`9abf1eb`](https://github.com/tm-a-t/TGPy/commit/9abf1eb9293313e22d68b57c3c1948365a9a7ed9)) 652 | 653 | - Readme video 654 | ([`59a2f12`](https://github.com/tm-a-t/TGPy/commit/59a2f121339f146dcb9b509450bdff8f1af36866)) 655 | 656 | - Readme video fix 657 | ([`383fe5b`](https://github.com/tm-a-t/TGPy/commit/383fe5bcfa8f9b1274b113ab426e1f08138dfdfd)) 658 | 659 | - Restructure guide 660 | ([`8cee429`](https://github.com/tm-a-t/TGPy/commit/8cee42932b69dc8b55098ae747e7c2d99306fa19)) 661 | 662 | - Update readme 663 | ([`a23c076`](https://github.com/tm-a-t/TGPy/commit/a23c0765d1f83f0cb41d8297d8218d8078d0a7d4)) 664 | 665 | - add badges - update project description 666 | 667 | - Update readme 668 | ([`5569ccb`](https://github.com/tm-a-t/TGPy/commit/5569ccbb5fa601ff9b0c8cb4011dce5ea93eab9f)) 669 | 670 | ### Features 671 | 672 | - Allow to specify data directory via environment variable TGPY_DATA 673 | ([`4d769da`](https://github.com/tm-a-t/TGPy/commit/4d769daea76bc1abe86914487f9a80d3ea0eb2fb)) 674 | 675 | - Better version detection, ping() builtin improvements 676 | ([`265b83f`](https://github.com/tm-a-t/TGPy/commit/265b83f0c604b96ae740e06a32441b7e001bac1a)) 677 | 678 | - Distinguish between dev and release version by using IS_DEV_BUILD constant in tgpy/version.py. 679 | It's set to False only during release build process. - Allow version detection in docker container 680 | by using COMMIT_HASH constant in tgpy/version.py, which is set during docker build - 681 | utils.get_version() tries to get version from (in order): __version__ variable, commit hash from 682 | `git rev-parse`, commit hash from COMMIT_HASH constant - update() builtin now shows friendly 683 | message when: running in docker, git is not installed, installation method is unknown (not pypi 684 | package, not docker image, not git) 685 | 686 | - Transform x.await into await x 687 | ([`6117421`](https://github.com/tm-a-t/TGPy/commit/6117421cc7b72c56dace006d2fc569edfe14b734)) 688 | 689 | - Use the latest telethon from v1.24 branch, update all dependencies 690 | ([`91894fc`](https://github.com/tm-a-t/TGPy/commit/91894fc6894e5e111baa469c3de372b46e62b049)) 691 | 692 | 693 | ## v0.4.1 (2022-01-10) 694 | 695 | ### Bug Fixes 696 | 697 | - **code detection**: Ignore messages like "fix: fix" 698 | ([`1b73815`](https://github.com/tm-a-t/TGPy/commit/1b73815928fdbdae3eae1202c01b4b53b9906ba4)) 699 | 700 | 701 | ## v0.4.0 (2022-01-10) 702 | 703 | ### Bug Fixes 704 | 705 | - Ctx.msg now always points to message from which code is executed 706 | ([`59acde9`](https://github.com/tm-a-t/TGPy/commit/59acde9ec7baef5ff130d6fb77d74c2981bd15e2)) 707 | 708 | - Data directory path on Windows 709 | ([`7d0e283`](https://github.com/tm-a-t/TGPy/commit/7d0e2835b8c2f5b6327b012a5c56035a63433ba9)) 710 | 711 | - Print now always writes to message from which code is executed 712 | ([`0e46527`](https://github.com/tm-a-t/TGPy/commit/0e46527446749dd691263069def260ae29453077)) 713 | 714 | - **code detection**: Ignore messages like "cat (no)" and "fix: fix" 715 | ([`75bb43e`](https://github.com/tm-a-t/TGPy/commit/75bb43eae71f9e024a3e7f299cd0614c860c2457)) 716 | 717 | ### Code Style 718 | 719 | - Reformat [skip ci] 720 | ([`769c4ca`](https://github.com/tm-a-t/TGPy/commit/769c4ca06f4c9ad1faa9054f1b7b842587234527)) 721 | 722 | ### Continuous Integration 723 | 724 | - Add isort 725 | ([`c03578c`](https://github.com/tm-a-t/TGPy/commit/c03578c1d1b7a463a2e853fe4d892cde3bd5c586)) 726 | 727 | - Black config in pyproject.toml 728 | ([`81ff388`](https://github.com/tm-a-t/TGPy/commit/81ff3881178df8e42650ef59e35b29c02a082873)) 729 | 730 | - Deploy guide to Netlify 731 | ([`52eb160`](https://github.com/tm-a-t/TGPy/commit/52eb1605951f8c95db90a6ed19021dc438815bc8)) 732 | 733 | - Don't trigger main workflow when guide workflow changes, fix cache key 734 | ([`91c7f9c`](https://github.com/tm-a-t/TGPy/commit/91c7f9cca9fbf911254afe436c713ee2934855ed)) 735 | 736 | - **guide**: Disable commit/pull request comments 737 | ([`a1c1a81`](https://github.com/tm-a-t/TGPy/commit/a1c1a81d36ce77a892879437c767277ef3a3437a)) 738 | 739 | - **main**: Fix cache key 740 | ([`109048e`](https://github.com/tm-a-t/TGPy/commit/109048e1b6fc26cd85a2b619bffaff6ca2f435e2)) 741 | 742 | - **main**: Format with black 743 | ([`357c8ca`](https://github.com/tm-a-t/TGPy/commit/357c8caba4082fb9d1192bcb67626c2f95abe74b)) 744 | 745 | ### Documentation 746 | 747 | - Installation with pip & other readme changes 748 | ([`b220b94`](https://github.com/tm-a-t/TGPy/commit/b220b9451a7e882817292ea7ccfe2c4be9739741)) 749 | 750 | - Readme & guide updates 751 | ([`59a4036`](https://github.com/tm-a-t/TGPy/commit/59a40360ed3c0c6315f933dc3871c147bcf4bcd2)) 752 | 753 | - add guide link to readme - 'hooks' are replaced with 'modules' - API page 754 | 755 | ### Features 756 | 757 | - Multiple improvements 758 | ([`6b9cbda`](https://github.com/tm-a-t/TGPy/commit/6b9cbdaf79b11cd1e5922999f96e9321a2df4051)) 759 | 760 | - hooks are now modules - new module format (.py file with yaml metadata in docstring) - refactor 761 | some code - add API for modifying tgpy behaviour accessible via `tgpy` variable - modules.add now 762 | changes hook if exists, instead of completely overwriting - modules.add now uses 763 | `tgpy://module/module_name` as origin - modules['name'] can now be used instead of 764 | Module.load('name') - 'name' in modules can now be used to check if module exists 765 | 766 | 767 | ## v0.3.0 (2021-12-26) 768 | 769 | ### Features 770 | 771 | - **update**: Show when no updates are available 772 | ([`62145ff`](https://github.com/tm-a-t/TGPy/commit/62145ff10215e25793e49d7a83d350d665946fce)) 773 | 774 | 775 | ## v0.2.3 (2021-12-26) 776 | 777 | ### Bug Fixes 778 | 779 | - **update**: Try both regular installation and --user installation 780 | ([`50ffbe9`](https://github.com/tm-a-t/TGPy/commit/50ffbe94da5f8e061326be492f064c891bb63817)) 781 | 782 | 783 | ## v0.2.2 (2021-12-26) 784 | 785 | ### Bug Fixes 786 | 787 | - **update**: Use --user installation when updating 788 | ([`1902672`](https://github.com/tm-a-t/TGPy/commit/19026724dbe26e29562e580d187575c774125da8)) 789 | 790 | ### Refactoring 791 | 792 | - Remove broken migrate_config function 793 | ([`c8f976c`](https://github.com/tm-a-t/TGPy/commit/c8f976cfb726a35481126c67558dbb5cc95fd38c)) 794 | 795 | 796 | ## v0.2.1 (2021-12-26) 797 | 798 | ### Bug Fixes 799 | 800 | - Store data in system config dir instead of module directory 801 | ([`7d92544`](https://github.com/tm-a-t/TGPy/commit/7d9254425e72640bce07a06205cb0fb692b72250)) 802 | 803 | - Update from pypi, if installed as package 804 | ([`a80b78f`](https://github.com/tm-a-t/TGPy/commit/a80b78fccc710b902b5264a738451b52765f49a5)) 805 | 806 | ### Build System 807 | 808 | - Remove obsolete requirements.txt [skip ci] 809 | ([`902e389`](https://github.com/tm-a-t/TGPy/commit/902e389235a5d63f00f47169ff77393682bd483b)) 810 | 811 | ### Chores 812 | 813 | - **changelog**: Remove old generated changelog [skip ci] 814 | ([`e6740e7`](https://github.com/tm-a-t/TGPy/commit/e6740e7978d3f0130db9df91cc7eddd9c2d32859)) 815 | 816 | ### Continuous Integration 817 | 818 | - Fixes, [skip release] tag 819 | ([`5bacb86`](https://github.com/tm-a-t/TGPy/commit/5bacb86f069ad11b7f81dd498668a704a6866fac)) 820 | 821 | - Remove [skip release] tag, trigger only on changes in tgpy/ or .github/ 822 | ([`27a2b48`](https://github.com/tm-a-t/TGPy/commit/27a2b48290de87cc2e939cd636e54e5635d1d3f3)) 823 | 824 | 825 | ## v0.2.0 (2021-12-25) 826 | 827 | ### Bug Fixes 828 | 829 | - 'msg' stands for Message instead of events.NewMessage 830 | ([`428b08b`](https://github.com/tm-a-t/TGPy/commit/428b08b48131c24274a33329dbed7d3ffd3f6ce8)) 831 | 832 | - App.run_code.parse_code: attr.attr.attr was a false positive 833 | ([`5cb7e28`](https://github.com/tm-a-t/TGPy/commit/5cb7e2819a1c4a3e22df5c1a414fe2e04ed1d961)) 834 | 835 | - Change return value only if it's not inside other function 836 | ([`553f864`](https://github.com/tm-a-t/TGPy/commit/553f8646fca74ddfdc447ff93d63d628de74898b)) 837 | 838 | - Check for forward, via and out in all handlers 839 | ([`d189c08`](https://github.com/tm-a-t/TGPy/commit/d189c0838cddad15a633f489429acafd6b9bdb24)) 840 | 841 | - Code detection 842 | ([`b668329`](https://github.com/tm-a-t/TGPy/commit/b6683292239bcd9c66c92c16f98ec301c135ee41)) 843 | 844 | Include ctx, msg, print and client to locals to improve code detection 845 | 846 | - Code like *"constant", *name or *attr.ibute is ignored 847 | ([`739c80e`](https://github.com/tm-a-t/TGPy/commit/739c80e56c87042e6e1803d51fe5d22d840bc891)) 848 | 849 | - Convert old config 850 | ([`10e0285`](https://github.com/tm-a-t/TGPy/commit/10e028596f45fb67fac70f7a23fa34a9003884c0)) 851 | 852 | - Disable link preview when editing 853 | ([`703ea27`](https://github.com/tm-a-t/TGPy/commit/703ea273387269c33d01d6307364aecb56af2c37)) 854 | 855 | - Disable reevaluation of edited messages in broadcast channels 856 | ([`62c3006`](https://github.com/tm-a-t/TGPy/commit/62c30060b510cd8c149da6507b9e4b5938fecc57)) 857 | 858 | - Do not stringify TLObjects in iterable eval results 859 | ([`fdec6d4`](https://github.com/tm-a-t/TGPy/commit/fdec6d47a3fe61080a3c7fc61dabf08705a003bf)) 860 | 861 | - Don't count -1 as code 862 | ([`eab27f6`](https://github.com/tm-a-t/TGPy/commit/eab27f60103a0db9ecb76a9c6c7e9e7280edf5c6)) 863 | 864 | A single expr which is unary operator with constant operand is no longer a code! 865 | 866 | - Edit message after restart 867 | ([`44b5d07`](https://github.com/tm-a-t/TGPy/commit/44b5d0755cb812a6651620a2470384c2a7f2833d)) 868 | 869 | - Editting message caused error 870 | ([`b171036`](https://github.com/tm-a-t/TGPy/commit/b171036b2d107469f5bbf5a1226206843815604c)) 871 | 872 | - Empty return 873 | ([`cab3635`](https://github.com/tm-a-t/TGPy/commit/cab3635e85b9151aa22211ca887c9ca40318a530)) 874 | 875 | - False positive code detections (closes #4) 876 | ([`c8f25d4`](https://github.com/tm-a-t/TGPy/commit/c8f25d4899f51d07cc47de2ddcc0c3c5ec22d0b8)) 877 | 878 | - Get rid of for ... in range(len(root.body)) 879 | ([`ceff1de`](https://github.com/tm-a-t/TGPy/commit/ceff1de8a43054b6a03b7f9ae114f45a0863de86)) 880 | 881 | - Make cancel command case-insensitive 882 | ([`b938ea3`](https://github.com/tm-a-t/TGPy/commit/b938ea3621caca4279e52788ea828cf42d212dd0)) 883 | 884 | - Make update sync 885 | ([`73f96a1`](https://github.com/tm-a-t/TGPy/commit/73f96a1e4b1125107ee7ced4d524e12b802b2732)) 886 | 887 | - Messageemptyerror on '//', closes #13 888 | ([`a2d8883`](https://github.com/tm-a-t/TGPy/commit/a2d8883768f2ccbf0db915902830d801edcbd7c3)) 889 | 890 | - Meval.shallow_walk - ignore async function definitions too 891 | ([`5e5f04e`](https://github.com/tm-a-t/TGPy/commit/5e5f04ebea285da3db688d566be6feb403cb8b72)) 892 | 893 | - Move session file to data dir 894 | ([`b7db892`](https://github.com/tm-a-t/TGPy/commit/b7db8929be793e75dd77ca67006e89c56d1cc7c6)) 895 | 896 | - No link preview in TGPy error notifications 897 | ([`3af06cd`](https://github.com/tm-a-t/TGPy/commit/3af06cdb625e4db2c8a672cc26e9094771c4ec54)) 898 | 899 | - Remove buggy ctx.orig 900 | ([`f5f31ac`](https://github.com/tm-a-t/TGPy/commit/f5f31ac64887a8f6f984f633a87533af2afbdd31)) 901 | 902 | - Save formatting when prevent evaluation 903 | ([`1f3b3dd`](https://github.com/tm-a-t/TGPy/commit/1f3b3dd587dc5a4938b58a360479cfbbd8d41b6e)) 904 | 905 | - Show commit info in ping message and after update 906 | ([`26c63d4`](https://github.com/tm-a-t/TGPy/commit/26c63d492993784aaa8cca316053be1d040d930b)) 907 | 908 | - Str instead of repr for Context 909 | ([`3c51b72`](https://github.com/tm-a-t/TGPy/commit/3c51b7205d60e0f87b63164d13d386efea0e15d6)) 910 | 911 | - Tgpy will no longer crash if returned coroutine crashes 912 | ([`a4303f3`](https://github.com/tm-a-t/TGPy/commit/a4303f32b624f7ae77d8f9099178b222b078ae21)) 913 | 914 | - Tuples of constants are not evaluated 915 | ([`ecec598`](https://github.com/tm-a-t/TGPy/commit/ecec598eca5be5130a264861938a1db423f6cb21)) 916 | 917 | - Update 918 | ([`846c98b`](https://github.com/tm-a-t/TGPy/commit/846c98b9d57bf9ff19bdc39b24025c6d6f607b22)) 919 | 920 | - Use getpass.getpass to get 2FA password (fixes #7) ([#8](https://github.com/tm-a-t/TGPy/pull/8), 921 | [`858e721`](https://github.com/tm-a-t/TGPy/commit/858e7212082e9b7061d0219403e144224cf7f573)) 922 | 923 | - Using orig var after message edit 924 | ([`7f3108f`](https://github.com/tm-a-t/TGPy/commit/7f3108fc9776330bbe374c1c421ff1cca0162200)) 925 | 926 | ### Build System 927 | 928 | - Switch to poetry, reanme package to tgpy, fix readme images 929 | ([`263bbcc`](https://github.com/tm-a-t/TGPy/commit/263bbcca39b02f38d340262fcd5f34a648a5598c)) 930 | 931 | ### Continuous Integration 932 | 933 | - Automatic semantic releases 934 | ([`2f34246`](https://github.com/tm-a-t/TGPy/commit/2f3424696bbae77004c08f43fe7271355bdfe779)) 935 | 936 | - Debug VIVOD 937 | ([`fb90e12`](https://github.com/tm-a-t/TGPy/commit/fb90e12a77f80e1efa6299ee27b138e4c91fe1fa)) 938 | 939 | - Fix x1 940 | ([`cde7e27`](https://github.com/tm-a-t/TGPy/commit/cde7e2754a0fb813c2ab764adeb93f1746d0c93c)) 941 | 942 | - Fix x1874 943 | ([`378ed92`](https://github.com/tm-a-t/TGPy/commit/378ed92043de25e5f20350e9146e6b287949979d)) 944 | 945 | - Fix x1875 946 | ([`d2eeb60`](https://github.com/tm-a-t/TGPy/commit/d2eeb60cfe0be3c7b5850af1e39fd8d03eec7212)) 947 | 948 | - Fix x1876 949 | ([`cd3f30a`](https://github.com/tm-a-t/TGPy/commit/cd3f30ad3d6cfd518453086c8259a5fae3a37925)) 950 | 951 | - Fix x1877 952 | ([`0a83ec3`](https://github.com/tm-a-t/TGPy/commit/0a83ec368b8e6e095b516af45eb4d74b18f9e296)) 953 | 954 | - Fix x2 955 | ([`d53cf91`](https://github.com/tm-a-t/TGPy/commit/d53cf91478b35244548f35ad3cf4c48880bf8980)) 956 | 957 | - Fix x3 958 | ([`8858a69`](https://github.com/tm-a-t/TGPy/commit/8858a69a8aab05547570e7280a44ab9212fcfcd5)) 959 | 960 | - Fix x4 961 | ([`d6e7852`](https://github.com/tm-a-t/TGPy/commit/d6e7852bd8d3e308c6a937491715d3dc051cbd3c)) 962 | 963 | - Fix x5 964 | ([`687a7cd`](https://github.com/tm-a-t/TGPy/commit/687a7cddf3a5e3221a0612dab5e2f81e84090fb5)) 965 | 966 | - Fix x6 967 | ([`df75fe8`](https://github.com/tm-a-t/TGPy/commit/df75fe86f8bdc29ad3ba883d932aed5c824d91d7)) 968 | 969 | - Fix x7 970 | ([`7663ce1`](https://github.com/tm-a-t/TGPy/commit/7663ce1d6706d1c511b983f237193f9b7533dc8e)) 971 | 972 | - Fix: deploy guide only from master branch 973 | ([`6a99cb1`](https://github.com/tm-a-t/TGPy/commit/6a99cb159abf87ee85d88b22925bcaecc60b0026)) 974 | 975 | - Github................. ne materus 976 | ([`54153e5`](https://github.com/tm-a-t/TGPy/commit/54153e5929de376e4fe1f69cd4aaf8b820bd49ab)) 977 | 978 | - Init ([`8c06daf`](https://github.com/tm-a-t/TGPy/commit/8c06daf990e2ce6a6b5bcb3df2707579df36d068)) 979 | 980 | - Ne nu ya dazhe debug vivod ne can sdelat( 981 | ([`21a0074`](https://github.com/tm-a-t/TGPy/commit/21a0074b0317814dbc758e690d262b866d3bd4d9)) 982 | 983 | ### Documentation 984 | 985 | - Copied readme to index.md 986 | ([`50e7879`](https://github.com/tm-a-t/TGPy/commit/50e7879812695d983a1c08ea824afb75073a739f)) 987 | 988 | ### Features 989 | 990 | - __repr__ of Context, ping() function 991 | ([`a1a1443`](https://github.com/tm-a-t/TGPy/commit/a1a1443a5a266457e77506a7d19b1564687393d5)) 992 | 993 | - App object and config loading 994 | ([`ae9bd17`](https://github.com/tm-a-t/TGPy/commit/ae9bd176e33b9f325beb8949685a1f07b64095b3)) 995 | 996 | Load config before setting Telethon client. 997 | 998 | - Cancel without reply 999 | ([`2c77e6f`](https://github.com/tm-a-t/TGPy/commit/2c77e6f5907937e9ad91b5bfc5ac24d10e80e85e)) 1000 | 1001 | - Changes of custom hook functions 1002 | ([`3ed8822`](https://github.com/tm-a-t/TGPy/commit/3ed882290fb358a2b1415aa81e89b0e43b430d98)) 1003 | 1004 | - Ctx variable 1005 | ([`80503a8`](https://github.com/tm-a-t/TGPy/commit/80503a866510b74222b9dd2cb9cde3c84056de7d)) 1006 | 1007 | - Context class for ctx variable (with ctx.msg for current msg and ctx.orig for current orig) - 1008 | run_code.utils file for auxiliary functions and classes 1009 | 1010 | - Docstrings for app/run_code/parse_code.py 1011 | ([`b6d283a`](https://github.com/tm-a-t/TGPy/commit/b6d283aed3e6a714ca70d44a93f46087a6bd450b)) 1012 | 1013 | - Exception formatting 1014 | ([`1417583`](https://github.com/tm-a-t/TGPy/commit/141758336a2f7354f4a3e795f793c187725d3458)) 1015 | 1016 | - Only show evaluating levels related to code - Start lines with 'File "" ...' 1017 | 1018 | - If message with code is deleted, ignore error on the result editing 1019 | ([`7872678`](https://github.com/tm-a-t/TGPy/commit/78726786fcf2ba6288752e6ab0fd7eff5e54c8b4)) 1020 | 1021 | - If result is None, show output instead of result 1022 | ([`bc8f1ce`](https://github.com/tm-a-t/TGPy/commit/bc8f1ce865acd87032cc4bc43df9659c1c930cee)) 1023 | 1024 | - Make code detection less strict 1025 | ([`c86d842`](https://github.com/tm-a-t/TGPy/commit/c86d842eb5759b0e4c7ea92f8dc5bd2e86edc6a3)) 1026 | 1027 | - binary operations with constants (like "1 - 2", but not "1" or "+1") are considered code now - if 1028 | a variable which is present in locals() appears in the message, it **is** evaluated 1029 | 1030 | - Preparing for PyPI publication & single command configuration 1031 | ([`2e7e3ca`](https://github.com/tm-a-t/TGPy/commit/2e7e3ca82bbc7778cbe3a347c7c79c559200e99f)) 1032 | 1033 | - add `rich` console - create app_config.py and Config class - add required files for PyPI - rename 1034 | utils.py 1035 | 1036 | - Pretty cli setup and logs 1037 | ([`f440ea4`](https://github.com/tm-a-t/TGPy/commit/f440ea42e2e98a16442f6c5b563f5d37ad11e771)) 1038 | 1039 | - Pretty logging 1040 | ([`f7d1b60`](https://github.com/tm-a-t/TGPy/commit/f7d1b605c3022bcb96423c2d3d2ad615b6018a56)) 1041 | 1042 | - Run using aiorun 1043 | ([`28e95b5`](https://github.com/tm-a-t/TGPy/commit/28e95b5430097f4cb93fd561dbc616431054fd6d)) 1044 | 1045 | - Save hook datetime, run hooks in order of addition 1046 | ([`e6d2cea`](https://github.com/tm-a-t/TGPy/commit/e6d2cea9b8f13356b109e3a12472f53cb312421d)) 1047 | 1048 | - Show 'tgpy' package version if it's installed 1049 | ([`d9fbf77`](https://github.com/tm-a-t/TGPy/commit/d9fbf77e6e7bc3eaf2f2e591286dacd96601c6ab)) 1050 | 1051 | - Show username and hostname in ping() 1052 | ([`df7722c`](https://github.com/tm-a-t/TGPy/commit/df7722c2a202df65de6e3905d639ffe9789ab16b)) 1053 | 1054 | - Update command 1055 | ([`a87a803`](https://github.com/tm-a-t/TGPy/commit/a87a8030cd27d2d7122d7562478a363279077f13)) 1056 | 1057 | - User hooks addition and removal 1058 | ([`0abb062`](https://github.com/tm-a-t/TGPy/commit/0abb0628d08e02bd7a93a93aa35c38b91b18fc98)) 1059 | 1060 | ### Refactoring 1061 | 1062 | - 'cancel' command 1063 | ([`4696022`](https://github.com/tm-a-t/TGPy/commit/46960222e0291312b5a11c4a91a8f92d7bc22c53)) 1064 | 1065 | - App/run_code/parse_code.py: shorter lines, better function names 1066 | ([`fc0e66d`](https://github.com/tm-a-t/TGPy/commit/fc0e66d0fb32e0a9b632f5bfcd0de6435091a620)) 1067 | 1068 | - Meval 1069 | ([`187b738`](https://github.com/tm-a-t/TGPy/commit/187b73889ef3aa41f835f89b61896625e2fe3ed5)) 1070 | 1071 | - Meval.py changes 1072 | ([`d84202a`](https://github.com/tm-a-t/TGPy/commit/d84202a7311b1ac22b6a6762e088bb1ab1a23548)) 1073 | 1074 | - Project structure 1075 | ([`97cad96`](https://github.com/tm-a-t/TGPy/commit/97cad961662a732a0c2a84cd998205e3ba1acfd2)) 1076 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.3 2 | FROM python:3.12-slim as base 3 | WORKDIR /app 4 | 5 | FROM base as builder 6 | RUN apt-get update \ 7 | && apt-get install -y git \ 8 | && rm -rf /var/lib/apt/lists/* 9 | 10 | ENV PIP_DISABLE_PIP_VERSION_CHECK=1 11 | 12 | RUN --mount=type=cache,target=/root/.cache/pip \ 13 | pip install poetry~=2.0 \ 14 | && pip install poetry-plugin-export 15 | RUN python -m venv /venv 16 | 17 | COPY pyproject.toml poetry.lock LICENSE ./ 18 | RUN --mount=type=cache,target=/root/.cache/pip \ 19 | poetry export -o /tmp/requirements.txt && /venv/bin/pip install -r /tmp/requirements.txt 20 | 21 | COPY . . 22 | RUN sed -i "s/\(COMMIT_HASH *= *\).*/\1'$(git rev-parse HEAD)'/" tgpy/version.py 23 | RUN rm -rf .git guide poetry.lock pyproject.toml .dockerignore .gitignore README.md 24 | 25 | FROM base as runner 26 | COPY --from=builder /venv /venv 27 | ENV PATH="/venv/bin:$PATH" 28 | 29 | COPY --from=builder /app /app 30 | 31 | ENV TGPY_DATA=/data 32 | ENV PYTHONPATH=/app 33 | VOLUME /data 34 | 35 | ENTRYPOINT ["/app/entrypoint.sh"] 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Artyom Ivanov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TGPy 2 | 3 | **Runs Python code snippets within your Telegram messages** 4 | 5 | [![PyPI - Downloads](https://img.shields.io/pypi/dm/tgpy?style=flat-square)](https://pypi.org/project/tgpy/) 6 | [![PyPI](https://img.shields.io/pypi/v/tgpy?style=flat-square&color=9B59B6)](https://pypi.org/project/tgpy/) 7 | [![Docker Image Version (latest semver)](https://img.shields.io/docker/v/tgpy/tgpy?style=flat-square&label=docker&sort=semver&color=9B59B6)](https://hub.docker.com/r/tgpy/tgpy) 8 | [![Open issues](https://img.shields.io/github/issues-raw/tm-a-t/TGPy?style=flat-square)](https://github.com/tm-a-t/TGPy/issues) 9 | [![Docs](https://img.shields.io/website?style=flat-square&label=docs&url=https%3A%2F%2Ftgpy.dev)](https://tgpy.dev/) 10 | 11 |
12 | 13 | Guide: https://tgpy.dev/guide 14 | 15 | Recipes: https://tgpy.dev/recipes 16 | 17 | Discussion: https://t.me/tgpy_flood 18 | 19 |
20 | 21 | https://user-images.githubusercontent.com/38432588/181266550-c4640ff1-71f2-4868-ab83-6ea3690c01b6.mp4 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | ## Quick Start 30 | 31 | Python 3.10+ required. Install using pipx: 32 | 33 | ```shell 34 | pipx install tgpy 35 | tgpy 36 | ``` 37 | 38 | or Docker: 39 | 40 | ```shell 41 | docker pull tgpy/tgpy 42 | docker run -it --rm -v /tgpy_data:/data tgpy/tgpy 43 | ``` 44 | 45 | Then follow instructions to connect your Telegram account. 46 | 47 | More on installation: [https://tgpy.dev/installation](https://tgpy.dev/installation) 48 | 49 | Next, learn TGPy basics: [https://tgpy.dev/basics](https://tgpy.dev/basics) 50 | 51 | ## Use Cases 52 | 53 | Here are a few examples of how people use TGPy: 54 | 55 | 🧮 Run Python as an in-chat calculator 56 | 57 | 🔍 Search for song lyrics within a chat 58 | 59 | 🧹 Delete multiple messages with a command 60 | 61 | 📊 Find out the most active members in a chat 62 | 63 | ✏️ Instantly convert TeX to Unicode in messages:
For example, `x = \alpha^7` becomes `x = α⁷` 64 | 65 | ## About 66 | 67 | TGPy allows you to easily write and execute code snippets directly within your Telegram messages. Combine Telegram 68 | features with the full power of Python: Integrate with libraries and APIs. Create functions and TGPy modules to reuse 69 | code in the future. Set up code transformers and hooks to create custom commands and tweak Python syntax. 70 | 71 | TGPy uses Telegram API through the [Telethon](https://github.com/LonamiWebs/Telethon) library. 72 | 73 | ## Inspiration and Credits 74 | 75 | TGPy is inspired by [FTG](https://gitlab.com/friendly-telegram/friendly-telegram) and similar userbots. However, the key 76 | concept is different: TGPy is totally based on usage of code in Telegram rather than plugging extra modules. It was 77 | designed for running single-use scripts and reusing code flexibly. You can think of TGPy as **a userbot for programmers 78 | **. 79 | 80 | We built TGPy with [Telethon](https://github.com/LonamiWebs/Telethon), a Python library to interact with Telegram API. 81 | Basic code transformation (such as auto-return of values) is based on [meval](https://github.com/penn5/meval). 82 | 83 | TGPy Docs use [Material for MKDocs](https://squidfunk.github.io/mkdocs-material/) with custom CSS. 84 | 85 | ## License 86 | 87 | This project is licensed under the terms of the MIT license. 88 | 89 | 90 | -------------------------------------------------------------------------------- /docker/install_mods.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from subprocess import Popen 3 | 4 | from tgpy.api.config import config 5 | 6 | logging.basicConfig( 7 | format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', 8 | datefmt='%Y-%m-%d %H:%M:%S', 9 | level=logging.INFO, 10 | ) 11 | logger = logging.getLogger('install_mods') 12 | 13 | 14 | def main(): 15 | config.load() 16 | command_groups: dict = config.get('docker.setup_commands', {}) 17 | command_groups['_core'] = 'apt-get update' 18 | for name, commands in sorted(command_groups.items()): 19 | if isinstance(commands, str): 20 | commands = [commands] 21 | logger.info(f"Running setup command group '{name}'") 22 | for command in commands: 23 | logger.info(f"Running setup command '{command}'") 24 | p = Popen(command, shell=True) 25 | p.wait() 26 | if p.returncode != 0: 27 | logger.info('Running setup command failed') 28 | 29 | 30 | if __name__ == '__main__': 31 | main() 32 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | if [ ! -f container_setup_completed ]; then 4 | PYTHONPATH=. python /app/docker/install_mods.py 5 | touch container_setup_completed 6 | fi 7 | 8 | exec python -m tgpy 9 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1736143030, 9 | "narHash": "sha256-+hu54pAoLDEZT9pjHlqL9DNzWz0NbUn8NEAHP7PQPzU=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "b905f6fc23a9051a6e1b741e1438dbfc0634c6de", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "hercules-ci", 17 | "repo": "flake-parts", 18 | "type": "github" 19 | } 20 | }, 21 | "nixpkgs": { 22 | "locked": { 23 | "lastModified": 1736798957, 24 | "narHash": "sha256-qwpCtZhSsSNQtK4xYGzMiyEDhkNzOCz/Vfu4oL2ETsQ=", 25 | "owner": "NixOS", 26 | "repo": "nixpkgs", 27 | "rev": "9abb87b552b7f55ac8916b6fc9e5cb486656a2f3", 28 | "type": "github" 29 | }, 30 | "original": { 31 | "owner": "NixOS", 32 | "ref": "nixos-unstable", 33 | "repo": "nixpkgs", 34 | "type": "github" 35 | } 36 | }, 37 | "nixpkgs-lib": { 38 | "locked": { 39 | "lastModified": 1735774519, 40 | "narHash": "sha256-CewEm1o2eVAnoqb6Ml+Qi9Gg/EfNAxbRx1lANGVyoLI=", 41 | "type": "tarball", 42 | "url": "https://github.com/NixOS/nixpkgs/archive/e9b51731911566bbf7e4895475a87fe06961de0b.tar.gz" 43 | }, 44 | "original": { 45 | "type": "tarball", 46 | "url": "https://github.com/NixOS/nixpkgs/archive/e9b51731911566bbf7e4895475a87fe06961de0b.tar.gz" 47 | } 48 | }, 49 | "pyproject-nix": { 50 | "inputs": { 51 | "nixpkgs": [ 52 | "nixpkgs" 53 | ] 54 | }, 55 | "locked": { 56 | "lastModified": 1736836246, 57 | "narHash": "sha256-bFvBMziYvFtB/Hly+O4WtBGeiDoz7eb2dVQbOvIrHHM=", 58 | "owner": "pyproject-nix", 59 | "repo": "pyproject.nix", 60 | "rev": "3db43c7414fce4ce94ca67545233d251d306385a", 61 | "type": "github" 62 | }, 63 | "original": { 64 | "owner": "pyproject-nix", 65 | "repo": "pyproject.nix", 66 | "type": "github" 67 | } 68 | }, 69 | "root": { 70 | "inputs": { 71 | "flake-parts": "flake-parts", 72 | "nixpkgs": "nixpkgs", 73 | "pyproject-nix": "pyproject-nix", 74 | "treefmt-nix": "treefmt-nix" 75 | } 76 | }, 77 | "treefmt-nix": { 78 | "inputs": { 79 | "nixpkgs": [ 80 | "nixpkgs" 81 | ] 82 | }, 83 | "locked": { 84 | "lastModified": 1736154270, 85 | "narHash": "sha256-p2r8xhQZ3TYIEKBoiEhllKWQqWNJNoT9v64Vmg4q8Zw=", 86 | "owner": "numtide", 87 | "repo": "treefmt-nix", 88 | "rev": "13c913f5deb3a5c08bb810efd89dc8cb24dd968b", 89 | "type": "github" 90 | }, 91 | "original": { 92 | "owner": "numtide", 93 | "repo": "treefmt-nix", 94 | "type": "github" 95 | } 96 | } 97 | }, 98 | "root": "root", 99 | "version": 7 100 | } 101 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | description = "Run Python code right in your Telegram messages"; 3 | 4 | inputs = { 5 | flake-parts.url = "github:hercules-ci/flake-parts"; 6 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 7 | pyproject-nix = { 8 | url = "github:pyproject-nix/pyproject.nix"; 9 | inputs.nixpkgs.follows = "nixpkgs"; 10 | }; 11 | treefmt-nix = { 12 | url = "github:numtide/treefmt-nix"; 13 | inputs.nixpkgs.follows = "nixpkgs"; 14 | }; 15 | }; 16 | 17 | outputs = 18 | inputs@{ self, flake-parts, ... }: 19 | flake-parts.lib.mkFlake { inherit inputs; } ( 20 | { lib, ... }: 21 | { 22 | imports = [ 23 | ./nix/treefmt.nix 24 | ]; 25 | 26 | systems = [ 27 | "x86_64-linux" 28 | "aarch64-linux" 29 | "x86_64-darwin" 30 | "aarch64-darwin" 31 | ]; 32 | 33 | flake.lib.project = inputs.pyproject-nix.lib.project.loadPyproject { 34 | pyproject = lib.pipe ./pyproject.toml [ 35 | lib.readFile 36 | (lib.replaceStrings [ "cryptg-anyos" ] [ "cryptg" ]) 37 | builtins.fromTOML 38 | ]; 39 | }; 40 | 41 | perSystem = 42 | { config, pkgs, ... }: 43 | let 44 | python = pkgs.python3.override { 45 | packageOverrides = import ./nix/mkPackageOverrides.nix { inherit pkgs; }; 46 | }; 47 | packageAttrs = import ./nix/mkPackageAttrs.nix { 48 | inherit (self.lib) project; 49 | inherit pkgs python; 50 | rev = self.rev or null; 51 | }; 52 | in 53 | { 54 | packages = { 55 | tgpy = python.pkgs.buildPythonPackage packageAttrs; 56 | default = config.packages.tgpy; 57 | }; 58 | 59 | devShells.default = pkgs.mkShell { 60 | packages = [ 61 | pkgs.poetry 62 | pkgs.ruff 63 | pkgs.isort 64 | (python.withPackages (self.lib.project.renderers.withPackages { inherit python; })) 65 | ]; 66 | }; 67 | }; 68 | } 69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /guide/docs/assets/TGPy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tm-a-t/TGPy/2e1ed0cf44405762e79429d684b2b1addf267892/guide/docs/assets/TGPy.png -------------------------------------------------------------------------------- /guide/docs/assets/chatgpt1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tm-a-t/TGPy/2e1ed0cf44405762e79429d684b2b1addf267892/guide/docs/assets/chatgpt1.jpg -------------------------------------------------------------------------------- /guide/docs/assets/chatgpt2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tm-a-t/TGPy/2e1ed0cf44405762e79429d684b2b1addf267892/guide/docs/assets/chatgpt2.jpg -------------------------------------------------------------------------------- /guide/docs/assets/example.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tm-a-t/TGPy/2e1ed0cf44405762e79429d684b2b1addf267892/guide/docs/assets/example.mp4 -------------------------------------------------------------------------------- /guide/docs/assets/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tm-a-t/TGPy/2e1ed0cf44405762e79429d684b2b1addf267892/guide/docs/assets/example.png -------------------------------------------------------------------------------- /guide/docs/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tm-a-t/TGPy/2e1ed0cf44405762e79429d684b2b1addf267892/guide/docs/assets/icon.png -------------------------------------------------------------------------------- /guide/docs/basics/asyncio.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: 'TGPy has special asyncio features: top-level async/await, property-like syntax, and auto-awaiting functions.' 3 | --- 4 | 5 | # Asyncio 6 | 7 | ## Not familiar with asyncio? 8 | 9 | In order to send messages, get info about chats, and use other Telegram features through TGPy, you should understand Python 10 | asynchronous functions. 11 | 12 | Basically, asynchronous function is a function that runs until 13 | completion while not blocking other code parts. It is a feature of modern Python versions. 14 | 15 | Let’s say you need to use such function in your TGPy message. To do that, you should place the `await` keyword before: 16 | otherwise, the function won’t run. 17 | 18 | ```python 19 | result = await some_function() 20 | ``` 21 | 22 | This way the code snippet will be suspended until `some_function()` ends, but TGPy itself won’t stop (for instance, 23 | the code from another message may run at the same time.) 24 | 25 | If you declare some function which uses asynchronous functions, your function must be asynchronous too; for that 26 | use `async def` instead of `def`. 27 | 28 | To learn more about Python `async`/`await`, you may read [explanation by Tiangolo written for FastAPI](https://fastapi.tiangolo.com/async/#technical-details) — or google something else about it :) 29 | 30 | ## Asyncio in TGPy 31 | 32 | You can use top-level `async`/`await` in TGPy code: 33 | 34 | ```python 35 | import asyncio 36 | 37 | await asyncio.sleep(10) 38 | print('Done!') 39 | ``` 40 | 41 | TGPy provides a shortcut to use `await` as an attribute: 42 | 43 | ```python 44 | import asyncio 45 | 46 | asyncio.sleep(10).await 47 | print('Done!') 48 | ``` 49 | 50 | In addition, TGPy automatically awaits the last returned value (if needed). Therefore, you may omit `await` 51 | in simple cases: 52 | 53 | ```python 54 | import asyncio 55 | 56 | asyncio.sleep(10) 57 | ``` 58 | -------------------------------------------------------------------------------- /guide/docs/basics/code.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: TGPy runs code when you send it to any chat. It also supports all Python capabilities and has features for convenient usage. 3 | --- 4 | 5 | # Running code 6 | 7 | ## How to use TGPy 8 | 9 | Open any chat, type some Python code and send it. It’s that simple. 10 | 11 |
12 | ```python 13 | 2 + 2 14 | ``` 15 |
16 | ``` 17 | 4 18 | ``` 19 |
20 | 21 |
22 | ```python 23 | s = 0 24 | for i in range(100): 25 | s += i 26 | s 27 | ``` 28 |
29 | ``` 30 | 4950 31 | ``` 32 |
33 | 34 | If you edit your message, TGPy will recalculate the result. 35 | 36 | When TGPy mistakes your plain-text message for code, type [`cancel`](/reference/code_detection/#cancel-evaluation) to 37 | fix that. 38 | 39 | !!! tip 40 | 41 | You can experiment with TGPy in [Saved Messages](tg://resolve?domain=TelegramTips&post=242). Nobody else will see that ;) 42 | 43 | ## Power of Python 44 | 45 | All Python features are available, including **module imports** and **function definitions**. Moreover, you can use 46 | most of Telegram features, such as sending messages. You’ll learn more about them later in the guide. 47 | 48 | ## Code result 49 | 50 | You can explicitly return values in messages: 51 | 52 |
53 | ```python 54 | x = 2 * 2 55 | return x 56 | ``` 57 |
58 | ``` 59 | 4 60 | ``` 61 |
62 | 63 | Otherwise, all computed values will be returned automatically: 64 | 65 |
66 | ```python 67 | x = 10 68 | x * 7 69 | x + 20 70 | ``` 71 |
72 | ``` 73 | [70, 30] 74 | ``` 75 |
76 | 77 | You can also print values. The `print` function is redefined, so that the output is added to the message. 78 | 79 |
80 | ```python 81 | print('Hello World!') 82 | ``` 83 |
84 | ``` 85 | Hello World! 86 | ``` 87 |
88 | 89 | Exceptions are also shown right in the message. 90 | 91 | !!! note 92 | 93 | Long messages might be truncated because of Telegram limit of 4096 symbols per message. 94 | 95 | ## More tips 96 | 97 | - TGPy saves the defined variables, so you use them in further messages 98 | - The `_` variable contains the result of the previous message 99 | - Edit the message to rerun it 100 | -------------------------------------------------------------------------------- /guide/docs/basics/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: These are TGPy code examples that use simple features. Copy an example and send it somewhere to run! 3 | --- 4 | 5 | # Examples 6 | 7 | Copy an example and send it somewhere to run! 8 | 9 | These examples show how you can use TGPy in various ways. 10 | When you get used to it, you will be able to quickly write code snippets for your needs. 11 | 12 | By the way, if you want to delete the code message before running the code, start it with `#!python await msg.delete()`. 13 | 14 | ## Sending messages 15 | 16 | ### Auto-laugh 17 | 18 | ```python 19 | text = 'ha' * 20 20 | await msg.respond(text) 21 | ``` 22 | 23 | ### Countdown 24 | 25 | Send numbers from 10 to 1, at a one second interval: 26 | 27 | ```python 28 | import asyncio 29 | 30 | for i in range(10): 31 | await msg.respond(str(10 - i)) 32 | await asyncio.sleep(1) 33 | ``` 34 | 35 | ### Message typing animation 36 | 37 | Send a message and edit it several times, adding letters one by one: 38 | 39 | ```python 40 | import asyncio 41 | 42 | text = 'Hello World' 43 | message = await msg.respond('...') 44 | for i in range(len(text)): 45 | await message.edit(text[:i + 1] + '|') 46 | await asyncio.sleep(0.5) 47 | ``` 48 | 49 | ### Send a copy 50 | 51 | Send a copy of the message to another chat: 52 | 53 | ```python 54 | message = orig 55 | await client.send_message('Example Chat', message) 56 | return 'Sent the message' 57 | ``` 58 | 59 | ## More Telegram features 60 | 61 | ### Download a picture or file 62 | 63 | Download a picture from a message to the TGPy directory. Reply to the message with the following: 64 | 65 | ```python 66 | await orig.download_media('example.jpg') 67 | ``` 68 | 69 | ### Send a picture or file 70 | 71 | ```python 72 | await msg.respond(file='example.jpg') # You can also pass URL here 73 | return 74 | ``` 75 | 76 | ### Delete recent messages from the chat 77 | 78 | Delete all messages starting with the message you‘re replying to and ending with the current message: 79 | 80 | === "From all users" 81 | 82 | ```python 83 | messages = await client.get_messages( 84 | msg.chat, 85 | min_id=orig.id - 1, 86 | max_id=msg.id 87 | ) 88 | await client.delete_messages(msg.chat, messages) 89 | ``` 90 | 91 | === "From a specified user" 92 | 93 | ```python hl_lines="5" 94 | messages = await client.get_messages( 95 | msg.chat, 96 | min_id=orig.id - 1, 97 | max_id=msg.id, 98 | from_user='John Doe' 99 | ) 100 | await client.delete_messages(msg.chat, messages) 101 | ``` 102 | 103 | !!! note 104 | 105 | Of course, TGPy can delete messages only if you have the permission, for instance if you’re a group admin. 106 | 107 | ### List your drafts 108 | 109 | Print all chats where you have any drafts: 110 | 111 | ```python 112 | async for draft in client.iter_drafts(): 113 | title = getattr(draft.entity, 'title', None) # if this is a group or a channel 114 | name = getattr(draft.entity, 'first_name', None) # if this is a user 115 | print(name or title) 116 | ``` 117 | 118 | ### Kick a user from the chat 119 | 120 | This works only if you’re a chat admin. Ban a user and remove them from the blacklist, so that they can join the chat 121 | again: 122 | 123 | ```python 124 | await client.kick_participant(msg.chat, 'John Doe') 125 | return 'Bye!' 126 | ``` 127 | 128 | Use `#!python 'me'` instead of the name to leave. 129 | 130 | ## Integrations 131 | 132 | ### Run shell commands on the host 133 | 134 | ```python 135 | import subprocess 136 | 137 | command = 'echo Hello World' 138 | process = subprocess.run(command, shell=True, capture_output=True) 139 | print(process.stdout.decode()) 140 | print(process.stderr.decode()) 141 | ``` 142 | 143 | ### Send a plot rendered by matplotlib 144 | 145 | [Example taken from matplotlib docs](https://matplotlib.org/stable/gallery/lines_bars_and_markers/simple_plot.html) 146 | 147 | ```python 148 | import matplotlib.pyplot as plt 149 | import numpy as np 150 | 151 | # Data for plotting 152 | t = np.arange(0.0, 2.0, 0.01) 153 | s = 1 + np.sin(2 * np.pi * t) 154 | 155 | fig, ax = plt.subplots() 156 | ax.plot(t, s) 157 | 158 | ax.set(xlabel='time (s)', ylabel='voltage (mV)', 159 | title='About as simple as it gets, folks') 160 | ax.grid() 161 | 162 | fig.savefig('test.png') 163 | 164 | # Send the plot 165 | await msg.reply(file='test.png') 166 | return 167 | ``` 168 | -------------------------------------------------------------------------------- /guide/docs/basics/messages.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: You can send and edit messages from your TGPy code through Telethon methods. Learn Telethon basics to control Telegram messages, users and chats. 3 | --- 4 | 5 | # Messages 6 | 7 | ## Telegram objects 8 | 9 | TGPy is based on **Telethon**, a Telegram API client library. You can 10 | use [Telethon objects and methods](https://docs.telethon.dev/en/stable/quick-references/objects-reference.html) 11 | for messages, users and chats. This page explains how to perform basic message actions, such as sending and editing. 12 | 13 | ??? tldr "Already familiar with Telethon?" 14 | 15 | Already familiar with Telethon? 16 | 17 | All you need to know is that in TGPy you can use the following objects: 18 | 19 | - `client` for the Telethon client 20 | - `msg` for the current message 21 | - `orig` for the message you’re replying to 22 | 23 | See the [Builtin reference](/reference/builtins/#telethon-objects) for details. 24 | 25 | Now you can skip the rest of the page and go to the [examples](/basics/examples) :) 26 | 27 | In TGPy messages, you can always use some Telegram objects. The `client` object is helpful for general functionality, 28 | such as sending messages, listing chats and so on. The `msg` object always refers to the current message. 29 | 30 | ## Sending a message 31 | 32 | The simplest Telegram action is sending a message. There is a method for that: 33 | 34 | ```python 35 | await client.send_message(chat, text) 36 | ``` 37 | 38 | chat can be either a chat name, a username, or an ID. For example, you can refer to the current chat 39 | with `msg.chat_id`. Hence, you can send a «Hello World» as following: 40 | 41 | ```python 42 | await client.send_message(msg.chat_id, "Hello World") 43 | ``` 44 | 45 | Or use a shortcut for this exact action: 46 | 47 | ```python 48 | await msg.respond("Hello World") 49 | ``` 50 | 51 | You can also use `msg.reply` instead of `msg.respond` to send the message as a reply, rather than just send it to the 52 | chat. 53 | 54 | !!! note 55 | 56 | The code above returns the new message. For now, TGPy shows the full info for the returned message, which may be 57 | very long to display. You can add a `#!python return` to suppress it: 58 | 59 | ```python 60 | await msg.respond("Hello World") 61 | return 62 | ``` 63 | 64 | ## Reusing messages 65 | 66 | The new message object can be used later: 67 | 68 | ```python 69 | hello = await msg.respond("Hello") # (1)! 70 | await hello.edit("Hiiiiiiiiii") 71 | ``` 72 | 73 | 1. `hello` is now the new message object 74 | 75 | You can use message properties such as `message.text`, `message.chat`, `message.sender` and others. 76 | 77 | There are also message methods for common actions, such as `message.edit()`, `message.delete()`, `message.forward_to()` 78 | , `message.pin()` and so on. 79 | 80 | Have fun :) 81 | 82 | !!! note 83 | 84 | Check out Telethon reference for details: 85 | 86 | - [Message attributes](https://docs.telethon.dev/en/stable/quick-references/objects-reference.html#message) 87 | 88 | - [Client attributes](https://docs.telethon.dev/en/stable/quick-references/client-reference.html) 89 | 90 | ??? example "Example: show full info about a message" 91 | 92 | ```python 93 | return msg 94 | 95 | TGPy> Message( 96 | id=77305, 97 | peer_id=PeerChannel( 98 | channel_id=1544471292 99 | ), 100 | date=datetime.datetime(2021, 10, 31, 11, 20, 28, tzinfo=datetime.timezone.utc), 101 | message='return msg', 102 | out=True, 103 | mentioned=False, 104 | media_unread=False, 105 | silent=False, 106 | post=False, 107 | from_scheduled=False, 108 | legacy=False, 109 | edit_hide=False, 110 | pinned=False, 111 | from_id=PeerUser( 112 | user_id=254210206 113 | ), 114 | fwd_from=None, 115 | via_bot_id=None, 116 | reply_to=None, 117 | media=None, 118 | reply_markup=None, 119 | entities=[ 120 | ], 121 | views=None, 122 | forwards=None, 123 | replies=MessageReplies( 124 | replies=0, 125 | replies_pts=87625, 126 | comments=False, 127 | recent_repliers=[ 128 | ], 129 | channel_id=None, 130 | max_id=None, 131 | read_max_id=None 132 | ), 133 | edit_date=None, 134 | post_author=None, 135 | grouped_id=None, 136 | restriction_reason=[ 137 | ], 138 | ttl_period=None 139 | ) 140 | ``` 141 | 142 | ## Getting the original message 143 | 144 | The `orig` variable is a shortcut for the message you are replying to. 145 | 146 | For example, you can reply with this to get the uppercased text of the message: 147 | 148 | ```python 149 | orig.text.upper() 150 | ``` 151 | 152 | When your message is not a reply, the `orig` object is None. 153 | -------------------------------------------------------------------------------- /guide/docs/extensibility/api.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Through TGPy API module you can use internal features such as internal config object and functions to parse and run code. 3 | --- 4 | 5 | # Other API features 6 | 7 | TGPy API allows you to use TGPy internal features in your messages and modules. 8 | 9 | ```python 10 | import tgpy.api 11 | ``` 12 | 13 | ## Config 14 | 15 | `tgpy.api.config` provides you simple key-value store for any data. 16 | The data, as well as some TGPy settings, is saved to [tgpy/](/installation/#data-storage)config.yml. 17 | 18 |
19 | ```python 20 | tgpy.api.config.get(key: str, default: JSON = None) -> JSON 21 | ``` 22 |
23 | ```python 24 | tgpy.api.config.set(key: str, value: JSON) 25 | ``` 26 |
27 | ```python 28 | tgpy.api.config.unset(key: str) 29 | ``` 30 |
31 | ```python 32 | tgpy.api.config.save() 33 | ``` 34 | 35 | 36 | useful when modifying objects acquired via the get method 37 | 38 | 39 |
40 | 41 | ## Code processing 42 | 43 | You can use the following functions to parse and run code. 44 | 45 | ### Parse code 46 | 47 | ```python 48 | async parse_code(text: str) -> ParseResult(is_code: bool, original: str, transformed: str, tree: AST | None) 49 | ``` 50 | 51 | Checks if the given text is code and gives AST and other info 52 | {.code-label} 53 | 54 | ### Parse a message 55 | 56 | ```python 57 | parse_tgpy_message(message: Message) -> MessageParseResult(is_tgpy_message: bool, code: str | None, result: str | None) 58 | ``` 59 | 60 | Splits Telethon message object into TGPy code and result (if present) 61 | {.code-label} 62 | 63 | ### Run code 64 | 65 | ```python 66 | async tgpy_eval(code: str, message: Message = None, *, filename: str = None) -> EvalResult(result: Any, output: str) 67 | ``` 68 | 69 | Runs code and gets the result and the output 70 | {.code-label} 71 | -------------------------------------------------------------------------------- /guide/docs/extensibility/context.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: You can use TGPy Context object to refer to the active TGPy message, disable showing the output, or check if the code is running from a module. 3 | --- 4 | 5 | # Context data 6 | 7 | The `ctx` object stores some information about the context of running the code. 8 | 9 | ## Current message 10 | 11 | The `msg` object always refers to the message where it was used. For 12 | instance, if you use `msg` inside a function, it will refer to the message that defined this function — even if later 13 | you call it from another message. However, sometimes you need to define reusable functions that use the current 14 | message. 15 | 16 | Let’s say we want to define a `cat()` function which sends a cat picture to the chat where you use it. Somehow the 17 | function must use the current chat. You can pass `msg` as an argument, but then it won’t be handy enough for you to call 18 | the function. Instead, you may want to use `ctx.msg` variable. 19 | 20 | `ctx.msg` always contains your TGPy message where the current code is running. With it, you can define `cat()` function 21 | as follows: 22 | 23 | ```python 24 | async def cat(): 25 | cat_url = 'https://cataas.com/cat' # URL for a cat image 26 | await ctx.msg.respond(file=cat_url) 27 | ``` 28 | 29 | ## Original message 30 | 31 | To get the message which `ctx.msg` replies to, use: 32 | 33 | ```python 34 | original = await ctx.msg.get_reply_message() 35 | ``` 36 | 37 | !!! info 38 | 39 | The shortcut `ctx.orig` is planned but not implemented yet. 40 | 41 | ## Set manual output 42 | 43 | Sometimes you want the code from a message to edit the message itself. However, after running the code TGPy 44 | basically changes the message back to the code and the output. 45 | 46 | To prevent TGPy editing the message, you should set: 47 | 48 | ```python 49 | ctx.is_manual_output = True 50 | ``` 51 | 52 | ## Other 53 | 54 | `#!python ctx.is_module` is True if the code runs from a module. 55 | 56 | 57 | -------------------------------------------------------------------------------- /guide/docs/extensibility/module_examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: 'Examples of using Context object and TGPy modules: saving shortcuts for message operations, getting chat stats, sharing modules, and more.' 3 | --- 4 | 5 | # Module examples 6 | 7 | These are examples of using [`ctx`](/extensibility/context) and [modules](/extensibility/modules). 8 | It may be handy to save the functions here as modules to reuse in the future. 9 | 10 | !!! Tip 11 | 12 | Join the Russian-speaking chat [@tgpy_flood](https://t.me/tgpy_flood) to see what modules users share. 13 | 14 | ## Shortcut for deleting messages 15 | 16 | Send `d()` in reply to any message to delete it. 17 | 18 | ```python 19 | async def d(): 20 | original = await ctx.msg.get_reply_message() 21 | await ctx.msg.delete() 22 | await original.delete() 23 | ``` 24 | 25 | ## Shortcut for saving messages 26 | 27 | Send `save()` in reply to any message to forward to Saved Messages. 28 | 29 | ```python 30 | async def save(): 31 | original = await ctx.msg.get_reply_message() 32 | await ctx.msg.delete() 33 | await original.forward_to('me') 34 | ``` 35 | 36 | ## Stats for chat member IDs 37 | 38 | Sort chat members by their IDs. In average, the lower the ID of a user is, the earlier they registered in Telegram. 39 | 40 | ```python 41 | def fullname(user): 42 | return ((user.first_name or '') + ' ' + (user.last_name or '')).strip() or 'Deleted account' 43 | 44 | def idstat(users): 45 | users.sort(key=lambda x: x.id) 46 | return '\n'.join([f'{x.id:>10} {fullname(x)}' for x in users]) 47 | 48 | async def idstatgrp(): 49 | return idstat(await client.get_participants(ctx.msg.chat)) 50 | ``` 51 | 52 | ## Send the source of all your modules 53 | 54 | ```python 55 | from html import escape 56 | 57 | for name in modules: 58 | code = escape(modules[name].code) 59 | await ctx.msg.respond(f'
Module "{name}":\n\n{code}
') 60 | ``` 61 | 62 | ## Process a message with sed 63 | 64 | Use in reply to a message. 65 | 66 | ```python 67 | import subprocess 68 | 69 | async def sed(s): 70 | orig = await ctx.msg.get_reply_message() 71 | text = subprocess.run(["sed", s], input=orig.text, capture_output=True, check=True, encoding="utf-8").stdout 72 | if text == orig.text: 73 | return "(no changes)" 74 | if orig.from_id == ctx.msg.from_id: 75 | await orig.edit(text) 76 | await ctx.msg.delete() 77 | else: 78 | return text 79 | ``` 80 | -------------------------------------------------------------------------------- /guide/docs/extensibility/modules.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Modules help you keep functions, classes, or constants when TGPy restarts. 3 | --- 4 | 5 | # Modules 6 | 7 | You may want to define functions, classes, or constants to reuse later. If you want to keep them when TGPy restarts, 8 | save their definitions to modules. 9 | 10 | Modules are code snippets that run at every startup. 11 | 12 | ## Add a module 13 | 14 | Say TGPy ran your message. Then you can reply to your message with this method: 15 | 16 | ```python 17 | modules.add(name) 18 | ``` 19 | 20 | Alternatively, you can add a module from a string with `#!python modules.add(name, source)`. 21 | 22 | !!! example 23 | 24 | 1. Define a square function: 25 | 26 |
27 | ```python 28 | def square(x): 29 | return x * x 30 | ``` 31 |
32 | ``` 33 | None 34 | ``` 35 |
36 | 37 | 2. Save the definition to modules: 38 | 39 |
40 | ```python 41 | # in reply to the previous message 42 | modules.add('square') 43 | ``` 44 |
45 | ``` 46 | Added module 'square'. 47 | The module will be executed every time TGPy starts. 48 | ``` 49 |
50 | 51 | !!! Info 52 | 53 | If a module with this name already exists, its code will be replaced. 54 | 55 | ## Remove a module 56 | 57 | Remove a module by name: 58 | 59 | ```python 60 | modules.remove(name) 61 | ``` 62 | 63 | ## Manage modules 64 | 65 | Use the string value of `modules` to list all of your modules: 66 | 67 | ```python 68 | modules 69 | ``` 70 | 71 | The `modules` object provides handy ways to manage your modules. You can iterate over it to get names of your 72 | modules or use `modules[name]` to get info about the module. 73 | 74 | ## Storage 75 | 76 | Modules are stored as separate Python files in [tgpy/](/installation/#data-storage)modules directory. You 77 | can safely edit them manually. 78 | 79 | Modules run each time TGPy starts. By default, they run in the order they were added. 80 | 81 | Each module file contains [module metadata](/reference/module_metadata). 82 | 83 | ## Features 84 | 85 | By default, all variables from a module are saved for future use. You can specify ones the with the `__all__` variable. 86 | 87 | ## Standard modules 88 | 89 | TGPy has a number of features implemented via standard modules, such as `#!python ping()` 90 | and `#!python restart()` functions. 91 | You may want to disable these features, for example to reimplement them. Use the `#!python core.disabled_modules` config 92 | key to specify the disabled modules. For example, you can use the following code to disable the `prevent_eval` module which 93 | provides [// and cancel](/reference/code_detection/#cancel-evaluation) features: 94 | 95 | ```python 96 | tgpy.api.config.set('core.disabled_modules', ['prevent_eval']) 97 | ``` 98 | 99 | [All standard modules in the repo](https://github.com/tm-a-t/TGPy/tree/master/tgpy/std) 100 | -------------------------------------------------------------------------------- /guide/docs/extensibility/transformers.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Code transformers, AST transformers, and hooks are advanced TGPy API features that can control code evaluation. 3 | --- 4 | 5 | # Transformers & hooks 6 | 7 | TGPy API allows you to use TGPy internal features in your messages and modules. 8 | 9 | ```python 10 | import tgpy.api 11 | ``` 12 | 13 | Code transformers, AST transformers, and exec hooks are TGPy API features that can control code evaluation. 14 | 15 | Code transformers are most commonly used. 16 | 17 | ## Code transformers 18 | 19 | With code transformers, you can transform the code before TGPy runs it. 20 | This is useful for setting up prefix commands, syntax changes, and more. 21 | 22 | Transformers are functions that take message text and return some modified text. Whenever you send a message, TGPy tries 23 | to apply your code transformers to its text. If the final text is the valid Python code, it runs. 24 | 25 | To create a transformer, you should define a function which takes a string and returns a new string — let’s call 26 | it `func`. Then you should register it as following: 27 | 28 | ```python 29 | tgpy.api.code_transformers.add(name, func) 30 | ``` 31 | 32 | !!! example 33 | 34 | Say you want to run shell commands by starting your message with `.sh`, for example: 35 | 36 | ```shell title="Your message" 37 | .sh ls 38 | ``` 39 | 40 | You can implement this feature by saving a code transformer to a module: 41 | 42 | ```python title="Your module" 43 | import os 44 | import subprocess 45 | import tgpy.api 46 | 47 | def shell(code): 48 | proc = subprocess.run([os.getenv("SHELL") or "/bin/sh", "-c", code], encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 49 | return proc.stdout + (f"\n\nReturn code: {proc.returncode}" if proc.returncode != 0 else "") 50 | 51 | def sh_trans(cmd): 52 | if cmd.lower().startswith(".sh "): 53 | return f"shell({repr(cmd[4:])})" 54 | return cmd 55 | 56 | tgpy.api.code_transformers.add("shell", sh_trans) 57 | ``` 58 | 59 | Code by [@purplesyringa](https://t.me/purplesyringa) 60 | 61 | ## AST transformers 62 | 63 | AST transformers are similar to code transformers, but operate with abstract syntax trees instead of text strings. 64 | 65 | Add an AST transformer: 66 | 67 | ```python 68 | tgpy.api.ast_transformers.add(name, func) 69 | ``` 70 | 71 | First, TGPy applies code transformers. If the transformation result is valid Python code, AST transformers are then 72 | applied. 73 | 74 | ## Exec hooks 75 | 76 | Exec hooks are functions that run before the message is parsed and handled. Unlike transformers, they may edit 77 | the message, delete it, and so on. 78 | 79 | Exec hooks must have the following signature: 80 | 81 | ```python 82 | async hook(message: Message, is_edit: bool) -> Message | bool | None 83 | ``` 84 | 85 | `is_edit` is True if you have edited the TGPy message 86 | {.code-label} 87 | 88 | An exec hook may edit the message using Telegram API methods or alter the message in place. 89 | 90 | If a hook returns Message object or alters it in place, the object is used instead of the original one during the rest 91 | of handling (including calling other hook functions). If a hook returns True or None, execution completes normally. 92 | If a hook returns False, the rest of hooks are executed and then the handling stops without further message 93 | parsing or evaluating. 94 | 95 | Add a hook: 96 | 97 | ```python 98 | tgpy.api.exec_hooks.add(name, func) 99 | ``` 100 | 101 | ## Complete flow 102 | 103 | ``` mermaid 104 | flowchart TB 105 | start(New outgoing message) --> hooks 106 | start2(Outgoing message edited) -- is_edit=True --> hooks 107 | hooks(Exec hooks applied) -- If hooks didn't stop execution --> code 108 | code(Code transformers applied) -- If syntax is correct --> ast 109 | ast(AST transformers applied) -- If not a too simple expression --> run((Code runs)) 110 | 111 | click hooks "#exec-hooks" 112 | click ast "#ast-transformers" 113 | click code "#code-transformers" 114 | ``` 115 | 116 | ## Managing 117 | 118 | TGPy stores transformers and exec hooks in `#!python TransformerStore` objects: `#!python tgpy.api.code_transformers`, 119 | `#!python tgpy.api.ast_transformers` and `#!python tgpy.api.exec_hooks`. 120 | 121 | Each of them represents a list of tuples `#!python (name, func)` or a dict in the form of `#!python {name: func}`. 122 | 123 | While TGPy applies exec hooks in the same order they are listed, transformers are applied in reverse order. 124 | It's done so that the newly added transformers can emit code that uses features of an older transformer. 125 | 126 | Some examples: 127 | 128 |
129 | ```python 130 | tgpy.api.code_transformers 131 | ``` 132 |
133 | ```python 134 | TransformerStore({'postfix_await': .code_trans at 0x7f2db16cd1c0>}) 135 | ``` 136 |
137 | 138 | ```python 139 | tgpy.api.code_transformers.remove('postfix_await') 140 | del tgpy.api.code_transformers['postfix_await'] 141 | tgpy.api.code_transformers['test'] = func 142 | tgpy.api.code_transformers.add('test', func) 143 | tgpy.api.code_transformers.append(('test', func)) 144 | for name, func in tgpy.api.code_transformers: 145 | ... 146 | list(tgpy.api.code_transformers) -> list[tuple[str, function]] 147 | dict(tgpy.api.code_transformers) -> dict[str, function] 148 | ``` 149 | 150 | ## Manual usage 151 | 152 | Apply all your code transformers to a custom text: 153 | 154 | ```python 155 | tgpy.api.code_transformers.apply(text) 156 | ``` 157 | 158 | Apply all your AST transformers to a custom AST: 159 | 160 | ```python 161 | await tgpy.api.ast_transformers.apply(tree) 162 | ``` 163 | 164 | Apply all your exec hooks to a message: 165 | 166 | ```python 167 | await tgpy.api.exec_hooks.apply(message, is_edit) 168 | ``` 169 | 170 | Returns False if any of the hooks returned False or a Message object that should be used instead 171 | of the original one otherwise 172 | {.code-label} 173 | -------------------------------------------------------------------------------- /guide/docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | hide: 3 | - navigation 4 | - toc 5 | --- 6 | 7 |
8 | 9 | # TGPy 10 | 11 | Runs Python code snippets within your Telegram messages 12 | { style="font-size: 1.5em; font-family: var(--md-code-font); margin: -1.5rem 0 3rem" } 13 | 14 | [Guide 15 | --8<-- "snippets/arrow.md" 16 | ](/guide) 17 | [Recipes 18 | --8<-- "snippets/arrow.md" 19 | ](/recipes) 20 | [GitHub 21 | --8<-- "snippets/arrow.md" 22 | ](https://github.com/tm-a-t/TGPy/) 23 | { .home-links } 24 | 25 | 28 | 29 | 30 | --8<-- "README.md:body" 31 | 32 |
33 | -------------------------------------------------------------------------------- /guide/docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: You can install TGPy with pip and run it with a shell command. To update TGPy, use update() function. 3 | --- 4 | 5 | # Installation 6 | 7 | TGPy is a command line application that connects to your account much like a Telegram app on a new device. 8 | 9 | You can install and run TGPy on your computer, but you might have to use a remote server to have TGPy available 24/7. 10 | 11 | !!! warning 12 | 13 | **Make sure you run TGPy on a trusted machine** — that is, no one except you can read TGPy files on the computer. 14 | Anyone with access to TGPy files can steal your Telegram account. 15 | 16 | And the other way round: anyone with access to your Telegram account has access to the machine TGPy is running on. 17 | 18 | It’s recommended to use pipx or Docker. 19 | 20 | ## How to install using pipx 21 | 22 | pipx is a package manager for Python command line applications. 23 | 24 | 1. Make sure you have [Python 3.10 or above](https://www.python.org/) installed. 25 | 26 | 2. Get pipx if you don’t have it: 27 | 28 | === "Ubuntu" 29 | 30 | ```shell 31 | sudo apt update 32 | sudo apt install pipx 33 | pipx ensurepath 34 | ``` 35 | 36 | === "Arch" 37 | 38 | ```shell 39 | sudo pacman -Sy python-pipx 40 | pipx ensurepath 41 | ``` 42 | 43 | === "Fedora" 44 | 45 | ```shell 46 | sudo dnf install pipx 47 | pipx ensurepath 48 | ``` 49 | 50 | === "Other Linux" 51 | 52 | 1. Install `pipx` with your package manager. 53 | 2. 54 | ```shell 55 | pipx ensurepath 56 | ``` 57 | 58 | === "Windows" 59 | 60 | ```shell 61 | python3 -m pip install --user pipx 62 | python3 -m pipx ensurepath 63 | ``` 64 | 65 | === "macOS" 66 | 67 | ```shell 68 | brew install pipx 69 | pipx ensurepath 70 | ``` 71 | 72 | 3. Now install TGPy: 73 | 74 | ```shell 75 | pipx install tgpy 76 | ``` 77 | 78 | 5. And start it: 79 | 80 | ```shell 81 | tgpy 82 | ``` 83 | 84 | 85 | Follow the instructions to connect your Telegram account for the first time. When it’s ready, try sending `ping()` to any chat to check if TGPy is running. 86 | 87 | ## How to install using Docker 88 | 89 | ```shell 90 | docker pull tgpy/tgpy 91 | docker run -it --rm -v /tgpy_data:/data tgpy/tgpy 92 | ``` 93 | 94 | Follow the instructions to connect your Telegram account for the first time. When it’s ready, try sending `ping()` to any chat to check if TGPy is running. 95 | 96 | ## Updating to the latest version 97 | 98 | When new updates arrive, you can get them with a TGPy function or from shell. 99 | 100 | === "From Telegram message" 101 | 102 | ```python 103 | update() 104 | ``` 105 | 106 | === "From shell using pipx" 107 | 108 | ```shell 109 | pipx upgrade tgpy 110 | ``` 111 | 112 | === "From shell using docker" 113 | 114 | ```shell 115 | docker pull tgpy/tgpy 116 | ``` 117 | 118 | Then re-run: 119 | 120 | ```shell 121 | docker run -it --rm -v /tgpy_data:/data tgpy/tgpy 122 | ``` 123 | 124 | ## Running in background 125 | 126 | To get TGPy running in background, you need to additionally configure systemd, docker compose, or similar. 127 | Instructions are coming. 128 | 129 | ## Data storage 130 | 131 | Config, session, and modules are stored in `~/.config/tgpy` directory (unless you’re using Docker.) 132 | You can change this path via `TGPY_DATA` environment variable. 133 | 134 | ## Using proxy 135 | 136 | If you require proxy to connect to Telegram, do the following: 137 | 138 | 1. Launch TGPy and provide api_id and api_hash, then quit. 139 | 2. Open `config.yml` file (see Data storage above) and add your proxy settings here: 140 | ```yaml 141 | core: 142 | api_hash: ... 143 | api_id: ... 144 | proxy: 145 | proxy_type: socks5 146 | addr: ... 147 | port: ... 148 | username: ... 149 | password: ... 150 | ``` 151 | 3. Run TGPy normally 152 | 153 | ## API secrets as environment variables 154 | 155 | It's possible to provide Telegram API ID and hash through environment variables `TGPY_API_ID` and `TGPY_API_HASH`. 156 | -------------------------------------------------------------------------------- /guide/docs/recipes/about.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Recipes are short tutorials and tips on TGPy. They may help you learn new use cases or understand advanced features. 3 | --- 4 | 5 | # About recipes 6 | 7 | Recipes are short TGPy tutorials or use cases. 8 | 9 | They will help you re-implement certain scenarios or better understand TGPy and Telethon features. Recipes contain code 10 | that you can copy easily; feel free to read into the code and change the details. 11 | 12 | Read in any order. For example, start here: 13 | 14 | [Setting up reminders](/recipes/reminders/) 15 | 16 | ## How to add a recipe 17 | 18 | You can suggest your own recipe! [Please comment #36.](https://github.com/tm-a-t/TGPy/issues/36) 19 | -------------------------------------------------------------------------------- /guide/docs/recipes/chatgpt.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: How to implement ChatGPT module for TGPy. 3 | --- 4 | 5 | # Asking ChatGPT from TGPy 6 | 7 | [![author](https://avatars.githubusercontent.com/u/38432588) Artyom Ivanov](https://github.com/tm-a-t) 8 | {.author} 9 | 10 | So, I implemented ChatGPT module for TGPy. 11 | 12 | ChatGPT replies when I start my message with `ai,`: 13 | 14 | ![ChatGPT writes a joke about Python in a Telegram message](/assets/chatgpt1.jpg) 15 | 16 | It also remembers dialog history: 17 | 18 | ![ChatGPT explains its joke in another Telegram message](/assets/chatgpt2.jpg) 19 | 20 | To re-implement this feature, you will need to obtain an API key from [platform.openai.com.](https://platform.openai.com) 21 | 22 | ## 1. Functions 23 | 24 | I wrote a couple of functions: 25 | 26 | - `#!Python ai(text)` — sends a request to ChatGPT. 27 | - `#!Python reset_ai()` — resets dialog history. You will want to reset the history often: API pricing [depends on the length of the dialog.](https://openai.com/pricing) 28 | 29 | The official OpenAI Python library isn‘t async, so I made raw queries with aiohttp (`pip install aiohttp`). 30 | 31 | ```python 32 | import aiohttp 33 | 34 | openai_key = "YOUR_API_KEY_HERE" 35 | 36 | http = aiohttp.ClientSession() 37 | chatgpt_messages = [] 38 | 39 | 40 | async def ai(text: str) -> str: 41 | user_message = {"role": "user", "content": text} 42 | chatgpt_messages.append(user_message) 43 | result = await http.post( 44 | 'https://api.openai.com/v1/chat/completions', 45 | headers={ 46 | 'Content-Type': 'application/json', 47 | 'Authorization': 'Bearer ' + openai_key, 48 | }, 49 | json={ 50 | "model": "gpt-3.5-turbo", 51 | "messages": chatgpt_messages, 52 | }, 53 | ) 54 | try: 55 | answer = result.json().await['choices'][0]['message']['content'] 56 | except Exception as e: 57 | return 'Error: ' + str(e) 58 | assistant_message = {'role': 'assistant', 'content': answer} 59 | chatgpt_messages.append(assistant_message) 60 | return 'ChatGPT:\n' + answer 61 | 62 | 63 | def reset_ai(prompt='You are a helpful assistant'): 64 | chatgpt_messages[:] = [{"role": "system", "content": prompt}] 65 | return 'Cleared ChatGPT dialog' 66 | ``` 67 | 68 | If you want to call GPT-4 instead of ChatGPT, replace `"gpt-3.5-turbo"` with `"gpt-4"`. See 69 | [OpenAI API reference](https://platform.openai.com/docs/api-reference) for details. 70 | 71 | Try it out: run the code above and call, `#!python ai('Hello ChatGPT!')` 72 | 73 | ## 2. Transformer 74 | 75 | To address to ChatGPT by starting your message with `ai,` you will need a simple code transformer: 76 | 77 | ```python 78 | def ai_transformer(text): 79 | prefix = 'ai, ' 80 | if text.startswith(prefix): 81 | text = text.removeprefix(prefix) 82 | return f'ai("{text}")' 83 | return text 84 | 85 | tgpy.api.code_transformers.add('chatgpt', ai_transformer) 86 | ``` 87 | 88 | This function transforms a string like `#!python 'ai, hello'` to code like `#!python ai("hello")`. 89 | By adding it as a transformer, you are applying it to all messages you send. 90 | {.code-label} 91 | 92 | Done! 93 | 94 | Save all the code to a module and it will always work. 95 | 96 | If you occasionally share sources of your modules with other people or publish modules in a git repo, you will want 97 | to store `openai_key` in an environment variable or [`tgpy.api.config`.](/extensibility/api#config) 98 | -------------------------------------------------------------------------------- /guide/docs/recipes/contacts.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: I wrote a TGPy script that adds to contacts all members from a selected group. Now I can see their stories and identify them in other contexts. 3 | --- 4 | 5 | # Auto-adding group members to contacts to see their stories 6 | 7 | [![author](https://avatars.githubusercontent.com/u/38432588) Artyom Ivanov](https://github.com/tm-a-t) 8 | {.author} 9 | 10 | I barely ever add people from Telegram to contacts. I chat in groups and private messages, so I have them in 11 | my Telegram dialog history. 12 | 13 | However, recently Telegram introduced stories. Their visibility is based on saved contacts: you need to have a person 14 | added to contacts if you want to see their stories. 15 | 16 | I felt FOMO... 17 | 18 | So I wanted to massively add all users from my favorite groups to contacts. 19 | 20 | I also didn’t want to turn my contact list to a mess, so contacts should have been categorized. I would mark 21 | their names with hashtags. For example, if I add to contacts _John Doe_ who I know from _Example Chat_, their contact title 22 | will be _John Doe #ExampleChat._ 23 | 24 | Saving contacts with the sources would also help me remember from where I know these people. 25 | 26 | I decided that every contact will have only one hashtag, as I usually know a person from one chat or a set of similar 27 | chats (thus, one hashtag will be enough to identify the person.) 28 | 29 | I only wanted to add people from a few chats I care about and didn’t want to implement regular contact updating. 30 | I just wrote a code that would instantly add to contacts all members of a specified group. 31 | 32 | So here is the code. When I want to use it, I paste it and change `chat` and `mark` variables. 33 | 34 | `chat` can be the chat title, username, or id, whatever; 35 | 36 | `mark` is the hashtag to add to contact names (without '#'). 37 | 38 | ```python 39 | from telethon.tl.functions.contacts import AddContactRequest 40 | 41 | chat = '' 42 | mark = '' 43 | 44 | logs_chat = msg.chat 45 | suffix = ' #' + mark 46 | print('conflicts:') 47 | async for user in client.iter_participants(chat): 48 | if user.contact and not (user.last_name and user.last_name.endswith(suffix)): 49 | print(user.first_name, user.last_name or '') 50 | continue 51 | elif user.contact or user.bot or user.id == msg.sender_id: 52 | continue 53 | 54 | first_name = user.first_name 55 | last_name = (user.last_name or '') + suffix 56 | username = '@' + user.username if user.username else '' 57 | phone = user.phone or '' 58 | 59 | await client( 60 | AddContactRequest( 61 | user, 62 | first_name, 63 | last_name, 64 | phone, 65 | add_phone_privacy_exception=False, 66 | ) 67 | ) 68 | ``` 69 | 70 | The code outputs «conflicts»: list of people who are already in my contacts but are marked with other hashtags or no 71 | hashtag at all. These I handle manually. 72 | 73 | Also, I want to keep my contact list clean, so I manually remove special symbols or emoji from contacts’ names. 74 | -------------------------------------------------------------------------------- /guide/docs/recipes/dice.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: You can use TGPy to throw dice in Telegram and brute-force it to get a fake result. This also works with other emoji such as football and casino. 3 | --- 4 | 5 | # Throwing dice (and faking the result) 6 | 7 | [![author](https://avatars.githubusercontent.com/u/38432588) Artyom Ivanov](https://github.com/tm-a-t) 8 | {.author} 9 | 10 | When you send the 🎲 (dice) emoji in Telegram, it gets animated and shows a random result. 11 | 12 | This trick works similarly on 🎯 🎳 ⚽️ 🏀. However, in this recipe I will refer to all such animation messages 13 | as «dice messages» (they call it all dice in Telegram API.) 14 | 15 | You can send a dice message from TGPy as following: 16 | 17 | ```python 18 | from telethon.tl.types import InputMediaDice as Dice 19 | 20 | await msg.respond(file=Dice('🎲')) 21 | return 22 | ``` 23 | 24 | You can change the emoji to throw something other than a dice. 25 | {.code-label} 26 | 27 | You can't choose the result, because it's generated server-side. You also can't edit a dice message. Nevertheless, you 28 | can send dice and delete them until you get the desired result: 29 | 30 | ```python 31 | from telethon.tl.types import InputMediaDice as Dice 32 | 33 | 34 | async def throw_dice(val): 35 | m = await ctx.msg.respond(file=Dice('🎲')) 36 | while m.media.value != val: 37 | await m.delete() 38 | m = await ctx.msg.respond(file=Dice('🎲')) 39 | ``` 40 | 41 | This will work for about 2 seconds. Chat members will see your messages quickly appear and disappear until you get the 42 | right dice. 43 | 44 | Note that the method works only in groups and channels due to the fact that you can't delete dice messages in direct 45 | messages. 46 | 47 | In terms of emoji other than 🎲, the result is usually represented in the same way. That is, the value is a number from 1 48 | to 6 and 6 is the best result. 49 | 50 | 🎰 (casino) result, however, is the number from 1 to 64 where 64 is the win. 51 | 52 | I don't recommend using the above brute-force method on 🎰, because it will spam sending-deleting messages for a while. 53 | -------------------------------------------------------------------------------- /guide/docs/recipes/editors.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Tips on fixing syntax highlighting and code autocompletion when using code editors for TGPy scripts. 3 | --- 4 | 5 | # Writing TGPy programs in code editors 6 | 7 | [![author](https://avatars.githubusercontent.com/u/38432588) Artyom Ivanov](https://github.com/tm-a-t) 8 | {.author} 9 | 10 | Editing complex code without syntax highlighting or code autocompletion is inconvenient. If you write a large code 11 | snippet for TGPy, you will want to use a code editor instead of the message input field. 12 | 13 | Of course, you can write code in an editor and then paste it to Telegram. However, editors will highlight TGPy-specific 14 | builtins 15 | as undefined and unknown-typed. 16 | 17 | You can deal with this by annotating types of builtin variables. 18 | 19 | Common TGPy builtins include `client`, `msg`, `orig`, and `ctx`. So, code with annotations will look like this: 20 | 21 | ```python 22 | from telethon import TelegramClient 23 | from telethon.tl.custom import Message 24 | from tgpy.context import Context 25 | 26 | client: TelegramClient 27 | msg: Message 28 | orig: Message 29 | ctx: Context 30 | 31 | # Custom code here 32 | ``` 33 | 34 | Write your code, copy it and paste to Telegram: this should work. 35 | 36 | Unfortunately, editors won’t understand syntax tricks such 37 | as [`.await` property-like notation,](/basics/asyncio/#asyncio-in-tgpy) so you should use normal Python syntax. 38 | 39 | By the way, normal Python syntax also forbids top-level await. To avoid syntax errors, you can wrap your code in an 40 | async function. Or give up on hacks and have half of your code underlined as errors. 41 | 42 | Why not. 43 | -------------------------------------------------------------------------------- /guide/docs/recipes/reminders.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: 'Sometimes you will want to send notifications to yourself, such as reminders or logs. There are multiple approaches: scheduling messages, using a bot, or marking chats as unread.' 3 | --- 4 | 5 | # Setting up reminders 6 | 7 | [![author](https://avatars.githubusercontent.com/u/38432588) Artyom Ivanov](https://github.com/tm-a-t) 8 | {.author} 9 | 10 | Sometimes you will want to send notifications to yourself, such as reminders or logs. 11 | 12 | You basically don't get a notification when your TGPy code sends a message to any chat, pretty like you don't get a notification when you send a message from another device. 13 | 14 | There are a few workarounds. 15 | 16 | ## Method 1. Scheduling messages 17 | 18 | Telegram has a built-in feature for scheduling messages. You can try it by opening the app and long-tapping (or right clicking) the «Send» button. 19 | 20 | Luckily, Telegram notifies you whenever any of your scheduled messages was sent. 21 | 22 | That means your TGPy script can postpone a message by a minute rather then send it, and you will get a notification on your devices. 23 | 24 | Saved Messages suit well for scheduling reminders. You can also create a private group or channel to have such notifications there. 25 | 26 | The realization is as easy as adding the `schedule` argument to `client.send_message()` or `msg.respond()`. For example, this is how to schedule a message to Saved Messages: 27 | 28 | ```python 29 | import datetime as dt 30 | 31 | next_minute = dt.datetime.utcnow() + dt.timedelta(minutes=1) 32 | await client.send_message('me', 'Hey there!', schedule=next_minute) 33 | ``` 34 | 35 | ## Method 2. Using a bot 36 | 37 | Direct messages from a bot are the natural way to have notifications. 38 | 39 | It's super-easy to control a bot from TGPy, as Telethon methods can be used for bots as well as user accounts. 40 | 41 | Firstly, you should create a bot with BotFather. Then log in using the bot token and API key that you are already using: 42 | 43 | ```python 44 | from telethon import TelegramClient 45 | bot = TelegramClient('bot', client.API_ID, client.API_KEY) 46 | await bot.start('your_token') 47 | ``` 48 | 49 | As a user, you should start the dialog so that the bot will be able to send you messages. 50 | 51 | The bot can talk now. 52 | 53 | ```python 54 | await bot.send_message(msg.sender_id, 'Hello world') 55 | return 56 | ``` 57 | 58 | Just like this, you can use `bot` with familiar client methods to edit and pin messages, for example. 59 | 60 | ## Bonus method. Marking chats as unread 61 | 62 | Putting «Unread» mark on a chat without notification may also be helpful. 63 | 64 | I used to have TGPy auto-reposting memes from other social media to my channel. My TGPy module sent a message and then marked the chat as unread so I would notice the new messages later. 65 | 66 | To mark a chat as uread, you should use the special API method: 67 | 68 | ```python 69 | from telethon import functions 70 | 71 | await client(functions.messages.MarkDialogUnreadRequest(peer='Example Chat', unread=True)) 72 | ``` 73 | 74 | As usual, `peer` here can be anything tha Telethon can convert to a peer: the title, the username, the id, a chat object and so on. 75 | -------------------------------------------------------------------------------- /guide/docs/reference/builtins.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Reference on built-in functions and objects. 3 | --- 4 | 5 | # Builtins 6 | 7 | ## Control functions 8 | 9 | | Function | Description | 10 | |----------------------|---------------------------------------------------------------------------------------| 11 | | `#!python ping()` | Return basic info about your TGPy instance. Use `ping()` to check if TGPy is running. | 12 | | `#!python restart()` | Restart TGPy. | 13 | | `#!python update()` | Download the latest version of TGPy, update, and restart the instance. | 14 | 15 | ## Telethon objects 16 | 17 | | Object | Description | 18 | |-------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------| 19 | | `#!python client` | The Telethon client. [See Telethon Client reference](https://docs.telethon.dev/en/stable/quick-references/client-reference.html) | 20 | | `#!python msg` | The current message. [See Telethon Message reference](https://docs.telethon.dev/en/stable/quick-references/objects-reference.html#message) | 21 | | `#!python orig` | Original message: the message you replied to. [See Telethon Message reference](https://docs.telethon.dev/en/stable/quick-references/objects-reference.html#message) | 22 | 23 | !!! note 24 | 25 | TGPy fetches `orig` message only if your code uses the `orig` variable. That’s because it requires an additional 26 | request to Telegram API. 27 | 28 | ## Modules 29 | 30 | [Read on modules](/extensibility/modules/) 31 | 32 | | Object | Description | 33 | |----------------------------------------------|------------------------------------------------------------------------------------------------------------| 34 | | `#!python modules` | Object for [module management](/extensibility/modules/#manage-modules). | 35 | | `#!python modules.add(name: str, code: str)` | Add the given code as a module. If `code` isn’t specified, the code from the `orig` message will be added. | 36 | | `#!python modules.remove(name: str)` | Remove the module named `name`. | 37 | 38 | ## Context 39 | 40 | [Read on the `ctx` object](../extensibility/context.md) 41 | 42 | | Object | Description | 43 | |---------------------------------|------------------------------------------------------------------------------------------| 44 | | `#!python ctx.msg` | The message where the code is running. | 45 | | `#!python ctx.is_module` | `True` if the code is running from a module. | 46 | | `#!python ctx.is_manual_output` | Can be set to True to prevent the last message edit by TGPy so you can edit it yourself. | 47 | -------------------------------------------------------------------------------- /guide/docs/reference/code_detection.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Automatic code detection is a unique TGPy design feature. In case of a false positive, you can escape code. You can also save a hook that disables auto-detection forever. 3 | --- 4 | 5 | # Code detection 6 | 7 | ## Cancel evaluation 8 | 9 | Sometimes TGPy processes a message when you don’t mean it. In this case TGPy usually shows a value or an error. 10 | 11 | Send `cancel` to the chat to edit back your latest TGPy message (only if it’s one of the 10 latest in the chat). 12 | 13 | You can also use `cancel` in reply to a specific TGPy message. 14 | 15 | 16 | ## Prevent evaluation 17 | 18 | If you begin your message with `//`, the code won’t run. TGPy will delete the `//` prefix. 19 | 20 | 21 | ## Why use auto-detection? 22 | 23 | We designed TGPy for writing code snippets quickly and sequentially. Bot-like commands, 24 | such as `#!python /run print('Hello World')`, would break the workflow. That's why we made TGPy automatically detect 25 | your messages with syntactically correct Python code. 26 | 27 | It turns out that regular text messages are identified as code pretty rarely. In fact, TGPy ignores too simple 28 | expressions. 29 | 30 | 31 | ??? note "Ignored expressions" 32 | TL;DR: TGPy ignores some simple expressions, which could be email addresses, URLs or several comma- or hyphen-separated words 33 | (as described in [issue 4](https://github.com/tm-a-t/TGPy/issues/4)) 34 | 35 | In this section, an **unknown** variable is one not present in `locals` — that is, one that was not saved in previous 36 | messages and which is not built into TGPy (as `ctx`, `orig`, `msg` and `print` are). Unknown variables' attributes are 37 | also considered unknown. 38 | 39 | **Ignored** expressions are the expressions from the list below: 40 | 41 | * Constants like `1` or `"abcd"` and unknown variables 42 | * Binary operations on unknown variables (recursively, i.e., `a - b -c` is also ignored in case `a`, `b`, or `c` are unknown) 43 | * Unary operations on constants or unknown variables 44 | * Tuples of ignored expressions 45 | * Multiple ignored expressions (i.e. separated by `;` or newline) 46 | 47 | 48 | ## Disable auto-detection 49 | 50 | If you want to disable auto-detection, you can save the following [hook](/extensibility/transformers/#exec-hooks) 51 | as a [module](/extensibility/modules/). 52 | 53 | ```python 54 | import re 55 | import tgpy.api 56 | 57 | CODE_RGX = re.compile(r'\.py[ \n]') # regex for the messages that you want to run 58 | 59 | def hook(msg, is_edit): 60 | if is_edit: 61 | return True 62 | if not CODE_RGX.match(msg.raw_text): 63 | return False 64 | msg.raw_text = CODE_RGX.sub('', msg.raw_text, count=1) 65 | return msg 66 | 67 | tgpy.api.add_exec_hook('no_autodetect', hook) 68 | 69 | __all__ = [] 70 | ``` -------------------------------------------------------------------------------- /guide/docs/reference/module_metadata.md: -------------------------------------------------------------------------------- 1 | --- 2 | description: Reference on module metadata. The metadata stores settings such as module name and execution order. 3 | --- 4 | 5 | # Module metadata 6 | 7 | [Modules are stored as separate Python files.](/extensibility/modules/#storage) Module metadata is 8 | a YAML comment in the beginning of the module file. You can safely edit it manually; the changes will apply 9 | after a restart. 10 | 11 | ## Metadata example 12 | 13 | ```python 14 | """ 15 | name: MyModule 16 | once: false 17 | origin: tgpy://module/MyModule 18 | priority: 1655584820 19 | """ 20 | 21 | # module code goes here 22 | ``` 23 | 24 | ## Fields 25 | 26 | | Key | Description | Default value | 27 | |------------|--------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------| 28 | | `name` | The name of the module | | 29 | | `once` | If `true`, the module will be deleted after running. | `false` | 30 | | `origin` | The string that specifies the origin of the module (used for logs) | `tgpy://module/` | 31 | | `priority` | A number that defines the order in which modules are run at startup. The module with the lowest priority will run first. | timestamp for the time the module was created | 32 | 33 | You can also define custom fields. 34 | 35 | 36 | ## Change as attributes 37 | 38 | You can change the fields by editing the Module object. To set custom fields, use `.extra` dict. For example: 39 | 40 | ```python 41 | m = modules['shell'] 42 | m.once = False 43 | m.priority = 0 44 | m.extra['description'] = 'This module defines shell() function' 45 | m.save() 46 | ``` 47 | 48 | -------------------------------------------------------------------------------- /guide/docs/stylesheets/code_blocks.css: -------------------------------------------------------------------------------- 1 | .tgpy-code-block { 2 | background-color: var(--md-code-bg-color); 3 | } 4 | 5 | .tgpy-code-block--wrap pre { 6 | white-space: pre-line; 7 | } 8 | 9 | .tgpy-code-block > hr { 10 | margin: -1em 0; 11 | } 12 | 13 | .tgpy-code-block > div:last-child > pre > code:before { 14 | content: "TGPy> "; 15 | display: inline; 16 | font-weight: 700; 17 | } 18 | 19 | .code-label { 20 | margin-top: -0.5rem; 21 | opacity: 0.75; 22 | font-size: 80%; 23 | } 24 | 25 | /* 26 | .md-typeset pre>code { 27 | padding: 4em 2em; 28 | } 29 | */ 30 | -------------------------------------------------------------------------------- /guide/docs/stylesheets/custom_theme.css: -------------------------------------------------------------------------------- 1 | /* GENERAL */ 2 | 3 | :root { 4 | --md-primary-fg-color: #06968d; 5 | --md-primary-fg-color--light: #cbfff7; 6 | --md-primary-fg-color--dark: #056375; 7 | --md-accent-fg-color: #aa8f66; 8 | --md-accent-fg-color--transparent: #aa8f661a; 9 | } 10 | 11 | [data-md-color-scheme="default"] { 12 | --md-footer-bg-color: var(--md-primary-fg-color); 13 | } 14 | 15 | [data-md-color-scheme="slate"] { 16 | --md-primary-fg-color: #0dc7bd; 17 | --md-hue: 185; 18 | } 19 | 20 | ::selection { 21 | background-color: #e2d9ca; 22 | } 23 | 24 | [data-md-color-scheme="slate"] ::selection { 25 | background-color: #5d4c32; 26 | } 27 | 28 | /* TYPOGRAPHY */ 29 | 30 | .md-main__inner { 31 | margin-top: 1.5rem; 32 | } 33 | 34 | .md-typeset h1 { 35 | font-weight: 400; 36 | letter-spacing: -0.02em; 37 | color: inherit; 38 | font-family: var(--md-code-font-family); 39 | font-size: 2.5rem; 40 | margin: -0.25rem 0 1em; 41 | } 42 | 43 | .md-typeset h1 .headerlink { 44 | display: none; 45 | } 46 | 47 | /* SIDE NAVS */ 48 | 49 | @media screen and (min-width: 76.25em) { 50 | .md-nav--primary 51 | > .md-nav__list 52 | > .md-nav__item 53 | > .md-nav 54 | > .md-nav__list 55 | > .md-nav__item { 56 | margin-top: 0; 57 | } 58 | 59 | .md-nav--primary > .md-nav__list > .md-nav__item > .md-nav__link { 60 | display: none; 61 | } 62 | 63 | .md-nav__item--section > .md-nav__link[for] { 64 | color: inherit; 65 | } 66 | 67 | .md-nav__item--section { 68 | margin: 1.5em 0; 69 | } 70 | 71 | /*[dir="ltr"] .md-sidebar--primary:not([hidden]) ~ .md-content > .md-content__inner {*/ 72 | /* margin-left: 5rem;*/ 73 | /*}*/ 74 | 75 | /*[dir="ltr"] .md-sidebar--secondary:not([hidden]) ~ .md-content > .md-content__inner {*/ 76 | /* margin-right: 5rem;*/ 77 | /*}*/ 78 | } 79 | 80 | @media screen and (min-width: 60em) { 81 | .md-nav__title { 82 | color: inherit; 83 | } 84 | } 85 | 86 | /* HEADER */ 87 | 88 | @media screen and (min-width: 76.1875em) { 89 | /*.md-header__title > .md-header__ellipsis > .md-header__topic:first-child > .md-ellipsis {*/ 90 | /* visibility: hidden;*/ 91 | /*}*/ 92 | 93 | /*.md-header__title > .md-header__ellipsis > .md-header__topic:first-child > .md-ellipsis::before {*/ 94 | /* visibility: visible;*/ 95 | /* content: "Docs";*/ 96 | /* content: "Docs" / "";*/ 97 | /*}*/ 98 | 99 | [dir="ltr"] .md-header__title { 100 | margin-left: 0.6rem; 101 | } 102 | } 103 | 104 | .md-header { 105 | background: none; 106 | box-shadow: none; 107 | backdrop-filter: blur(8px); 108 | } 109 | 110 | .md-header::before { 111 | z-index: -100; 112 | content: ""; 113 | position: absolute; 114 | top: 0; 115 | left: 0; 116 | right: 0; 117 | bottom: 0; 118 | opacity: 0.85; 119 | background-color: var(--md-default-bg-color); 120 | } 121 | 122 | .md-header__button.md-logo, 123 | .md-nav--primary .md-nav__button.md-logo { 124 | display: none; 125 | } 126 | 127 | .md-header__inner { 128 | color: var(--md-default-fg-color); 129 | } 130 | 131 | .md-search__input::placeholder { 132 | color: var(--md-default-fg-color--light); 133 | } 134 | 135 | @media screen and (min-width: 76.25em) { 136 | .md-tabs { 137 | background: none; 138 | color: inherit; 139 | } 140 | } 141 | 142 | /* HEADER THEME */ 143 | /* Copypasted from styles with [data-md-color-primary=white] */ 144 | 145 | [data-md-color-scheme="default"] .md-button { 146 | color: var(--md-typeset-a-color); 147 | } 148 | 149 | [data-md-color-scheme="default"] .md-button--primary { 150 | background-color: var(--md-typeset-a-color); 151 | border-color: var(--md-typeset-a-color); 152 | color: #fff; 153 | } 154 | 155 | @media screen and (min-width: 60em) { 156 | [data-md-color-scheme="default"] .md-search__form { 157 | background-color: #00000012; 158 | } 159 | 160 | [data-md-color-scheme="default"] .md-search__form:hover { 161 | background-color: #00000052; 162 | } 163 | 164 | [data-md-color-scheme="default"] .md-search__input + .md-search__icon { 165 | color: #000000de; 166 | } 167 | } 168 | 169 | /* FOOTER */ 170 | 171 | .md-footer-meta { 172 | display: none; 173 | } 174 | 175 | @media screen and (min-width: 60em) { 176 | .md-footer__link { 177 | padding-top: 0.4rem; 178 | padding-bottom: 1rem; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /guide/docs/stylesheets/home.css: -------------------------------------------------------------------------------- 1 | .md-typeset video { 2 | border-radius: 0.1rem; 3 | margin: 1rem 0; 4 | } 5 | 6 | .md-typeset .link-card-list { 7 | display: grid; 8 | grid-template-columns: 1fr 1fr; 9 | grid-gap: 1rem; 10 | } 11 | 12 | .md-typeset .link-card { 13 | min-height: 8rem; 14 | display: block; 15 | color: var(--md-typeset-color); 16 | border: 2px var(--md-primary-fg-color) solid; 17 | border-radius: 0.1rem; 18 | padding: 1rem 1rem 3rem; 19 | } 20 | 21 | .md-typeset .link-card:hover { 22 | background-color: var(--md-primary-fg-color); 23 | color: var(--md-primary-bg-color); 24 | } 25 | 26 | .md-typeset .link-card h3 { 27 | margin: 0.25rem 0 0.125rem; 28 | } 29 | 30 | .md-typeset a.link-card { 31 | transition: 32 | color, 33 | background-color 125ms; 34 | } 35 | 36 | .md-typeset .home-links { 37 | display: flex; 38 | flex-wrap: wrap; 39 | gap: 2rem; 40 | row-gap: 0.5rem; 41 | position: relative; 42 | } 43 | 44 | .md-typeset .home-links > a > svg { 45 | width: 1.2em; 46 | height: 1.2em; 47 | margin: -0.6em 0; 48 | position: relative; 49 | top: -0.35em; 50 | } 51 | -------------------------------------------------------------------------------- /guide/docs/stylesheets/recipes.css: -------------------------------------------------------------------------------- 1 | .author { 2 | font-size: 1rem; 3 | margin-bottom: 4.75rem; 4 | font-weight: 700; 5 | } 6 | 7 | h1 + .author { 8 | margin-top: -1rem; 9 | } 10 | 11 | .author img { 12 | border-radius: 50%; 13 | width: 1.75rem; 14 | position: relative; 15 | top: 0.5rem; 16 | margin-right: 0.3rem; 17 | margin-left: 0.05rem; 18 | } 19 | -------------------------------------------------------------------------------- /guide/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: TGPy Docs 2 | site_description: "TGPy, a tool for running Python code snippets right in your Telegram messages" 3 | site_url: https://tgpy.dev/ 4 | repo_url: https://github.com/tm-a-t/TGPy 5 | repo_name: tm-a-t/TGPy 6 | edit_uri: edit/master/guide/docs/ 7 | watch: [../README.md] 8 | theme: 9 | name: material 10 | logo: assets/icon.png 11 | favicon: assets/icon.png 12 | features: 13 | - content.action.edit 14 | - content.code.annotate 15 | - content.code.copy 16 | - content.tabs.link 17 | - navigation.instant 18 | - navigation.sections 19 | - navigation.tabs 20 | - navigation.footer 21 | - search.suggest 22 | - search.highlight 23 | - search.share 24 | icon: 25 | repo: fontawesome/brands/github 26 | admonition: 27 | note: octicons/pin-16 28 | abstract: octicons/checklist-16 29 | info: octicons/info-16 30 | tip: octicons/flame-16 31 | success: octicons/check-16 32 | question: octicons/question-16 33 | warning: octicons/alert-16 34 | failure: octicons/x-circle-16 35 | danger: octicons/zap-16 36 | bug: octicons/bug-16 37 | example: octicons/beaker-16 38 | quote: octicons/quote-16 39 | font: 40 | text: Cantarell 41 | code: Red Hat Mono 42 | palette: 43 | - scheme: default 44 | primary: custom 45 | accent: custom 46 | toggle: 47 | icon: material/weather-sunny 48 | name: Switch to dark mode 49 | - scheme: slate 50 | primary: custom 51 | accent: custom 52 | toggle: 53 | icon: material/weather-night 54 | name: Switch to light mode 55 | plugins: 56 | - redirects: 57 | redirect_maps: 58 | guide.md: installation.md 59 | basics.md: basics/code.md 60 | extensibility.md: extensibility/context.md 61 | reference.md: reference/builtins.md 62 | recipes.md: recipes/about.md 63 | - search 64 | - social 65 | - git-revision-date-localized: 66 | type: timeago 67 | enable_creation_date: true 68 | markdown_extensions: 69 | - toc: 70 | permalink: true 71 | - admonition 72 | - attr_list 73 | - tables 74 | - md_in_html 75 | - pymdownx.highlight 76 | - pymdownx.inlinehilite 77 | - pymdownx.details 78 | - pymdownx.superfences: 79 | custom_fences: 80 | - name: mermaid 81 | class: mermaid 82 | format: !!python/name:pymdownx.superfences.fence_code_format 83 | - pymdownx.mark 84 | - pymdownx.tabbed: 85 | alternate_style: true 86 | - pymdownx.tilde 87 | - pymdownx.snippets: 88 | base_path: [".", "../README.md"] 89 | - pymdownx.emoji: 90 | emoji_index: !!python/name:materialx.emoji.twemoji 91 | emoji_generator: !!python/name:materialx.emoji.to_svg 92 | extra: 93 | generator: false 94 | extra_css: 95 | - stylesheets/custom_theme.css 96 | - stylesheets/code_blocks.css 97 | - stylesheets/home.css 98 | - stylesheets/recipes.css 99 | nav: 100 | - Home: index.md 101 | - Guide: 102 | - Get started: 103 | - installation.md 104 | - Basics: 105 | - basics/code.md 106 | - basics/asyncio.md 107 | - basics/messages.md 108 | - basics/examples.md 109 | - Extensibility: 110 | - extensibility/context.md 111 | - extensibility/modules.md 112 | - extensibility/module_examples.md 113 | - extensibility/transformers.md 114 | - extensibility/api.md 115 | - Reference: 116 | - reference/builtins.md 117 | - reference/module_metadata.md 118 | - reference/code_detection.md 119 | - Recipes: 120 | - TGPy recipes: 121 | - recipes/about.md 122 | - recipes/chatgpt.md 123 | - recipes/dice.md 124 | - recipes/reminders.md 125 | - recipes/contacts.md 126 | - recipes/editors.md 127 | -------------------------------------------------------------------------------- /guide/snippets/arrow.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /nix/mkPackageAttrs.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | project, 4 | python, 5 | rev, 6 | }: 7 | let 8 | postPatch = 9 | '' 10 | substituteInPlace pyproject.toml \ 11 | --replace-fail "cryptg-anyos" "cryptg" 12 | '' 13 | + pkgs.lib.optionalString (rev != null) '' 14 | substituteInPlace tgpy/version.py \ 15 | --replace-fail "COMMIT_HASH = None" "COMMIT_HASH = \"${rev}\"" 16 | ''; 17 | newAttrs = { 18 | src = ./..; 19 | inherit postPatch; 20 | pythonRelaxDeps = [ 21 | "cryptg" 22 | ]; 23 | meta = { 24 | license = pkgs.lib.licenses.mit; 25 | homepage = "https://tgpy.dev/"; 26 | pythonImportsCheck = [ "tgpy" ]; 27 | }; 28 | }; 29 | in 30 | (project.renderers.buildPythonPackage { inherit python; }) // newAttrs 31 | -------------------------------------------------------------------------------- /nix/mkPackageOverrides.nix: -------------------------------------------------------------------------------- 1 | { pkgs }: 2 | self: super: { 3 | telethon-v1-24 = super.telethon.overridePythonAttrs (old: rec { 4 | version = "1.24.19"; 5 | pname = "telethon_v1_24"; 6 | src = pkgs.fetchPypi { 7 | inherit version pname; 8 | hash = "sha256-kO/7R8xGMiCjDHnixLKS6GDxP327HA4lnx/dlD3Q8Eo="; 9 | }; 10 | doCheck = false; 11 | }); 12 | mkdocs-git-revision-date-localized-plugin = 13 | super.mkdocs-git-revision-date-localized-plugin.overridePythonAttrs 14 | (old: { 15 | pyproject = true; 16 | format = null; 17 | 18 | dependencies = old.propagatedBuildInputs ++ [ super.setuptools-scm ]; 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /nix/treefmt.nix: -------------------------------------------------------------------------------- 1 | { inputs, ... }: 2 | { 3 | imports = [ inputs.treefmt-nix.flakeModule ]; 4 | 5 | perSystem = 6 | { pkgs, lib, ... }: 7 | { 8 | treefmt = { 9 | projectRootFile = "flake.nix"; 10 | programs = { 11 | ruff = { 12 | check = true; 13 | format = true; 14 | }; 15 | 16 | nixfmt.enable = true; 17 | shfmt.enable = true; 18 | 19 | # taplo crashes `nix flake check` on darwin 20 | taplo.enable = pkgs.stdenv.hostPlatform.isLinux; 21 | 22 | yamlfmt.enable = true; 23 | 24 | prettier.enable = true; 25 | }; 26 | 27 | settings.formatter = { 28 | ruff-check.priority = 1; 29 | ruff-format.priority = 2; 30 | }; 31 | 32 | settings.excludes = 33 | [ 34 | "*.md" 35 | 36 | "*.png" 37 | "*.jpg" 38 | "*.mp4" 39 | 40 | "LICENSE" 41 | 42 | "Dockerfile" 43 | ".dockerignore" 44 | 45 | ".gitignore" 46 | "*.lock" 47 | ] 48 | ++ lib.optionals pkgs.stdenv.hostPlatform.isDarwin [ 49 | "*.toml" 50 | ]; 51 | }; 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "tgpy" 3 | version = "0.18.0" 4 | description = "Run Python code right in your Telegram messages" 5 | readme = "README.md" 6 | requires-python = ">=3.10,<4" 7 | license = { file = "LICENSE" } 8 | classifiers = [ 9 | "Operating System :: OS Independent", 10 | "Programming Language :: Python", 11 | "Development Status :: 4 - Beta", 12 | ] 13 | authors = [ 14 | { name = "tmat", email = "a@tmat.me" }, 15 | { name = "vanutp", email = "hello@vanutp.dev" }, 16 | { name = "ntonee", email = "a12286@yandex.com" }, 17 | ] 18 | dependencies = [ 19 | "PyYAML~=6.0", 20 | "aiorun>=2024.5.1", 21 | "rich~=13.8", 22 | "appdirs~=1.4", 23 | "telethon-v1-24~=1.24", 24 | "python-socks[asyncio]~=2.5", 25 | "cryptg-anyos~=0.4", 26 | ] 27 | 28 | [project.urls] 29 | documentation = "https://tgpy.dev/" 30 | repository = "https://github.com/tm-a-t/TGPy/" 31 | 32 | [project.scripts] 33 | tgpy = "tgpy.main:main" 34 | 35 | [tool.poetry.group.dev.dependencies] 36 | ruff = "^0.9" 37 | 38 | [tool.poetry.group.guide.dependencies] 39 | mkdocs-material = "^9.5" 40 | mkdocs-git-revision-date-localized-plugin = "^1.2" 41 | mkdocs-redirects = "^1.2" 42 | pillow = "^10.3" 43 | cairosvg = "^2.7" 44 | 45 | [tool.poetry.group.release.dependencies] 46 | python-semantic-release = "^9.15" 47 | 48 | [tool.semantic_release] 49 | version_variables = ["tgpy/version.py:__version__", "pyproject.toml:version"] 50 | build_command = """sed -i "s/\\(IS_DEV_BUILD *= *\\).*/\\1False/" tgpy/version.py && poetry build""" 51 | commit_message = 'chore(release): v{version} [skip ci]' 52 | commit_author = "github-actions " 53 | 54 | [tool.ruff] 55 | preview = true 56 | builtins = ["ctx", "client", "restart"] 57 | 58 | [tool.ruff.lint] 59 | extend-select = ["I"] 60 | 61 | [tool.ruff.format] 62 | quote-style = "single" 63 | 64 | [build-system] 65 | requires = ["flit-core~=3.4"] 66 | build-backend = "flit_core.buildapi" 67 | -------------------------------------------------------------------------------- /tgpy/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from telethon import TelegramClient 4 | 5 | from tgpy.context import Context 6 | from tgpy.version import __version__ # noqa: F401 7 | 8 | logging.basicConfig( 9 | format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', 10 | datefmt='%Y-%m-%d %H:%M:%S', 11 | level=logging.INFO, 12 | ) 13 | logging.getLogger('telethon').setLevel(logging.WARNING) 14 | 15 | 16 | class App: 17 | client: TelegramClient 18 | ctx: Context 19 | 20 | def __init__(self): 21 | self.ctx = Context() 22 | 23 | 24 | app = App() 25 | 26 | __all__ = ['App', 'app'] 27 | -------------------------------------------------------------------------------- /tgpy/__main__.py: -------------------------------------------------------------------------------- 1 | from tgpy.main import main 2 | 3 | main() 4 | -------------------------------------------------------------------------------- /tgpy/_core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tm-a-t/TGPy/2e1ed0cf44405762e79429d684b2b1addf267892/tgpy/_core/__init__.py -------------------------------------------------------------------------------- /tgpy/_core/eval_message.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from asyncio import Task 3 | from contextvars import copy_context 4 | 5 | from telethon.errors import MessageIdInvalidError 6 | from telethon.tl.custom import Message 7 | 8 | from tgpy import app 9 | from tgpy._core import message_design 10 | from tgpy._core.utils import convert_result, format_traceback 11 | from tgpy.api import constants, tgpy_eval 12 | 13 | running_messages: dict[tuple[int, int], Task] = {} 14 | 15 | 16 | async def eval_message(code: str, message: Message) -> Message | None: 17 | eval_ctx = copy_context() 18 | task = asyncio.create_task( 19 | tgpy_eval(code, message, filename=None), context=eval_ctx 20 | ) 21 | running_messages[(message.chat_id, message.id)] = task 22 | # noinspection PyBroadException 23 | try: 24 | eval_result = await task 25 | except asyncio.CancelledError: 26 | # message cancelled, do nothing 27 | # return no message as it wasn't edited 28 | return None 29 | except Exception: 30 | result = None 31 | output = '' 32 | exc, constants['exc'] = format_traceback() 33 | else: 34 | if eval_ctx.run(lambda: app.ctx.is_manual_output): 35 | return 36 | result = convert_result(eval_result.result) 37 | output = eval_result.output 38 | exc = '' 39 | constants['exc'] = None 40 | finally: 41 | running_messages.pop((message.chat_id, message.id)) 42 | 43 | try: 44 | return await message_design.edit_message( 45 | message, 46 | code, 47 | result, 48 | traceback=exc, 49 | output=output, 50 | ) 51 | except MessageIdInvalidError: 52 | return None 53 | 54 | 55 | __all__ = ['eval_message', 'running_messages'] 56 | -------------------------------------------------------------------------------- /tgpy/_core/message_design.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback as tb 3 | 4 | from telethon.tl.custom import Message 5 | from telethon.tl.types import ( 6 | MessageEntityBold, 7 | MessageEntityCode, 8 | MessageEntityPre, 9 | MessageEntityTextUrl, 10 | ) 11 | 12 | from tgpy import app, reactions_fix 13 | 14 | TITLE = 'TGPy>' 15 | RUNNING_TITLE = 'TGPy running>' 16 | OLD_TITLE_URLS = ['https://github.com/tm-a-t/TGPy', 'https://tgpy.tmat.me/'] 17 | TITLE_URL = 'https://tgpy.dev/' 18 | FORMATTED_ERROR_HEADER = f'TGPy error>' 19 | 20 | 21 | class Utf16CodepointsWrapper(str): 22 | def __len__(self): 23 | return len(self.encode('utf-16-le')) // 2 24 | 25 | def __getitem__(self, item): 26 | s = self.encode('utf-16-le') 27 | if isinstance(item, slice): 28 | item = slice( 29 | item.start * 2 if item.start else None, 30 | item.stop * 2 if item.stop else None, 31 | item.step * 2 if item.step else None, 32 | ) 33 | s = s[item] 34 | elif isinstance(item, int): 35 | s = s[item * 2 : item * 2 + 2] 36 | else: 37 | raise TypeError(f'{type(item)} is not supported') 38 | return s.decode('utf-16-le') 39 | 40 | 41 | async def edit_message( 42 | message: Message, 43 | code: str, 44 | result: str = '', 45 | traceback: str = '', 46 | output: str = '', 47 | is_running: bool = False, 48 | ) -> Message: 49 | if not result and output: 50 | result = output 51 | output = '' 52 | if not result and traceback: 53 | result = traceback 54 | traceback = '' 55 | 56 | if is_running: 57 | title = Utf16CodepointsWrapper(RUNNING_TITLE) 58 | else: 59 | title = Utf16CodepointsWrapper(TITLE) 60 | parts = [ 61 | Utf16CodepointsWrapper(code.strip()), 62 | Utf16CodepointsWrapper(f'{title} {str(result).strip()}'), 63 | ] 64 | parts += [ 65 | Utf16CodepointsWrapper(part) 66 | for part in (output.strip(), traceback.strip()) 67 | if part 68 | ] 69 | 70 | entities = [] 71 | offset = 0 72 | for i, p in enumerate(parts): 73 | entities.append(MessageEntityCode(offset, len(p))) 74 | newline_cnt = 1 if i == 1 else 2 75 | offset += len(p) + newline_cnt 76 | 77 | entities[0] = MessageEntityPre(entities[0].offset, entities[0].length, 'python') 78 | entities[1].offset += len(title) + 1 79 | entities[1].length -= len(title) + 1 80 | entities[1:1] = [ 81 | MessageEntityBold( 82 | len(parts[0]) + 2, 83 | len(title), 84 | ), 85 | MessageEntityTextUrl( 86 | len(parts[0]) + 2, 87 | len(title), 88 | TITLE_URL, 89 | ), 90 | ] 91 | 92 | text = str(''.join(x + ('\n' if i == 1 else '\n\n') for i, x in enumerate(parts))) 93 | if len(text) > 4096: 94 | text = text[:4095] + '…' 95 | res = await message.edit(text, formatting_entities=entities, link_preview=False) 96 | reactions_fix.update_hash(res, in_memory=False) 97 | return res 98 | 99 | 100 | def get_title_entity(message: Message) -> MessageEntityTextUrl | None: 101 | for e in message.entities or []: 102 | if isinstance(e, MessageEntityTextUrl) and ( 103 | e.url in OLD_TITLE_URLS or e.url == TITLE_URL 104 | ): 105 | return e 106 | return None 107 | 108 | 109 | async def send_error(chat) -> None: 110 | exc = ''.join(tb.format_exception(*sys.exc_info())) 111 | if len(exc) > 4000: 112 | exc = exc[:4000] + '…' 113 | await app.client.send_message( 114 | chat, 115 | f'{FORMATTED_ERROR_HEADER}\n\n{exc}', 116 | link_preview=False, 117 | parse_mode='html', 118 | ) 119 | 120 | 121 | __all__ = [ 122 | 'edit_message', 123 | 'send_error', 124 | ] 125 | -------------------------------------------------------------------------------- /tgpy/_core/meval.py: -------------------------------------------------------------------------------- 1 | # forked from https://pypi.org/project/meval/ 2 | 3 | import ast 4 | import inspect 5 | import sys 6 | from collections import deque 7 | from copy import deepcopy 8 | from importlib.abc import SourceLoader 9 | from importlib.util import module_from_spec, spec_from_loader 10 | from types import CodeType 11 | from typing import Any, Iterator 12 | 13 | from tgpy.api.parse_code import ParseResult 14 | 15 | 16 | def shallow_walk(node) -> Iterator: 17 | # Like ast.walk, but ignoring function definitions: 18 | # Recursively yield all descendant nodes except for function definitions in the tree starting at node 19 | queue = deque([node]) 20 | while queue: 21 | node = queue.popleft() 22 | if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): 23 | continue 24 | queue.extend(ast.iter_child_nodes(node)) 25 | yield node 26 | 27 | 28 | class MevalLoader(SourceLoader): 29 | source: bytes 30 | code: CodeType 31 | filename: str 32 | 33 | def __init__(self, source: str, code: CodeType, filename: str): 34 | self.source = source.encode('utf-8') 35 | self.code = code 36 | self.filename = filename 37 | 38 | def is_package(self, _): 39 | return False 40 | 41 | def get_filename(self, _): 42 | return self.filename 43 | 44 | def get_data(self, _): 45 | return self.source 46 | 47 | def get_code(self, _): 48 | return self.code 49 | 50 | 51 | async def _meval( 52 | parsed: ParseResult, filename: str, saved_variables: dict, **kwargs 53 | ) -> (dict, Any): 54 | kwargs.update(saved_variables) 55 | 56 | root = deepcopy(parsed.tree) 57 | ret_name = '_ret' 58 | ok = False 59 | while True: 60 | if ret_name in kwargs.keys(): 61 | ret_name = '_' + ret_name 62 | continue 63 | for node in ast.walk(root): 64 | if isinstance(node, ast.Name) and node.id == ret_name: 65 | ret_name = '_' + ret_name 66 | break 67 | ok = True 68 | if ok: 69 | break 70 | 71 | code = root.body 72 | if not code: 73 | return {}, None 74 | 75 | # _ret = [] 76 | ret_decl = ast.Assign( 77 | targets=[ast.Name(id=ret_name, ctx=ast.Store())], 78 | value=ast.List(elts=[], ctx=ast.Load()), 79 | ) 80 | ast.fix_missing_locations(ret_decl) 81 | code.insert(0, ret_decl) 82 | 83 | # __import__('builtins').locals() 84 | get_locals = ast.Call( 85 | func=ast.Attribute( 86 | value=ast.Call( 87 | func=ast.Name(id='__import__', ctx=ast.Load()), 88 | args=[ast.Constant(value='builtins')], 89 | keywords=[], 90 | ), 91 | attr='locals', 92 | ctx=ast.Load(), 93 | ), 94 | args=[], 95 | keywords=[], 96 | ) 97 | 98 | if not any(isinstance(node, ast.Return) for node in shallow_walk(root)): 99 | for i in range(len(code)): 100 | if ( 101 | not isinstance(code[i], ast.Expr) 102 | or i != len(code) - 1 103 | and isinstance(code[i].value, ast.Call) 104 | ): 105 | continue 106 | 107 | # replace ... with _ret.append(...) 108 | code[i] = ast.copy_location( 109 | ast.Expr( 110 | ast.Call( 111 | func=ast.Attribute( 112 | value=ast.Name(id=ret_name, ctx=ast.Load()), 113 | attr='append', 114 | ctx=ast.Load(), 115 | ), 116 | args=[code[i].value], 117 | keywords=[], 118 | ) 119 | ), 120 | code[-1], 121 | ) 122 | else: 123 | for node in shallow_walk(root): 124 | if not isinstance(node, ast.Return): 125 | continue 126 | 127 | # replace return ... with return (__import__('builtins').locals(), [...]) 128 | node.value = ast.Tuple( 129 | elts=[ 130 | get_locals, 131 | ast.List( 132 | elts=[node.value or ast.Constant(value='None')], ctx=ast.Load() 133 | ), 134 | ], 135 | ctx=ast.Load(), 136 | ) 137 | 138 | # return (__import__('builtins').locals(), _ret) 139 | code.append( 140 | ast.copy_location( 141 | ast.Return( 142 | value=ast.Tuple( 143 | elts=[get_locals, ast.Name(id=ret_name, ctx=ast.Load())], 144 | ctx=ast.Load(), 145 | ) 146 | ), 147 | code[-1], 148 | ) 149 | ) 150 | 151 | args = [] 152 | for a in list(map(lambda x: ast.arg(x, None), kwargs.keys())): 153 | ast.fix_missing_locations(a) 154 | args += [a] 155 | args = ast.arguments( 156 | args=[], 157 | vararg=None, 158 | kwonlyargs=args, 159 | kwarg=None, 160 | defaults=[], 161 | kw_defaults=[None for i in range(len(args))], 162 | ) 163 | args.posonlyargs = [] 164 | fun = ast.AsyncFunctionDef( 165 | name='tmp', args=args, body=code, decorator_list=[], returns=None 166 | ) 167 | ast.fix_missing_locations(fun) 168 | mod = ast.Module(body=[fun], type_ignores=[]) 169 | 170 | # print(ast.unparse(mod)) 171 | comp = compile(mod, filename, 'exec') 172 | loader = MevalLoader(parsed.original, comp, filename) 173 | py_module = module_from_spec(spec_from_loader(filename, loader, origin=filename)) 174 | sys.modules[filename] = py_module 175 | loader.exec_module(py_module) 176 | 177 | new_locs, ret = await getattr(py_module, 'tmp')(**kwargs) 178 | for loc in list(new_locs): 179 | if (loc in kwargs or loc == ret_name) and loc not in saved_variables: 180 | new_locs.pop(loc) 181 | 182 | ret = [await el if inspect.isawaitable(el) else el for el in ret] 183 | ret = [el for el in ret if el is not None] 184 | 185 | if len(ret) == 1: 186 | ret = ret[0] 187 | elif not ret: 188 | ret = None 189 | 190 | new_locs['_'] = ret 191 | return new_locs, ret 192 | -------------------------------------------------------------------------------- /tgpy/_core/utils.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | 4 | from telethon.tl import TLObject 5 | 6 | 7 | def convert_result(result): 8 | if isinstance(result, TLObject): 9 | result = result.stringify() 10 | 11 | return result 12 | 13 | 14 | def format_traceback() -> tuple[str, str]: 15 | _, exc_value, exc_traceback = sys.exc_info() 16 | exc_traceback = exc_traceback.tb_next.tb_next 17 | te = traceback.TracebackException( 18 | type(exc_value), exc_value, exc_traceback, compact=True 19 | ) 20 | return ''.join(te.format_exception_only()), ''.join(te.format()) 21 | -------------------------------------------------------------------------------- /tgpy/_handlers/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Callable 3 | 4 | from telethon import events 5 | from telethon.tl.custom import Message 6 | from telethon.tl.types import Channel 7 | 8 | import tgpy.api 9 | from tgpy import app, reactions_fix 10 | from tgpy._core import message_design 11 | from tgpy._core.eval_message import eval_message, running_messages 12 | from tgpy.api.parse_code import parse_code 13 | from tgpy.api.transformers import exec_hooks 14 | from tgpy.api.utils import outgoing_messages_filter 15 | from tgpy.reactions_fix import ReactionsFixResult 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def _handle_errors(func: Callable): 21 | async def result(message: Message): 22 | # noinspection PyBroadException 23 | try: 24 | await func(message) 25 | except Exception: 26 | await message_design.send_error(message.chat_id) 27 | 28 | return result 29 | 30 | 31 | async def handle_message( 32 | original_message: Message, *, only_show_warning: bool = False 33 | ) -> None: 34 | message_data = tgpy.api.parse_tgpy_message(original_message) 35 | 36 | if message_data.is_tgpy_message: 37 | # message was edited/tgpy-formatted text was sent 38 | 39 | if not (message := await exec_hooks.apply(original_message, is_edit=True)): 40 | return 41 | 42 | # if message was "broken" by a hook, return 43 | message_data = tgpy.api.parse_tgpy_message(message) 44 | if not message_data.is_tgpy_message: 45 | return 46 | 47 | code = message_data.code 48 | else: 49 | # a new message was sent/message was edited to code 50 | 51 | if not (message := await exec_hooks.apply(original_message, is_edit=False)): 52 | reactions_fix.update_hash(message, in_memory=True) 53 | return 54 | 55 | res = await parse_code(message.raw_text) 56 | if not res.is_code: 57 | reactions_fix.update_hash(message, in_memory=True) 58 | return 59 | 60 | code = message.raw_text 61 | 62 | if only_show_warning: 63 | await message_design.edit_message( 64 | message, code, 'Edit message again to evaluate' 65 | ) 66 | else: 67 | await eval_message(code, message) 68 | 69 | 70 | @events.register(events.NewMessage(func=outgoing_messages_filter)) 71 | @_handle_errors 72 | async def on_new_message(event: events.NewMessage.Event) -> None: 73 | await handle_message(event.message) 74 | 75 | 76 | @events.register(events.MessageEdited(func=outgoing_messages_filter)) 77 | @_handle_errors 78 | async def on_message_edited(event: events.MessageEdited.Event) -> None: 79 | message: Message | None = event.message 80 | if isinstance(message.chat, Channel) and message.chat.broadcast: 81 | # Don't allow editing in channels, as the editor may not be the same account 82 | # which sent the message initially and there is no way to detect it 83 | return 84 | if (message.chat_id, message.id) in running_messages: 85 | # Message is already running, editing should do nothing. 86 | # The message will be corrected on the next flush or after evaluation finishes. 87 | return 88 | 89 | reactions_fix_result = reactions_fix.check_hash(message) 90 | 91 | if reactions_fix_result == ReactionsFixResult.ignore: 92 | return 93 | elif reactions_fix_result == ReactionsFixResult.show_warning: 94 | await handle_message(message, only_show_warning=True) 95 | return 96 | elif reactions_fix_result == ReactionsFixResult.evaluate: 97 | pass 98 | else: 99 | raise ValueError(f'Bad reactions fix result: {reactions_fix_result}') 100 | 101 | await handle_message(message) 102 | 103 | 104 | def add_handlers(): 105 | app.client.add_event_handler(on_new_message) 106 | app.client.add_event_handler(on_message_edited) 107 | -------------------------------------------------------------------------------- /tgpy/api/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import config 2 | from .directories import DATA_DIR, MODULES_DIR, STD_MODULES_DIR, WORKDIR 3 | from .parse_code import parse_code 4 | from .parse_tgpy_message import parse_tgpy_message 5 | from .tgpy_eval import constants, tgpy_eval, variables 6 | from .transformers import ast_transformers, code_transformers, exec_hooks 7 | from .utils import ( 8 | get_hostname, 9 | get_installed_version, 10 | get_running_version, 11 | get_user, 12 | installed_as_package, 13 | outgoing_messages_filter, 14 | running_in_docker, 15 | tokenize_string, 16 | try_await, 17 | untokenize_to_string, 18 | ) 19 | 20 | __all__ = [ 21 | # config 22 | 'config', 23 | # directories 24 | 'DATA_DIR', 25 | 'STD_MODULES_DIR', 26 | 'MODULES_DIR', 27 | 'WORKDIR', 28 | # parse_code 29 | 'parse_code', 30 | # parse_tgpy_message 31 | 'parse_tgpy_message', 32 | # tgpy_eval 33 | 'variables', 34 | 'constants', 35 | 'tgpy_eval', 36 | # transformers 37 | 'code_transformers', 38 | 'ast_transformers', 39 | 'exec_hooks', 40 | # utils 41 | 'get_installed_version', 42 | 'get_running_version', 43 | 'installed_as_package', 44 | 'get_user', 45 | 'get_hostname', 46 | 'running_in_docker', 47 | 'try_await', 48 | 'outgoing_messages_filter', 49 | 'tokenize_string', 50 | 'untokenize_to_string', 51 | ] 52 | -------------------------------------------------------------------------------- /tgpy/api/config.py: -------------------------------------------------------------------------------- 1 | import yaml 2 | 3 | from tgpy.utils import CONFIG_FILENAME, JSON, UNDEFINED, dot_get 4 | 5 | 6 | class Config: 7 | __data: dict 8 | 9 | def __init__(self): 10 | self.__data = {} 11 | 12 | def get(self, key: str | None, default: JSON = UNDEFINED) -> JSON: 13 | return dot_get( 14 | self.__data, 15 | key or '', 16 | default if default is not UNDEFINED else None, 17 | create=default is not UNDEFINED, 18 | ) 19 | 20 | def set(self, key: str | None, value: JSON): 21 | if not key: 22 | self.__data = value 23 | self.save() 24 | return 25 | path, _, key = key.rpartition('.') 26 | last_obj = dot_get(self.__data, path, create=True) 27 | last_obj[key] = value 28 | self.save() 29 | 30 | def unset(self, key: str): 31 | if not key: 32 | raise ValueError("Can't unset the root key") 33 | path, _, key = key.rpartition('.') 34 | try: 35 | last_obj = dot_get(self.__data, path, {}) 36 | except KeyError: 37 | return 38 | if key not in last_obj: 39 | return 40 | del last_obj[key] 41 | self.save() 42 | 43 | def load(self): 44 | try: 45 | with open(CONFIG_FILENAME) as file: 46 | self.__data = yaml.safe_load(file) 47 | except FileNotFoundError: 48 | self.__data = {} 49 | 50 | def save(self): 51 | CONFIG_FILENAME.write_text(yaml.safe_dump(self.__data)) 52 | 53 | 54 | config = Config() 55 | 56 | __all__ = ['config'] 57 | -------------------------------------------------------------------------------- /tgpy/api/directories.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pathlib import Path 3 | 4 | import appdirs 5 | 6 | if env_tgpy_data := os.getenv('TGPY_DATA'): 7 | DATA_DIR = Path(env_tgpy_data).absolute() 8 | os.environ['TGPY_DATA'] = str(DATA_DIR) 9 | else: 10 | # noinspection PyTypeChecker 11 | DATA_DIR = Path(appdirs.user_config_dir('tgpy', appauthor=False)) 12 | STD_MODULES_DIR = Path(__file__).parent.parent / 'std' 13 | MODULES_DIR = DATA_DIR / 'modules' 14 | WORKDIR = DATA_DIR / 'workdir' 15 | 16 | __all__ = [ 17 | 'DATA_DIR', 18 | 'STD_MODULES_DIR', 19 | 'MODULES_DIR', 20 | 'WORKDIR', 21 | ] 22 | -------------------------------------------------------------------------------- /tgpy/api/parse_code.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import logging 3 | from dataclasses import dataclass 4 | 5 | import tgpy.api 6 | from tgpy.api.transformers import ast_transformers, code_transformers 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | @dataclass 12 | class ParseResult: 13 | is_code: bool = False 14 | uses_orig: bool = False 15 | original: str = '' 16 | transformed: str = '' 17 | tree: ast.AST | None = None 18 | exc: Exception | None = None 19 | 20 | 21 | def _is_node_unknown_variable(node: ast.AST, locs: dict) -> bool: 22 | """Check if AST node is a Name or Attribute not present in locals""" 23 | if isinstance(node, ast.Attribute): 24 | return _is_node_unknown_variable(node.value, locs) 25 | return isinstance(node, ast.Name) and node.id not in locs 26 | 27 | 28 | def _is_node_suspicious_binop(node: ast.AST, locs: dict) -> bool: 29 | """Check if AST node can be an operand of binary operation (ast.BinOp, ast.Compare, ast.BoolOp) 30 | with operands which do not pass _is_node_unknown_variable check, or is such operation 31 | """ 32 | if _is_node_unknown_variable(node, locs): 33 | return True 34 | if not isinstance(node, (ast.BoolOp, ast.BinOp, ast.Compare)): 35 | return False 36 | if isinstance(node, ast.Compare): 37 | return _is_node_unknown_variable(node.left, locs) or any( 38 | _is_node_unknown_variable(x, locs) for x in node.comparators 39 | ) 40 | return any( 41 | _is_node_suspicious_binop(operand, locs) 42 | for operand in ( 43 | (node.left, node.right) if isinstance(node, ast.BinOp) else node.values 44 | ) 45 | ) 46 | 47 | 48 | def _ignore_node_simple(node: ast.AST, locs: dict) -> bool: 49 | """Check if message is constant or unknown variable""" 50 | return ( 51 | # Messages like "python", "123" or "example.com" 52 | isinstance(node, ast.Constant) or _is_node_unknown_variable(node, locs) 53 | ) 54 | 55 | 56 | def _ignore_node(node: ast.AST, locs: dict) -> bool: 57 | """Check if AST node didn't seem to be meant to be code""" 58 | if isinstance(node, ast.Expr): 59 | return _ignore_node(node.value, locs) 60 | return ( 61 | _ignore_node_simple(node, locs) 62 | # Messages like "-1", "+spam" and "not foo.bar" 63 | # `getattr(..., None) or node.value` is used here to avoid AttributeError and because in UnaryOp and Starred 64 | # operands are stored in different attributes ("operand" and "value" respectively) 65 | or isinstance(node, (ast.Starred, ast.UnaryOp)) 66 | and isinstance( 67 | getattr(node, 'operand', None) or node.value, 68 | (ast.Constant, ast.Name, ast.Attribute), 69 | ) 70 | # Messages like one-two, one is two, one >= two, one.b in two.c 71 | or _is_node_suspicious_binop(node, locs) 72 | # Messages like "yes, understood" 73 | or isinstance(node, ast.Tuple) 74 | and any(_ignore_node(elt, locs) for elt in node.elts) 75 | # Messages like "cat (no)" 76 | or isinstance(node, ast.Call) 77 | and _ignore_node(node.func, locs) 78 | and any(_ignore_node(arg, locs) for arg in node.args) 79 | # Messages like "fix: fix" 80 | or isinstance(node, ast.AnnAssign) 81 | and node.value is None 82 | and _ignore_node(node.target, locs) 83 | and _ignore_node(node.annotation, locs) 84 | ) 85 | 86 | 87 | async def parse_code(text: str, ignore_simple: bool = True) -> ParseResult: 88 | """Parse given text and decide should it be evaluated as Python code""" 89 | result = ParseResult(original=text) 90 | 91 | text = await code_transformers.apply(text) 92 | result.transformed = text 93 | 94 | try: 95 | tree = ast.parse(text, '', 'exec') 96 | except (SyntaxError, ValueError) as e: 97 | result.exc = e 98 | return result 99 | 100 | tree = await ast_transformers.apply(tree) 101 | result.tree = tree 102 | 103 | if ignore_simple: 104 | locs = ( 105 | list(tgpy.api.variables.keys()) 106 | + list(tgpy.api.constants.keys()) 107 | + ['msg', 'print', 'orig'] 108 | ) 109 | if all(_ignore_node(body_item, locs) for body_item in tree.body): 110 | return result 111 | 112 | result.is_code = True 113 | 114 | for node in ast.walk(tree): 115 | if isinstance(node, ast.Name) and node.id == 'orig': 116 | result.uses_orig = True 117 | return result 118 | 119 | return result 120 | 121 | 122 | __all__ = ['ParseResult', 'parse_code'] 123 | -------------------------------------------------------------------------------- /tgpy/api/parse_tgpy_message.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from telethon.tl.custom import Message 4 | 5 | from tgpy._core.message_design import Utf16CodepointsWrapper, get_title_entity 6 | 7 | 8 | @dataclass 9 | class MessageParseResult: 10 | is_tgpy_message: bool 11 | code: str | None 12 | result: str | None 13 | 14 | 15 | def parse_tgpy_message(message: Message) -> MessageParseResult: 16 | e = get_title_entity(message) 17 | if ( 18 | not e 19 | # Likely a `TGPy error>` message 20 | or e.offset == 0 21 | ): 22 | return MessageParseResult(False, None, None) 23 | msg_text = Utf16CodepointsWrapper(message.raw_text) 24 | code = msg_text[: e.offset].strip() 25 | result = msg_text[e.offset + e.length :].strip() 26 | return MessageParseResult(True, code, result) 27 | 28 | 29 | __all__ = ['MessageParseResult', 'parse_tgpy_message'] 30 | -------------------------------------------------------------------------------- /tgpy/api/tgpy_eval.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from dataclasses import dataclass 3 | from typing import Any 4 | 5 | from telethon.tl.custom import Message 6 | 7 | import tgpy.api 8 | from tgpy import app 9 | from tgpy._core import message_design 10 | from tgpy._core.meval import _meval 11 | from tgpy.api.parse_code import parse_code 12 | from tgpy.utils import FILENAME_PREFIX, numid 13 | 14 | variables: dict[str, Any] = {} 15 | constants: dict[str, Any] = {} 16 | 17 | 18 | @dataclass 19 | class EvalResult: 20 | result: Any 21 | output: str 22 | 23 | 24 | class Flusher: 25 | _code: str 26 | _message: Message | None 27 | _flushed_output: str 28 | _flush_timer: asyncio.Task | None 29 | _finished: bool 30 | 31 | def __init__(self, code: str, message: Message | None): 32 | self._code = code 33 | self._message = message 34 | self._flushed_output = '' 35 | self._flush_timer = None 36 | self._finished = False 37 | 38 | async def _wait_and_flush(self): 39 | await asyncio.sleep(3) 40 | await message_design.edit_message( 41 | self._message, 42 | self._code, 43 | output=self._flushed_output, 44 | is_running=True, 45 | ) 46 | self._flush_timer = None 47 | 48 | def flush_handler(self): 49 | if not self._message or self._finished or app.ctx.is_manual_output: 50 | return 51 | # noinspection PyProtectedMember 52 | self._flushed_output = app.ctx._output 53 | if self._flush_timer: 54 | # flush already scheduled, will print the latest output 55 | return 56 | self._flush_timer = asyncio.create_task(self._wait_and_flush()) 57 | 58 | def set_finished(self): 59 | if self._flush_timer: 60 | self._flush_timer.cancel() 61 | self._finished = True 62 | 63 | 64 | async def tgpy_eval( 65 | code: str, 66 | message: Message | None = None, 67 | *, 68 | filename: str | None = None, 69 | ) -> EvalResult: 70 | parsed = await parse_code(code, ignore_simple=False) 71 | if not parsed.is_code: 72 | if parsed.exc: 73 | raise parsed.exc 74 | else: 75 | raise ValueError('Invalid code provided') 76 | 77 | if message: 78 | await message_design.edit_message(message, code, is_running=True) 79 | 80 | flusher = Flusher(code, message) 81 | 82 | # noinspection PyProtectedMember 83 | app.ctx._init_stdio(flusher.flush_handler) 84 | kwargs = {'msg': message} 85 | if message: 86 | # noinspection PyProtectedMember 87 | app.ctx._set_msg(message) 88 | if not filename: 89 | if message: 90 | filename = f'{FILENAME_PREFIX}message/{message.chat_id}/{message.id}' 91 | else: 92 | filename = f'{FILENAME_PREFIX}eval/{numid()}' 93 | if parsed.uses_orig: 94 | if message: 95 | orig = await message.get_reply_message() 96 | kwargs['orig'] = orig 97 | else: 98 | kwargs['orig'] = None 99 | 100 | try: 101 | new_variables, result = await _meval( 102 | parsed, 103 | filename, 104 | tgpy.api.variables, 105 | **tgpy.api.constants, 106 | **kwargs, 107 | ) 108 | finally: 109 | flusher.set_finished() 110 | if '__all__' in new_variables: 111 | new_variables = { 112 | k: v for k, v in new_variables.items() if k in new_variables['__all__'] 113 | } 114 | tgpy.api.variables.update(new_variables) 115 | 116 | # noinspection PyProtectedMember 117 | return EvalResult( 118 | result=result, 119 | output=app.ctx._output, 120 | ) 121 | 122 | 123 | __all__ = ['variables', 'constants', 'EvalResult', 'tgpy_eval'] 124 | -------------------------------------------------------------------------------- /tgpy/api/transformers.py: -------------------------------------------------------------------------------- 1 | import ast 2 | import logging 3 | from typing import Awaitable, Callable, Generic, Iterator, Literal, Type, TypeVar 4 | 5 | from telethon.tl.custom import Message 6 | 7 | from tgpy.api.utils import try_await 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | # code -> code 12 | CodeTransformerRet = str 13 | CodeTransformerFunc = Callable[ 14 | [str], CodeTransformerRet | Awaitable[CodeTransformerRet] 15 | ] 16 | # ast -> ast 17 | AstTransformerRet = ast.AST 18 | AstTransformerFunc = Callable[ 19 | [ast.AST], AstTransformerRet | Awaitable[AstTransformerRet] 20 | ] 21 | # message, is_edit -> code 22 | ExecHookRet = Message | bool | None 23 | ExecHookFunc = Callable[[Message, bool], ExecHookRet | Awaitable[ExecHookRet]] 24 | 25 | TF = TypeVar('TF') 26 | 27 | 28 | class _TransformerStore(Generic[TF]): 29 | def __init__(self): 30 | self._by_name = {} 31 | self._names = [] 32 | 33 | def __iter__(self) -> Iterator[tuple[str, TF]]: 34 | for name in self._names: 35 | yield name, self._by_name[name] 36 | 37 | def __repr__(self): 38 | return f'TransformerStore({dict(self)!r})' 39 | 40 | def __len__(self): 41 | return len(self._names) 42 | 43 | def __getitem__(self, item: str | int) -> tuple[str, TF]: 44 | if isinstance(item, int): 45 | return self._names[item], self._by_name[self._names[item]] 46 | elif isinstance(item, str): 47 | return self._by_name[item] 48 | else: 49 | raise TypeError(f'Expected str or int, got {type(item)}') 50 | 51 | def __setitem__(self, key: str | int, value: TF | tuple[str, TF]): 52 | if isinstance(key, int) and isinstance(value, tuple): 53 | old_name = self._names[key] 54 | self._names[key] = value[0] 55 | del self._by_name[old_name] 56 | self._by_name[value[0]] = value[1] 57 | elif isinstance(key, str) and callable(value): 58 | if key not in self._by_name: 59 | self._names.append(key) 60 | self._by_name[key] = value 61 | else: 62 | raise TypeError( 63 | 'only `obj[str] = func` and `obj[int] = (str, func)` syntaxes are supported' 64 | ) 65 | 66 | def add(self, name: str, func: TF): 67 | self._names.append(name) 68 | self._by_name[name] = func 69 | 70 | def append(self, val: tuple[str, TF]): 71 | return self.add(*val) 72 | 73 | def remove(self, key: str | int): 74 | if isinstance(key, str): 75 | del self._by_name[key] 76 | self._names.remove(key) 77 | elif isinstance(key, int): 78 | del self._by_name[self._names[key]] 79 | del self._names[key] 80 | else: 81 | raise TypeError(f'Expected str or int, got {type(key)}') 82 | 83 | def __delitem__(self, key: str | int): 84 | self.remove(key) 85 | 86 | 87 | class CodeTransformerStore(_TransformerStore[CodeTransformerFunc]): 88 | async def apply(self, code: str) -> str: 89 | for _, transformer in reversed(self): 90 | try: 91 | code = await try_await(transformer, code) 92 | except Exception: 93 | logger.exception( 94 | f'Error while applying code transformer {transformer}', 95 | exc_info=True, 96 | ) 97 | raise 98 | return code 99 | 100 | 101 | class AstTransformerStore( 102 | _TransformerStore[AstTransformerFunc | Type[ast.NodeTransformer]] 103 | ): 104 | async def apply(self, tree: ast.AST) -> ast.AST: 105 | for _, transformer in reversed(self): 106 | try: 107 | if issubclass(transformer, ast.NodeTransformer): 108 | tree = transformer().visit(tree) 109 | else: 110 | tree = await try_await(transformer, tree) 111 | except Exception: 112 | logger.exception( 113 | f'Error while applying AST transformer {transformer}', 114 | exc_info=True, 115 | ) 116 | raise 117 | return tree 118 | 119 | 120 | class ExecHookStore(_TransformerStore[ExecHookFunc]): 121 | async def apply( 122 | self, message: Message, *, is_edit: bool 123 | ) -> Message | Literal[False]: 124 | res = True 125 | for _, hook in self: 126 | try: 127 | hook_ret = await try_await(hook, message, is_edit) 128 | if isinstance(hook_ret, Message): 129 | message = hook_ret 130 | elif hook_ret is False: 131 | res = False 132 | except Exception: 133 | logger.exception( 134 | f'Error while running exec hook {hook}', 135 | exc_info=True, 136 | ) 137 | raise 138 | if res: 139 | return message 140 | return False 141 | 142 | 143 | code_transformers = CodeTransformerStore() 144 | ast_transformers = AstTransformerStore() 145 | exec_hooks = ExecHookStore() 146 | 147 | __all__ = [ 148 | 'CodeTransformerFunc', 149 | 'AstTransformerFunc', 150 | 'ExecHookFunc', 151 | 'code_transformers', 152 | 'ast_transformers', 153 | 'exec_hooks', 154 | ] 155 | -------------------------------------------------------------------------------- /tgpy/api/utils.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import importlib.metadata 3 | import inspect 4 | import os 5 | import re 6 | import socket 7 | import tokenize 8 | from io import BytesIO 9 | 10 | from telethon import events 11 | from telethon.tl.custom import Message 12 | from telethon.tl.types import MessageService 13 | 14 | import tgpy 15 | from tgpy.utils import REPO_ROOT, RunCmdException, execute_in_repo_root, run_cmd 16 | 17 | 18 | def get_installed_version(): 19 | if version := _get_git_version(): 20 | return version 21 | 22 | if installed_as_package(): 23 | return importlib.metadata.version('tgpy') 24 | 25 | return None 26 | 27 | 28 | def _get_git_version() -> str | None: 29 | if not REPO_ROOT: 30 | return None 31 | with execute_in_repo_root(): 32 | try: 33 | return 'git@' + run_cmd(['git', 'rev-parse', '--short', 'HEAD']) 34 | except (RunCmdException, FileNotFoundError): 35 | pass 36 | 37 | return None 38 | 39 | 40 | _COMMIT_HASH = _get_git_version() 41 | 42 | 43 | def get_running_version(): 44 | if not tgpy.version.IS_DEV_BUILD: 45 | return tgpy.version.__version__ 46 | 47 | if _COMMIT_HASH: 48 | return _COMMIT_HASH 49 | 50 | if tgpy.version.COMMIT_HASH: 51 | return 'git@' + tgpy.version.COMMIT_HASH[:7] 52 | 53 | return 'unknown' 54 | 55 | 56 | def installed_as_package(): 57 | try: 58 | importlib.metadata.version('tgpy') 59 | return True 60 | except importlib.metadata.PackageNotFoundError: 61 | return False 62 | 63 | 64 | def get_user(): 65 | try: 66 | return getpass.getuser() 67 | except KeyError: 68 | return str(os.getuid()) 69 | 70 | 71 | DOCKER_DEFAULT_HOSTNAME_RGX = re.compile(r'[0-9a-f]{12}') 72 | 73 | 74 | def get_hostname(): 75 | real_hostname = socket.gethostname() 76 | if running_in_docker() and DOCKER_DEFAULT_HOSTNAME_RGX.fullmatch(real_hostname): 77 | return 'docker' 78 | return real_hostname 79 | 80 | 81 | def running_in_docker(): 82 | return os.path.exists('/.dockerenv') or os.path.exists('/run/.containerenv') 83 | 84 | 85 | async def try_await(func, *args, **kwargs): 86 | res = func(*args, **kwargs) 87 | if inspect.isawaitable(res): 88 | res = await res 89 | return res 90 | 91 | 92 | def outgoing_messages_filter( 93 | e: events.NewMessage.Event | events.MessageEdited.Event | Message, 94 | ) -> bool: 95 | if isinstance(e, Message): 96 | m = e 97 | else: 98 | m = e.message 99 | return ( 100 | m.out and not m.forward and not m.via_bot and not isinstance(m, MessageService) 101 | ) 102 | 103 | 104 | def tokenize_string(s: str) -> list[tokenize.TokenInfo] | None: 105 | try: 106 | return list(tokenize.tokenize(BytesIO(s.encode('utf-8')).readline)) 107 | except (IndentationError, tokenize.TokenError): 108 | return None 109 | 110 | 111 | def untokenize_to_string(tokens: list[tokenize.TokenInfo]) -> str: 112 | return tokenize.untokenize(tokens).decode('utf-8') 113 | 114 | 115 | __all__ = [ 116 | 'get_installed_version', 117 | 'get_running_version', 118 | 'installed_as_package', 119 | 'get_user', 120 | 'get_hostname', 121 | 'running_in_docker', 122 | 'try_await', 123 | 'outgoing_messages_filter', 124 | 'tokenize_string', 125 | 'untokenize_to_string', 126 | ] 127 | -------------------------------------------------------------------------------- /tgpy/context.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from contextvars import ContextVar 3 | from io import StringIO, TextIOBase 4 | from typing import Callable 5 | 6 | from telethon.tl.custom import Message 7 | 8 | _is_module: ContextVar[bool] = ContextVar('_is_module') 9 | _message: ContextVar[Message] = ContextVar('_message') 10 | _stdout: ContextVar[StringIO] = ContextVar('_stdout') 11 | _stderr: ContextVar[StringIO] = ContextVar('_stderr') 12 | _flush_handler: ContextVar[Callable[[], None]] = ContextVar('_flush_handler') 13 | _is_manual_output: ContextVar[bool] = ContextVar('_is_manual_output', default=False) 14 | 15 | 16 | class _StdoutWrapper(TextIOBase): 17 | def __init__(self, contextvar, fallback): 18 | self.__contextvar = contextvar 19 | self.__fallback = fallback 20 | 21 | def __getobj(self): 22 | return self.__contextvar.get(self.__fallback) 23 | 24 | def write(self, s: str) -> int: 25 | return self.__getobj().write(s) 26 | 27 | def flush(self) -> None: 28 | self.__getobj().flush() 29 | if flush_handler := _flush_handler.get(None): 30 | flush_handler() 31 | 32 | @property 33 | def isatty(self): 34 | return getattr(self.__getobj(), 'isatty', None) 35 | 36 | 37 | sys.stdout = _StdoutWrapper(_stdout, sys.__stdout__) 38 | sys.stderr = _StdoutWrapper(_stderr, sys.__stderr__) 39 | 40 | 41 | def cleanup_erases(data: str): 42 | lines = data.replace('\r\n', '\n').split('\n') 43 | return '\n'.join(x.rsplit('\r', 1)[-1] for x in lines) 44 | 45 | 46 | class Context: 47 | @property 48 | def is_module(self) -> bool: 49 | return _is_module.get(False) 50 | 51 | @staticmethod 52 | def _set_is_module(is_module: bool): 53 | _is_module.set(is_module) 54 | 55 | @property 56 | def msg(self) -> Message: 57 | return _message.get(None) 58 | 59 | @staticmethod 60 | def _set_msg(msg: Message): 61 | _message.set(msg) 62 | 63 | @staticmethod 64 | def _init_stdio(flush_handler: Callable[[], None]): 65 | _stdout.set(StringIO()) 66 | _stderr.set(StringIO()) 67 | _flush_handler.set(flush_handler) 68 | 69 | @property 70 | def _output(self) -> str: 71 | stderr = cleanup_erases(_stderr.get().getvalue()) 72 | stdout = cleanup_erases(_stdout.get().getvalue()) 73 | if stderr and stderr[-1] != '\n': 74 | stderr += '\n' 75 | return stderr + stdout 76 | 77 | @property 78 | def is_manual_output(self): 79 | return _is_manual_output.get() 80 | 81 | @is_manual_output.setter 82 | def is_manual_output(self, value: bool): 83 | _is_manual_output.set(value) 84 | 85 | def __str__(self): 86 | return '' 87 | -------------------------------------------------------------------------------- /tgpy/main.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import functools 3 | import logging 4 | import os 5 | import platform 6 | import subprocess 7 | import sys 8 | 9 | import aiorun 10 | import yaml 11 | from rich.console import Console, Theme 12 | from telethon import TelegramClient, errors 13 | from yaml import YAMLError 14 | 15 | from tgpy import app 16 | from tgpy._handlers import add_handlers 17 | from tgpy.api import DATA_DIR, MODULES_DIR, WORKDIR, config 18 | from tgpy.modules import run_modules, serialize_module 19 | from tgpy.utils import SESSION_FILENAME, create_config_dirs 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | theme = Theme(inherit=False) 24 | console = Console(theme=theme) 25 | 26 | 27 | async def ainput(prompt: str, password: bool = False): 28 | def wrapper(prompt, password): 29 | return console.input(prompt, password=password) 30 | 31 | return await asyncio.get_event_loop().run_in_executor( 32 | None, wrapper, prompt, password 33 | ) 34 | 35 | 36 | def get_api_id() -> str | None: 37 | return os.getenv('TGPY_API_ID') or config.get('core.api_id') 38 | 39 | 40 | def get_api_hash() -> str | None: 41 | return os.getenv('TGPY_API_HASH') or config.get('core.api_hash') 42 | 43 | 44 | def create_client(): 45 | device_model = None 46 | if sys.platform == 'linux': 47 | if os.path.isfile('/sys/devices/virtual/dmi/id/product_name'): 48 | with open('/sys/devices/virtual/dmi/id/product_name') as f: 49 | device_model = f.read().strip() 50 | elif sys.platform == 'darwin': 51 | device_model = ( 52 | subprocess.check_output('sysctl -n hw.model'.split(' ')).decode().strip() 53 | ) 54 | elif sys.platform == 'win32': 55 | device_model = ' '.join( 56 | subprocess.check_output('wmic computersystem get manufacturer,model') 57 | .decode() 58 | .replace('Manufacturer', '') 59 | .replace('Model', '') 60 | .split() 61 | ) 62 | 63 | client = TelegramClient( 64 | str(SESSION_FILENAME), 65 | get_api_id(), 66 | get_api_hash(), 67 | device_model=device_model, 68 | system_version=platform.platform(), 69 | lang_code='en', 70 | system_lang_code='en-US', 71 | proxy=config.get('core.proxy', None), 72 | ) 73 | client.parse_mode = 'html' 74 | return client 75 | 76 | 77 | async def start_client(): 78 | await app.client.start( 79 | phone=functools.partial(ainput, '| Please enter your phone number: '), 80 | code_callback=functools.partial( 81 | ainput, '| Please enter the code you received: ' 82 | ), 83 | password=functools.partial( 84 | ainput, '| Please enter your 2FA password: ', password=True 85 | ), 86 | ) 87 | 88 | 89 | async def initial_setup(): 90 | console.print('[bold #ffffff on #16a085] Welcome to TGPy ') 91 | console.print('Starting setup...') 92 | console.print() 93 | console.print('[bold #7f8c8d on #ffffff] Step 1 of 2 ') 94 | console.print( 95 | "│ TGPy uses Telegram API, so you'll need to register your Telegram app.\n" 96 | '│ [#1abc9c]1.[/] Go to https://my.telegram.org\n' 97 | '│ [#1abc9c]2.[/] Login with your Telegram account\n' 98 | '│ [#1abc9c]3.[/] Go to "API development tools"\n' 99 | '│ [#1abc9c]4.[/] Create your app. Choose any app title and short_title. You can leave other fields empty.\n' 100 | '│ You will get api_id and api_hash.' 101 | ) 102 | success = False 103 | while not success: 104 | config.set('core.api_id', int(await ainput('│ Please enter api_id: '))) 105 | config.set('core.api_hash', await ainput('│ ...and api_hash: ')) 106 | try: 107 | app.client = create_client() 108 | console.print() 109 | console.print('[bold #7f8c8d on #ffffff] Step 2 of 2 ') 110 | console.print('│ Now login to Telegram.') 111 | await app.client.connect() 112 | await start_client() 113 | success = True 114 | except (errors.ApiIdInvalidError, errors.ApiIdPublishedFloodError, ValueError): 115 | console.print( 116 | '│ [bold #ffffff on #ed1515]Incorrect api_id/api_hash, try again' 117 | ) 118 | finally: 119 | if app.client: 120 | await app.client.disconnect() 121 | del app.client 122 | console.print('│ Login successful!') 123 | 124 | 125 | def migrate_hooks_to_modules(): 126 | old_modules_dir = DATA_DIR / 'hooks' 127 | if not old_modules_dir.exists(): 128 | return 129 | for mod_file in old_modules_dir.iterdir(): 130 | # noinspection PyBroadException 131 | try: 132 | if mod_file.suffix not in ['.yml', '.yaml']: 133 | continue 134 | try: 135 | with open(mod_file) as f: 136 | module = yaml.safe_load(f) 137 | 138 | if 'type' in module: 139 | del module['type'] 140 | if 'datetime' in module: 141 | module['priority'] = int(module['datetime'].timestamp()) 142 | del module['datetime'] 143 | 144 | new_mod_file = mod_file.with_suffix('.py') 145 | with open(new_mod_file, 'w') as f: 146 | f.write(serialize_module(module)) 147 | mod_file.unlink() 148 | mod_file = new_mod_file 149 | except YAMLError: 150 | continue 151 | except Exception: 152 | pass 153 | finally: 154 | mod_file.rename(MODULES_DIR / mod_file.name) 155 | old_modules_dir.rmdir() 156 | 157 | 158 | def migrate_config(): 159 | if old_api_id := config.get('api_id'): 160 | config.set('core.api_id', int(old_api_id)) 161 | config.unset('api_id') 162 | if old_api_hash := config.get('api_hash'): 163 | config.set('core.api_hash', old_api_hash) 164 | config.unset('api_hash') 165 | 166 | 167 | async def _async_main(): 168 | create_config_dirs() 169 | os.chdir(WORKDIR) 170 | migrate_hooks_to_modules() 171 | 172 | config.load() 173 | migrate_config() 174 | if not (get_api_id() and get_api_hash()): 175 | await initial_setup() 176 | 177 | logger.info('Starting TGPy...') 178 | app.client = create_client() 179 | add_handlers() 180 | await start_client() 181 | logger.info('TGPy is running!') 182 | await run_modules() 183 | await app.client.run_until_disconnected() 184 | 185 | 186 | async def async_main(): 187 | try: 188 | await _async_main() 189 | except Exception: 190 | logger.exception('TGPy failed to start') 191 | asyncio.get_event_loop().stop() 192 | 193 | 194 | def main(): 195 | aiorun.run(async_main()) 196 | -------------------------------------------------------------------------------- /tgpy/modules.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import logging 3 | import re 4 | import traceback 5 | from dataclasses import dataclass 6 | from datetime import datetime 7 | from pathlib import Path 8 | from textwrap import dedent, indent 9 | from typing import Any, Iterator, Union 10 | 11 | import yaml 12 | from yaml import YAMLError 13 | 14 | import tgpy.api 15 | from tgpy import app 16 | from tgpy._core.utils import format_traceback 17 | from tgpy.api import MODULES_DIR, STD_MODULES_DIR 18 | from tgpy.api.tgpy_eval import tgpy_eval 19 | from tgpy.utils import FILENAME_PREFIX 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | 24 | def get_module_filename(name: Union[str, Path]) -> Path: 25 | name = Path(name) 26 | if name.suffix != '.py': 27 | name = name.with_suffix('.py') 28 | return MODULES_DIR / name 29 | 30 | 31 | def delete_module_file(name: Union[str, Path]): 32 | get_module_filename(name).unlink() 33 | 34 | 35 | def get_module_names() -> Iterator[str]: 36 | for file in MODULES_DIR.iterdir(): 37 | if file.suffix != '.py': 38 | continue 39 | yield file.stem 40 | 41 | 42 | def get_std_modules() -> 'list[Module]': 43 | disabled_modules = tgpy.api.config.get('core.disabled_modules', []) 44 | modules = [] 45 | for file in STD_MODULES_DIR.iterdir(): 46 | if file.suffix != '.py': 47 | continue 48 | mod_name = file.stem 49 | if mod_name not in disabled_modules: 50 | modules.append(Module.load(mod_name, str(file))) 51 | modules.sort(key=lambda mod: mod.priority) 52 | return modules 53 | 54 | 55 | def get_user_modules() -> 'list[Module]': 56 | modules = [] 57 | 58 | for mod_name in get_module_names(): 59 | # noinspection PyBroadException 60 | try: 61 | module = Module.load(mod_name) 62 | except Exception: 63 | logger.error(f'Error during loading module {mod_name!r}') 64 | logger.error(traceback.format_exc()) 65 | continue 66 | modules.append(module) 67 | 68 | modules.sort(key=lambda mod: mod.priority) 69 | return modules 70 | 71 | 72 | async def run_modules(): 73 | for module in get_std_modules(): 74 | await module.run() 75 | for module in get_user_modules(): 76 | # noinspection PyBroadException 77 | try: 78 | await module.run() 79 | except Exception: 80 | logger.error(f'Error during running module {module.name!r}') 81 | logger.error(format_traceback()[1]) 82 | continue 83 | 84 | 85 | DOCSTRING_RGX = re.compile(r'^\s*(?:\'\'\'(.*?)\'\'\'|"""(.*?)""")', re.DOTALL) 86 | MODULE_TEMPLATE = ''' 87 | """ 88 | {metadata} 89 | """ 90 | {code} 91 | '''.strip() 92 | 93 | 94 | def serialize_module(module: Union['Module', dict]) -> str: 95 | if isinstance(module, Module): 96 | module_dict = dataclasses.asdict(module) 97 | else: 98 | module_dict = module 99 | module_code = DOCSTRING_RGX.sub('', module_dict.pop('code')).strip() 100 | module_dict.update(module_dict.get('extra', {})) 101 | module_dict.pop('extra', None) 102 | module_str_metadata = yaml.safe_dump(module_dict).strip() 103 | return MODULE_TEMPLATE.format( 104 | metadata=indent(module_str_metadata, ' ' * 4), 105 | code=f'{module_code}\n', 106 | ) 107 | 108 | 109 | def deserialize_module(data: str, name: str) -> 'Module': 110 | docstring_match = DOCSTRING_RGX.search(data) 111 | fallback_metadata: dict[str, Any] = { 112 | 'name': name, 113 | 'origin': f'{FILENAME_PREFIX}module/{name}', 114 | 'priority': int(datetime.now().timestamp()), 115 | } 116 | if docstring_match: 117 | module_str_metadata = dedent( 118 | docstring_match.group(1) or docstring_match.group(2) 119 | ).strip() 120 | try: 121 | module_dict = yaml.safe_load(module_str_metadata) 122 | except YAMLError: 123 | logger.error( 124 | f'Error loading metadata of module {name!r}, stripping metadata' 125 | ) 126 | module_dict = fallback_metadata 127 | else: 128 | module_dict = fallback_metadata 129 | logger.warning(f'No metadata found in module {name!r}') 130 | # to support debugging properly, module code is the whole file 131 | module_dict['code'] = data 132 | field_names = {x.name for x in dataclasses.fields(Module)} 133 | extra_fields = set(module_dict.keys()) - field_names 134 | if extra_fields: 135 | module_dict['extra'] = module_dict.get('extra', {}) 136 | for field_name in extra_fields: 137 | module_dict['extra'][field_name] = module_dict.pop(field_name) 138 | module = Module(**module_dict) 139 | return module 140 | 141 | 142 | @dataclass 143 | class Module: 144 | name: str 145 | code: str 146 | origin: str 147 | priority: int 148 | once: bool = False 149 | extra: dict = dataclasses.field(default_factory=dict) 150 | 151 | @classmethod 152 | def load(cls, mod_name: str, filename: str | None = None) -> 'Module': 153 | if not filename: 154 | filename = get_module_filename(mod_name) 155 | with open(filename, 'r') as f: 156 | module = deserialize_module(f.read(), mod_name) 157 | if module.name != mod_name: 158 | raise ValueError( 159 | f'Invalid module name. Expected: {mod_name!r}, found: {module.name!r}' 160 | ) 161 | return module 162 | 163 | def save(self): 164 | filename = get_module_filename(self.name) 165 | 166 | with open(filename, 'w') as f: 167 | f.write(serialize_module(self)) 168 | 169 | async def run(self): 170 | # noinspection PyProtectedMember 171 | app.ctx._set_is_module(True) 172 | await tgpy_eval( 173 | self.code, 174 | message=None, 175 | filename=self.origin, 176 | ) 177 | if self.once: 178 | delete_module_file(self.name) 179 | -------------------------------------------------------------------------------- /tgpy/reactions_fix.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module tries to fix Telegram bug/undocumented feature where 3 | setting/removing reaction sometimes triggers message edit event. 4 | This bug/feature introduces a security vulnerability in TGPy, 5 | because message reevaluation can be triggered by other users. 6 | """ 7 | 8 | import base64 9 | import json 10 | from enum import Enum 11 | from hashlib import sha256 12 | 13 | from telethon.tl.custom import Message 14 | 15 | import tgpy.api 16 | 17 | CONFIG_BASE_KEY = 'core.reactions_fix.messages' 18 | 19 | in_memory_hashes = {} 20 | 21 | 22 | def get_content_hash(message: Message) -> str: 23 | entities = [json.dumps(e.to_dict()) for e in message.entities or []] 24 | data = str(len(entities)) + '\n' + '\n'.join(entities) + message.raw_text 25 | return base64.b64encode(sha256(data.encode('utf-8')).digest()).decode('utf-8') 26 | 27 | 28 | class ReactionsFixResult(Enum): 29 | ignore = 1 30 | evaluate = 2 31 | show_warning = 3 32 | 33 | 34 | def check_hash(message: Message) -> ReactionsFixResult: 35 | content_hash = get_content_hash(message) 36 | saved_hash = in_memory_hashes.get(( 37 | message.chat_id, 38 | message.id, 39 | )) or tgpy.api.config.get(CONFIG_BASE_KEY + f'.{message.chat_id}.{message.id}') 40 | if not saved_hash: 41 | return ReactionsFixResult.show_warning 42 | if saved_hash == content_hash: 43 | return ReactionsFixResult.ignore 44 | return ReactionsFixResult.evaluate 45 | 46 | 47 | def update_hash(message: Message | None, *, in_memory: bool = False) -> None: 48 | if not message: 49 | return 50 | if in_memory: 51 | in_memory_hashes[message.chat_id, message.id] = get_content_hash(message) 52 | else: 53 | in_memory_hashes.pop((message.chat_id, message.id), None) 54 | tgpy.api.config.set( 55 | CONFIG_BASE_KEY + f'.{message.chat_id}.{message.id}', 56 | get_content_hash(message), 57 | ) 58 | 59 | 60 | __all__ = ['ReactionsFixResult', 'check_hash', 'update_hash'] 61 | -------------------------------------------------------------------------------- /tgpy/std/client_fixes.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: client_fixes 3 | origin: tgpy://builtin_module/client_fixes 4 | priority: 800 5 | """ 6 | 7 | import re 8 | 9 | import tgpy.api 10 | 11 | DOUBLE_QUOTE_RE = re.compile(r'[“”]') 12 | SINGLE_QUOTE_RE = re.compile(r'[‘’]') 13 | 14 | tgpy.api.code_transformers.add( 15 | 'apple_fix', lambda x: DOUBLE_QUOTE_RE.sub('"', SINGLE_QUOTE_RE.sub("'", x)) 16 | ) 17 | tgpy.api.code_transformers.add('android_fix', lambda x: x.replace('\u00a0', ' ')) 18 | -------------------------------------------------------------------------------- /tgpy/std/compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: compat 3 | origin: tgpy://builtin_module/compat 4 | priority: 800 5 | """ 6 | 7 | import sys 8 | 9 | import tgpy.api 10 | from tgpy._core import message_design 11 | 12 | 13 | class MessageDesignCompatStub: 14 | @staticmethod 15 | def get_code(message): 16 | return tgpy.api.parse_tgpy_message(message).code 17 | 18 | @staticmethod 19 | def parse_message(message): 20 | return tgpy.api.parse_tgpy_message(message) 21 | 22 | @staticmethod 23 | async def edit_message(*args, **kwargs): 24 | return await message_design.edit_message(*args, **kwargs) 25 | 26 | 27 | # noinspection PyTypeChecker 28 | sys.modules['tgpy.message_design'] = MessageDesignCompatStub() 29 | 30 | 31 | class RunCodeCompatStub: 32 | @staticmethod 33 | def apply_code_transformers(code): 34 | return tgpy.api.code_transformers.apply(code) 35 | 36 | 37 | # noinspection PyTypeChecker 38 | sys.modules['tgpy.run_code'] = RunCodeCompatStub() 39 | 40 | 41 | class TGPyAPICompatStub: 42 | @staticmethod 43 | def add_code_transformer(name, transformer): 44 | tgpy.api.code_transformers.add(name, transformer) 45 | 46 | @property 47 | def constants(self): 48 | return tgpy.api.constants 49 | 50 | @property 51 | def variables(self): 52 | return tgpy.api.variables 53 | 54 | 55 | tgpy.api.variables['tgpy'] = TGPyAPICompatStub() 56 | 57 | __all__ = [] 58 | -------------------------------------------------------------------------------- /tgpy/std/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: constants 3 | origin: tgpy://builtin_module/constants 4 | priority: 100 5 | """ 6 | 7 | import tgpy.api 8 | from tgpy import app 9 | 10 | tgpy.api.constants['ctx'] = app.ctx 11 | tgpy.api.constants['client'] = app.client 12 | tgpy.api.constants['exc'] = None 13 | 14 | __all__ = [] 15 | -------------------------------------------------------------------------------- /tgpy/std/module_manager.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: module_manager 3 | origin: tgpy://builtin_module/module_manager 4 | priority: 800 5 | """ 6 | 7 | from datetime import datetime 8 | from textwrap import dedent 9 | 10 | from telethon.tl.custom import Message 11 | 12 | import tgpy.api 13 | from tgpy import Context 14 | from tgpy.api import parse_tgpy_message 15 | from tgpy.modules import Module, delete_module_file, get_module_names, get_user_modules 16 | from tgpy.utils import FILENAME_PREFIX 17 | 18 | ctx: Context 19 | 20 | 21 | class ModulesObject: 22 | async def add(self, name: str, code: str | None = None) -> str: 23 | name = str(name) 24 | 25 | if code is None: 26 | original: Message = await ctx.msg.get_reply_message() 27 | if original is None: 28 | return 'Use this function in reply to a message' 29 | message_data = parse_tgpy_message(original) 30 | if not message_data.is_tgpy_message: 31 | return 'No code found in reply message' 32 | code = message_data.code 33 | 34 | origin = f'{FILENAME_PREFIX}module/{name}' 35 | 36 | if name in self: 37 | module = self[name] 38 | module.code = code 39 | else: 40 | module = Module( 41 | name=name, 42 | once=False, 43 | code=code, 44 | origin=origin, 45 | priority=int(datetime.now().timestamp()), 46 | ) 47 | module.save() 48 | 49 | return dedent( 50 | f""" 51 | Added module {name!r}. 52 | Module's code will be executed every time TGPy starts. 53 | """ 54 | ) 55 | 56 | def remove(self, name) -> str: 57 | try: 58 | delete_module_file(name) 59 | except FileNotFoundError: 60 | return f'No module named {name!r}.' 61 | return f'Removed module {name!r}.' 62 | 63 | def __str__(self): 64 | lst = '\n'.join( 65 | f'{idx + 1}. {mod.name}' for idx, mod in enumerate(get_user_modules()) 66 | ) 67 | if not lst: 68 | return dedent( 69 | """ 70 | You have no modules. 71 | Learn about modules at https://tgpy.dev/extensibility/modules. 72 | """ 73 | ) 74 | return dedent( 75 | """ 76 | Your modules: 77 | {} 78 | 79 | Change modules with `modules.add(name)` and `modules.remove(name)`. 80 | Learn more at https://tgpy.dev/extensibility/modules. 81 | """ 82 | ).format(lst) 83 | 84 | def __iter__(self): 85 | return (mod.name for mod in get_user_modules()) 86 | 87 | def __getitem__(self, mod_name): 88 | return Module.load(mod_name) 89 | 90 | def __contains__(self, mod_name): 91 | return mod_name in get_module_names() 92 | 93 | 94 | tgpy.api.variables['modules'] = ModulesObject() 95 | 96 | __all__ = [] 97 | -------------------------------------------------------------------------------- /tgpy/std/ping.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: ping 3 | origin: tgpy://builtin_module/ping 4 | priority: 600 5 | """ 6 | 7 | from textwrap import dedent 8 | 9 | from tgpy.api import get_hostname, get_running_version, get_user 10 | 11 | 12 | def ping(): 13 | return dedent( 14 | f""" 15 | Pong! 16 | Running on {get_user()}@{get_hostname()} 17 | Version: {get_running_version()} 18 | """ 19 | ) 20 | 21 | 22 | __all__ = ['ping'] 23 | -------------------------------------------------------------------------------- /tgpy/std/postfix_await.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: postfix_await 3 | origin: tgpy://builtin_module/postfix_await 4 | priority: 300 5 | """ 6 | 7 | import ast 8 | import tokenize 9 | 10 | import tgpy.api 11 | from tgpy.api import tokenize_string, untokenize_to_string 12 | 13 | AWAIT_REPLACEMENT_ATTRIBUTE = '__tgpy_await__' 14 | 15 | 16 | def code_trans(code: str) -> str: 17 | tokens = tokenize_string(code) 18 | if not tokens: 19 | return code 20 | for i, tok in enumerate(tokens): 21 | if i == 0: 22 | continue 23 | prev_tok = tokens[i - 1] 24 | if ( 25 | tok.type == tokenize.NAME 26 | and tok.string == 'await' 27 | and prev_tok.type == tokenize.OP 28 | and prev_tok.string == '.' 29 | ): 30 | tokens[i] = tok._replace(string=AWAIT_REPLACEMENT_ATTRIBUTE) 31 | return untokenize_to_string(tokens) 32 | 33 | 34 | class AwaitTransformer(ast.NodeTransformer): 35 | def visit_Attribute(self, node: ast.Attribute): 36 | node = self.generic_visit(node) 37 | if node.attr == AWAIT_REPLACEMENT_ATTRIBUTE: 38 | return ast.Await(value=node.value) 39 | else: 40 | return node 41 | 42 | 43 | tgpy.api.code_transformers.add('postfix_await', code_trans) 44 | tgpy.api.ast_transformers.add('postfix_await', AwaitTransformer) 45 | 46 | __all__ = [] 47 | -------------------------------------------------------------------------------- /tgpy/std/prevent_eval.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: prevent_eval 3 | origin: tgpy://builtin_module/prevent_eval 4 | priority: 400 5 | """ 6 | 7 | import re 8 | 9 | from telethon import TelegramClient 10 | from telethon.tl.custom import Message 11 | from telethon.tl.types import MessageActionTopicCreate, MessageService 12 | 13 | import tgpy.api 14 | from tgpy import Context, reactions_fix 15 | from tgpy._core.eval_message import running_messages 16 | from tgpy.api.utils import outgoing_messages_filter 17 | 18 | client: TelegramClient 19 | ctx: Context 20 | 21 | MODULE_NAME = 'prevent_eval' 22 | IGNORED_MESSAGES_KEY = f'{MODULE_NAME}.ignored_messages' 23 | CANCEL_RGX = re.compile(r'(?i)^(cancel|сфтсуд)$') 24 | INTERRUPT_RGX = re.compile(r'(?i)^(stop|ыещз)$') 25 | 26 | 27 | async def cancel_message(message: Message, permanent: bool = True) -> bool: 28 | parsed = tgpy.api.parse_tgpy_message(message) 29 | 30 | if task := running_messages.get((message.chat_id, message.id)): 31 | task.cancel() 32 | if not parsed.is_tgpy_message: 33 | return False 34 | message = await message.edit(parsed.code) 35 | 36 | if permanent: 37 | ignored_messages = tgpy.api.config.get(IGNORED_MESSAGES_KEY, []) 38 | ignored_messages.append([message.chat_id, message.id]) 39 | tgpy.api.config.save() 40 | else: 41 | reactions_fix.update_hash(message) 42 | 43 | return True 44 | 45 | 46 | async def handle_cancel(message: Message, permanent: bool = True): 47 | target: Message | None = await message.get_reply_message() 48 | thread_id = None 49 | 50 | if ( 51 | target 52 | and target.fwd_from 53 | and target.fwd_from.from_id == target.from_id 54 | and target.fwd_from.saved_from_peer == target.from_id 55 | ): 56 | # Message from bound channel. Probably sent cancel from comments. 57 | # Searching for messages to cancel only in this comment thread 58 | thread_id = target.id 59 | target = None 60 | 61 | if ( 62 | target 63 | and isinstance(target, MessageService) 64 | and isinstance(target.action, MessageActionTopicCreate) 65 | ): 66 | # Message sent to a topic (without replying to any other message). 67 | # Searching for messages to cancel only in this topic 68 | thread_id = target.id 69 | target = None 70 | 71 | if not target: 72 | async for msg in client.iter_messages( 73 | message.chat_id, max_id=message.id, reply_to=thread_id, limit=10 74 | ): 75 | if not outgoing_messages_filter(msg): 76 | continue 77 | parsed = tgpy.api.parse_tgpy_message(msg) 78 | if parsed.is_tgpy_message: 79 | target = msg 80 | break 81 | 82 | if not target or not outgoing_messages_filter(target): 83 | return 84 | 85 | if await cancel_message(target, permanent): 86 | await message.delete() 87 | 88 | 89 | async def handle_comment(message: Message): 90 | ignored_messages = tgpy.api.config.get(IGNORED_MESSAGES_KEY, []) 91 | ignored_messages.append([message.chat_id, message.id]) 92 | tgpy.api.config.save() 93 | 94 | entities = message.entities or [] 95 | for ent in entities: 96 | if ent.offset < 2: 97 | ent.length -= 2 - ent.offset 98 | ent.offset = 0 99 | else: 100 | ent.offset -= 2 101 | await message.edit(message.raw_text[2:], formatting_entities=entities) 102 | 103 | 104 | async def exec_hook(message: Message, is_edit: bool): 105 | ignored_messages = tgpy.api.config.get(IGNORED_MESSAGES_KEY, []) 106 | if [message.chat_id, message.id] in ignored_messages: 107 | return False 108 | 109 | is_comment = message.raw_text.startswith('//') and message.raw_text[2:].strip() 110 | is_cancel = CANCEL_RGX.fullmatch(message.raw_text) is not None 111 | is_interrupt = INTERRUPT_RGX.fullmatch(message.raw_text) is not None 112 | if not is_comment and not is_cancel and not is_interrupt: 113 | return True 114 | 115 | if is_cancel or is_interrupt: 116 | await handle_cancel(message, permanent=is_cancel) 117 | elif is_comment: 118 | await handle_comment(message) 119 | 120 | return False 121 | 122 | 123 | tgpy.api.exec_hooks.add(MODULE_NAME, exec_hook) 124 | 125 | __all__ = [] 126 | -------------------------------------------------------------------------------- /tgpy/std/restart.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: restart 3 | origin: tgpy://builtin_module/restart 4 | priority: 500 5 | """ 6 | 7 | import os 8 | import sys 9 | from textwrap import dedent 10 | 11 | from tgpy import app 12 | from tgpy.modules import Module 13 | from tgpy.utils import FILENAME_PREFIX 14 | 15 | 16 | def restart(msg: str | None = 'Restarted successfully'): 17 | mod_code = dedent( 18 | f""" 19 | from tgpy.api.parse_tgpy_message import parse_tgpy_message 20 | from tgpy._core.message_design import edit_message 21 | msg = await client.get_messages({app.ctx.msg.chat_id}, ids={app.ctx.msg.id}) 22 | await edit_message(msg, parse_tgpy_message(msg).code, '{msg}') 23 | """ 24 | ) 25 | module = Module( 26 | name='__restart_message', 27 | once=True, 28 | code=mod_code, 29 | origin=f'{FILENAME_PREFIX}restart_message', 30 | priority=0, 31 | ) 32 | module.save() 33 | os.execl(sys.executable, sys.executable, *sys.argv) 34 | 35 | 36 | __all__ = ['restart'] 37 | -------------------------------------------------------------------------------- /tgpy/std/star_imports.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: star_imports 3 | origin: tgpy://builtin_module/star_imports 4 | priority: 300 5 | """ 6 | 7 | import ast 8 | 9 | import tgpy.api 10 | 11 | 12 | def unwrap_star_import(module_name: str) -> list[str]: 13 | # https://stackoverflow.com/a/41991139 14 | module = __import__(module_name, fromlist=['*']) 15 | if hasattr(module, '__all__'): 16 | names = module.__all__ 17 | else: 18 | names = [name for name in dir(module) if not name.startswith('_')] 19 | return names 20 | 21 | 22 | class StarImportsTransformer(ast.NodeTransformer): 23 | def visit_ImportFrom(self, node: ast.ImportFrom): 24 | node = self.generic_visit(node) 25 | if node.names[0].name == '*': 26 | try: 27 | # this has a downside of delaying the start 28 | # of message evaluation if the import takes a long time 29 | names = unwrap_star_import(node.module) 30 | node.names = [ast.alias(name) for name in names] 31 | except ImportError: 32 | pass 33 | return node 34 | 35 | 36 | tgpy.api.ast_transformers.add('star_imports', StarImportsTransformer) 37 | 38 | __all__ = [] 39 | -------------------------------------------------------------------------------- /tgpy/std/update.py: -------------------------------------------------------------------------------- 1 | """ 2 | name: update 3 | origin: tgpy://builtin_module/update 4 | priority: 700 5 | """ 6 | 7 | import sys 8 | 9 | from tgpy.api.utils import ( 10 | get_installed_version, 11 | get_running_version, 12 | installed_as_package, 13 | running_in_docker, 14 | ) 15 | from tgpy.utils import REPO_ROOT, RunCmdException, execute_in_repo_root, run_cmd 16 | 17 | 18 | def update(): 19 | old_version = get_running_version() 20 | 21 | if running_in_docker(): 22 | return "Can't update a docker container" 23 | 24 | if installed_as_package(): 25 | update_args = [sys.executable, '-m', 'pip', 'install', '-U', 'tgpy'] 26 | try: 27 | run_cmd(update_args) 28 | except RunCmdException: 29 | run_cmd(update_args + ['--user']) 30 | elif REPO_ROOT: 31 | with execute_in_repo_root(): 32 | try: 33 | run_cmd(['git', 'pull']) 34 | except FileNotFoundError: 35 | return 'Git is not installed' 36 | else: 37 | return 'Could not find suitable update method' 38 | 39 | new_version = get_installed_version() 40 | if not new_version: 41 | return 'Could not determine upgraded version. This is probably a bug in TGPy.' 42 | if old_version == new_version: 43 | return 'Already up to date' 44 | else: 45 | restart(f'Updated successfully! Current version: {new_version}') 46 | 47 | 48 | __all__ = ['update'] 49 | -------------------------------------------------------------------------------- /tgpy/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import shlex 4 | import string 5 | from contextlib import contextmanager 6 | from pathlib import Path 7 | from subprocess import PIPE, Popen 8 | from typing import Any, NewType, Union 9 | 10 | from tgpy.api.directories import DATA_DIR, MODULES_DIR, WORKDIR 11 | 12 | JSON = NewType('JSON', Union[None, str, int, bool, list['JSON'], dict[str, 'JSON']]) 13 | UNDEFINED = object() 14 | 15 | CONFIG_FILENAME = DATA_DIR / 'config.yml' 16 | SESSION_FILENAME = DATA_DIR / 'TGPy.session' 17 | REPO_ROOT = Path(__file__).parent.parent 18 | if not os.path.exists(REPO_ROOT / '.git'): 19 | REPO_ROOT = None 20 | 21 | FILENAME_PREFIX = 'tgpy://' 22 | 23 | 24 | @contextmanager 25 | def execute_in_repo_root(): 26 | if not REPO_ROOT: 27 | raise ValueError('No repository found') 28 | old_cwd = os.getcwd() 29 | os.chdir(REPO_ROOT) 30 | try: 31 | yield 32 | finally: 33 | os.chdir(old_cwd) 34 | 35 | 36 | def create_config_dirs(): 37 | DATA_DIR.mkdir(exist_ok=True) 38 | MODULES_DIR.mkdir(exist_ok=True) 39 | WORKDIR.mkdir(exist_ok=True) 40 | 41 | 42 | class RunCmdException(Exception): 43 | def __init__(self, process: Popen): 44 | self.process = process 45 | 46 | def __str__(self): 47 | return f'Command {shlex.join(self.process.args)} exited with code {self.process.returncode}' 48 | 49 | 50 | def run_cmd(args: list[str]): 51 | proc = Popen(args, stdout=PIPE) 52 | output, _ = proc.communicate() 53 | if proc.returncode: 54 | raise RunCmdException(proc) 55 | return output.decode('utf-8').strip() 56 | 57 | 58 | def dot_get( 59 | obj: dict, key: str, default: Any | None = UNDEFINED, *, create: bool = False 60 | ) -> Any: 61 | if not key: 62 | return obj 63 | curr_obj = obj 64 | parts = key.split('.') 65 | for i, part in enumerate(parts): 66 | new_obj = curr_obj.get(part, UNDEFINED) 67 | if new_obj is UNDEFINED: 68 | if create and (default is UNDEFINED or i != len(parts) - 1): 69 | new_obj = curr_obj[part] = {} 70 | elif default is UNDEFINED: 71 | raise KeyError(key) 72 | else: 73 | if create: 74 | curr_obj[part] = default 75 | return default 76 | if not isinstance(new_obj, dict) and i != len(parts) - 1: 77 | raise ValueError('.'.join(parts[: i + 1]) + ' is not a dict') 78 | curr_obj = new_obj 79 | return curr_obj 80 | 81 | 82 | def numid(): 83 | return ''.join(random.choices(string.digits, k=8)) 84 | 85 | 86 | __all__ = [ 87 | 'JSON', 88 | 'UNDEFINED', 89 | 'CONFIG_FILENAME', 90 | 'SESSION_FILENAME', 91 | 'REPO_ROOT', 92 | 'FILENAME_PREFIX', 93 | 'run_cmd', 94 | 'create_config_dirs', 95 | 'RunCmdException', 96 | 'execute_in_repo_root', 97 | 'dot_get', 98 | 'numid', 99 | ] 100 | -------------------------------------------------------------------------------- /tgpy/version.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.18.0' 2 | IS_DEV_BUILD = False 3 | COMMIT_HASH = None 4 | --------------------------------------------------------------------------------