diff --git a/.gitignore b/.gitignore index 3cc1b32..220a524 100644 --- a/.gitignore +++ b/.gitignore @@ -88,3 +88,6 @@ Thumbs.db # build-generated per-release (URLs+sha256 of the on-demand cloud addons) module/etc/cloud_addons.json + +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 5a38e28..0e03fa5 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,9 @@ import existing Neo-Backup and Android-DataBackup backups so you can move over. - **Compression:** none / lz4 / zstd / gzip (gzip = "compatibility mode", opens anywhere). - **Encryption:** optional and portable (`age` + passphrase, or AES-256 zip) - never tied to device hardware, so a backup restores on another device or opens on a PC. -- **Destinations:** local, SFTP, SCP, SMB, WebDAV, FTP, S3 and 70+ more (`rclone`), - plus `rsync` and `restic` repos. Optional Google Drive via a tiny opt-in companion. +- **Destinations:** local, Telegram Cloud Storage (private storage channel), + SFTP, SCP, SMB, WebDAV, FTP, S3 and 70+ more (`rclone`), plus `rsync` and `restic` + repos. Optional Google Drive via a tiny opt-in companion. - **Integrity:** every archive is verified against the manifest; dry-run and restore-test let you check a backup without touching anything. - **Selective & batch restore:** restore any subset of a backup's items, or multi-select diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 4a5bd64..1894bfc 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -405,6 +405,17 @@ automatically, so **no PC is needed** (pasting a token by hand still works as a The remote then appears in the destination's remote list. Passwords are stored obscured, never in clear text. You can list and delete your remotes from the same place. +### Telegram Cloud Storage + +You can back up directly to your personal Telegram account as private cloud storage: + +1. In the **Destinations** tab, tap **Link Telegram**. +2. Enter your phone number with country code (e.g. `+1 234 567 8900`). +3. Enter the login verification code (OTP) sent to your Telegram app (and your 2FA cloud password if enabled). +4. A dedicated private channel starting with `VD_` (e.g. `VD_Backup_4F2A`) is automatically created in your Telegram account to serve as your private storage. +5. Backups targeting the Telegram destination upload directly to this channel. +6. In the **Backups** tab, tap **Telegram Cloud** to browse, download, and restore backups directly from your Telegram channel. + Two rules keep remote use safe: - **Credentials are stored encrypted** at rest in the module's state directory, never diff --git a/module/customize.sh b/module/customize.sh index 801f09b..ef4a32f 100644 --- a/module/customize.sh +++ b/module/customize.sh @@ -95,6 +95,8 @@ set_perm_recursive "$MODPATH" 0 0 0755 0644 set_perm_recursive "$MODPATH/bin" 0 0 0755 0755 set_perm "$MODPATH/scripts/helper.sh" 0 0 0755 set_perm "$MODPATH/scripts/worker.sh" 0 0 0755 +set_perm "$MODPATH/scripts/telegram.sh" 0 0 0755 +set_perm "$MODPATH/scripts/telegram_cli.py" 0 0 0755 set_perm "$MODPATH/service.sh" 0 0 0755 [ -f "$MODPATH/uninstall.sh" ] && set_perm "$MODPATH/uninstall.sh" 0 0 0755 @@ -109,7 +111,7 @@ if [ "$BOOTMODE" = "true" ] && [ "$MODPATH" != "$ACTIVE" ]; then ui_print "- Activating without reboot" mkdir -p "$ACTIVE" cp -afT "$MODPATH" "$ACTIVE" 2>/dev/null || cp -af "$MODPATH"/. "$ACTIVE"/ 2>/dev/null - chmod 0755 "$ACTIVE"/*.sh "$ACTIVE"/scripts/*.sh "$ACTIVE"/bin/*/* 2>/dev/null + chmod 0755 "$ACTIVE"/*.sh "$ACTIVE"/scripts/*.sh "$ACTIVE"/scripts/*.py "$ACTIVE"/bin/*/* 2>/dev/null # the manager writes an "update" marker AFTER customize.sh returns; clear it (and # kick the scheduler) from a detached job so the module shows as active, not pending. setsid sh -c "sleep 4; rm -f '$ACTIVE/update' '$ACTIVE/remove' '$ACTIVE/disable'; '$ACTIVE/scripts/helper.sh' scheduler-tick" >/dev/null 2>&1 & diff --git a/module/scripts/helper.sh b/module/scripts/helper.sh old mode 100644 new mode 100755 index 4a7d343..8333b36 --- a/module/scripts/helper.sh +++ b/module/scripts/helper.sh @@ -45,6 +45,11 @@ BIN="$MODDIR/bin/$BINABI" export PATH="$BIN:$PATH" export LD_LIBRARY_PATH="$BIN/lib:${LD_LIBRARY_PATH:-}" +HELPER_DEX="$MODDIR/etc/vdbackup_helper.dex" +[ -f "$HELPER_DEX" ] || HELPER_DEX="$MODDIR/vdbhelper.dex" +COMPANION_APK="$MODDIR/etc/companion.apk" +[ -f "$COMPANION_APK" ] || COMPANION_APK="$MODDIR/companion.apk" + mkdir -p "$LOGS" # --- tiny JSON helpers (no jq dependency) ------------------------------- @@ -133,8 +138,8 @@ auto_backup_scan() { # ; echoes detached _seen="$STATE/autobackup_seen.json" _cur="$STATE/staging/autobackup_cur.$$.json"; mkdir -p "$STATE/staging" # current user apps as {pkg:vc}; dex gives versionCode, pm fallback gives vc=0 - if [ -f "$MODDIR/etc/vdbackup_helper.dex" ]; then - LD_LIBRARY_PATH= app_process -Djava.class.path="$MODDIR/etc/vdbackup_helper.dex" /system/bin \ + if [ -f "$HELPER_DEX" ]; then + LD_LIBRARY_PATH= app_process -Djava.class.path="$HELPER_DEX" /system/bin \ ru.vd171.vdbackup.Helper list-apps "$_u" 2>/dev/null \ | "$_jq" -c '[.apps[]? | select(.system|not) | {(.package): (.versionCode // 0)}] | add // {}' > "$_cur" 2>/dev/null else @@ -313,8 +318,8 @@ case "$verb" in "$(command -v "$BIN/jq" 2>/dev/null || echo jq)" -r '.user // 0' 2>/dev/null)" case "$u" in ''|*[!0-9]*) u=0 ;; esac # delegates to the dex framework helper when available, else pm fallback - if [ -f "$MODDIR/etc/vdbackup_helper.dex" ]; then - LD_LIBRARY_PATH= app_process -Djava.class.path="$MODDIR/etc/vdbackup_helper.dex" /system/bin \ + if [ -f "$HELPER_DEX" ]; then + LD_LIBRARY_PATH= app_process -Djava.class.path="$HELPER_DEX" /system/bin \ ru.vd171.vdbackup.Helper list-apps "$u" 2>/dev/null && exit 0 fi # shell fallback: package name + apk path @@ -343,8 +348,8 @@ case "$verb" in [ -e "$old" ] && [ "$old" != "$png" ] && rm -f "$old" done fi - if [ ! -s "$png" ] && [ -f "$MODDIR/etc/vdbackup_helper.dex" ]; then - LD_LIBRARY_PATH= app_process -Djava.class.path="$MODDIR/etc/vdbackup_helper.dex" /system/bin \ + if [ ! -s "$png" ] && [ -f "$HELPER_DEX" ]; then + LD_LIBRARY_PATH= app_process -Djava.class.path="$HELPER_DEX" /system/bin \ ru.vd171.vdbackup.Helper icon "$pkg" "$png" "$u" >/dev/null 2>&1 fi if [ -s "$png" ]; then ok "\"package\":\"$(jstr "$pkg")\",\"icon\":\"$(base64 -w0 < "$png")\"" @@ -368,8 +373,8 @@ case "$verb" in printf '%s\t%s\n' "$pkg" "$png" >> "$lst" fi done - if [ -s "$lst" ] && [ -f "$MODDIR/etc/vdbackup_helper.dex" ]; then - LD_LIBRARY_PATH= app_process -Djava.class.path="$MODDIR/etc/vdbackup_helper.dex" /system/bin \ + if [ -s "$lst" ] && [ -f "$HELPER_DEX" ]; then + LD_LIBRARY_PATH= app_process -Djava.class.path="$HELPER_DEX" /system/bin \ ru.vd171.vdbackup.Helper icons "$u" "$lst" >/dev/null 2>&1 fi rm -f "$lst" @@ -384,7 +389,7 @@ case "$verb" in done printf '}}\n' ;; - backup|restore|import|special-backup|special-restore|cloud-fetch|path-backup|path-restore) + backup|restore|import|special-backup|special-restore|cloud-fetch|path-backup|path-restore|telegram-download) opid="$(new_opid)" detach "$opid" "$verb" "$ARGS_B64" ok "\"opid\":\"$opid\"" ;; @@ -731,9 +736,54 @@ case "$verb" in i=0; while [ ! -f "$res.done" ] && [ "$i" -lt 30 ]; do sleep 1; i=$((i+1)); done if [ -f "$res.done" ] && "$JQ" -e '.ok==true' "$res" >/dev/null 2>&1; then rm -f "$res" "$res.done"; ok '"reachable":true' else c="$("$JQ" -r '.code // "ERR_DRIVE_TIMEOUT"' "$res" 2>/dev/null)"; rm -f "$res" "$res.done"; err "${c:-ERR_DRIVE}" "Drive not reachable - authorize consent"; fi ;; + telegram) + st="$("$MODDIR/scripts/telegram.sh" check-auth 2>/dev/null)" + if printf '%s' "$st" | "$JQ" -e '.authorized == true' >/dev/null 2>&1; then + ok '"reachable":true' + else + err ERR_DEST_AUTH "Telegram not connected. Please link your Telegram account." + fi ;; *) err ERR_DEST_TYPE "unknown type" ;; esac ;; + telegram-status) + "$MODDIR/scripts/telegram.sh" check-auth ;; + + telegram-send-code) + JQ="$(command -v "$BIN/jq" 2>/dev/null || echo jq)" + j="$(printf '%s' "$ARGS_B64" | base64 -d 2>/dev/null)" + phone="$(printf '%s' "$j" | "$JQ" -r '.phone // empty' 2>/dev/null)" + [ -n "$phone" ] || { err ERR_TG_PHONE "Phone number is required"; exit 0; } + "$MODDIR/scripts/telegram.sh" send-code "$phone" ;; + + telegram-sign-in) + JQ="$(command -v "$BIN/jq" 2>/dev/null || echo jq)" + j="$(printf '%s' "$ARGS_B64" | base64 -d 2>/dev/null)" + phone="$(printf '%s' "$j" | "$JQ" -r '.phone // empty' 2>/dev/null)" + code="$(printf '%s' "$j" | "$JQ" -r '.code // empty' 2>/dev/null)" + pw="$(printf '%s' "$j" | "$JQ" -r '.password // empty' 2>/dev/null)" + [ -n "$phone" ] && [ -n "$code" ] || { err ERR_TG_CODE "Phone and verification code are required"; exit 0; } + "$MODDIR/scripts/telegram.sh" sign-in "$phone" "$code" "$pw" ;; + + telegram-init-storage) + JQ="$(command -v "$BIN/jq" 2>/dev/null || echo jq)" + j="$(printf '%s' "$ARGS_B64" | base64 -d 2>/dev/null)" + title="$(printf '%s' "$j" | "$JQ" -r '.title // empty' 2>/dev/null)" + "$MODDIR/scripts/telegram.sh" init-storage "$title" ;; + + telegram-list-remote) + JQ="$(command -v "$BIN/jq" 2>/dev/null || echo jq)" + j="$(printf '%s' "$ARGS_B64" | base64 -d 2>/dev/null)" + cid="$(printf '%s' "$j" | "$JQ" -r '.channel_id // empty' 2>/dev/null)" + if [ -z "$cid" ] && [ -f "$STATE/dests/telegram.json" ]; then + cid="$("$JQ" -r '.channel_id // empty' "$STATE/dests/telegram.json" 2>/dev/null)" + fi + [ -n "$cid" ] || { err ERR_TG_NO_CHANNEL "No storage channel configured"; exit 0; } + "$MODDIR/scripts/telegram.sh" list-remote "$cid" ;; + + telegram-logout) + "$MODDIR/scripts/telegram.sh" logout ;; + schedule-set) JQ="$(command -v "$BIN/jq" 2>/dev/null || echo jq)" j="$(printf '%s' "$ARGS_B64" | base64 -d 2>/dev/null)" @@ -856,16 +906,16 @@ case "$verb" in companion-status) JQ="$(command -v "$BIN/jq" 2>/dev/null || echo jq)" inst=false; pm path ru.vd171.vdbackup >/dev/null 2>&1 && inst=true - bundled=false; [ -f "$MODDIR/etc/companion.apk" ] && bundled=true + bundled=false; [ -f "$COMPANION_APK" ] && bundled=true vc="$(dumpsys package ru.vd171.vdbackup 2>/dev/null | sed -n 's/.*versionCode=\([0-9]*\).*/\1/p' | head -1)" "$JQ" -nc --argjson installed "$inst" --argjson bundled "$bundled" --arg vc "${vc:-}" \ '{ok:true,installed:$installed,bundled:$bundled,versionCode:$vc}' ;; companion-install) # optional + on-demand: only ever called when the user picks Google Drive. - [ -f "$MODDIR/etc/companion.apk" ] || { err ERR_COMPANION "companion APK not bundled in this build"; exit 0; } + [ -f "$COMPANION_APK" ] || { err ERR_COMPANION "companion APK not bundled in this build"; exit 0; } # system_server cannot read /data/adb (adb_data_file); stage under /data/local/tmp - capk=/data/local/tmp/vdbackup-companion.apk; cp "$MODDIR/etc/companion.apk" "$capk"; chmod 644 "$capk" + capk=/data/local/tmp/vdbackup-companion.apk; cp "$COMPANION_APK" "$capk"; chmod 644 "$capk" if pm install -r "$capk" >/dev/null 2>&1; then rm -f "$capk"; ok '"installed":true,"hma":true' else rm -f "$capk"; err ERR_COMPANION_INSTALL "could not install companion"; fi ;; diff --git a/module/scripts/lib/pyaes/__init__.py b/module/scripts/lib/pyaes/__init__.py new file mode 100644 index 0000000..5712f79 --- /dev/null +++ b/module/scripts/lib/pyaes/__init__.py @@ -0,0 +1,53 @@ +# The MIT License (MIT) +# +# Copyright (c) 2014 Richard Moore +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# This is a pure-Python implementation of the AES algorithm and AES common +# modes of operation. + +# See: https://en.wikipedia.org/wiki/Advanced_Encryption_Standard +# See: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation + + +# Supported key sizes: +# 128-bit +# 192-bit +# 256-bit + + +# Supported modes of operation: +# ECB - Electronic Codebook +# CBC - Cipher-Block Chaining +# CFB - Cipher Feedback +# OFB - Output Feedback +# CTR - Counter + +# See the README.md for API details and general information. + +# Also useful, PyCrypto, a crypto library implemented in C with Python bindings: +# https://www.dlitz.net/software/pycrypto/ + + +VERSION = [1, 3, 0] + +from .aes import AES, AESModeOfOperationCTR, AESModeOfOperationCBC, AESModeOfOperationCFB, AESModeOfOperationECB, AESModeOfOperationOFB, AESModesOfOperation, Counter +from .blockfeeder import decrypt_stream, Decrypter, encrypt_stream, Encrypter +from .blockfeeder import PADDING_NONE, PADDING_DEFAULT diff --git a/module/scripts/lib/pyaes/aes.py b/module/scripts/lib/pyaes/aes.py new file mode 100644 index 0000000..c6e8bc0 --- /dev/null +++ b/module/scripts/lib/pyaes/aes.py @@ -0,0 +1,589 @@ +# The MIT License (MIT) +# +# Copyright (c) 2014 Richard Moore +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# This is a pure-Python implementation of the AES algorithm and AES common +# modes of operation. + +# See: https://en.wikipedia.org/wiki/Advanced_Encryption_Standard + +# Honestly, the best description of the modes of operations are the wonderful +# diagrams on Wikipedia. They explain in moments what my words could never +# achieve. Hence the inline documentation here is sparer than I'd prefer. +# See: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation + +# Also useful, PyCrypto, a crypto library implemented in C with Python bindings: +# https://www.dlitz.net/software/pycrypto/ + + +# Supported key sizes: +# 128-bit +# 192-bit +# 256-bit + + +# Supported modes of operation: +# ECB - Electronic Codebook +# CBC - Cipher-Block Chaining +# CFB - Cipher Feedback +# OFB - Output Feedback +# CTR - Counter + + +# See the README.md for API details and general information. + + +import copy +import struct + +__all__ = ["AES", "AESModeOfOperationCTR", "AESModeOfOperationCBC", "AESModeOfOperationCFB", + "AESModeOfOperationECB", "AESModeOfOperationOFB", "AESModesOfOperation", "Counter"] + + +def _compact_word(word): + return (word[0] << 24) | (word[1] << 16) | (word[2] << 8) | word[3] + +def _string_to_bytes(text): + return list(ord(c) for c in text) + +def _bytes_to_string(binary): + return "".join(chr(b) for b in binary) + +def _concat_list(a, b): + return a + b + + +# Python 3 compatibility +try: + xrange +except Exception: + xrange = range + + # Python 3 supports bytes, which is already an array of integers + def _string_to_bytes(text): + if isinstance(text, bytes): + return text + return [ord(c) for c in text] + + # In Python 3, we return bytes + def _bytes_to_string(binary): + return bytes(binary) + + # Python 3 cannot concatenate a list onto a bytes, so we bytes-ify it first + def _concat_list(a, b): + return a + bytes(b) + + +# Based *largely* on the Rijndael implementation +# See: http://csrc.nist.gov/publications/fips/fips197/fips-197.pdf +class AES(object): + '''Encapsulates the AES block cipher. + + You generally should not need this. Use the AESModeOfOperation classes + below instead.''' + + # Number of rounds by keysize + number_of_rounds = {16: 10, 24: 12, 32: 14} + + # Round constant words + rcon = [ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36, 0x6c, 0xd8, 0xab, 0x4d, 0x9a, 0x2f, 0x5e, 0xbc, 0x63, 0xc6, 0x97, 0x35, 0x6a, 0xd4, 0xb3, 0x7d, 0xfa, 0xef, 0xc5, 0x91 ] + + # S-box and Inverse S-box (S is for Substitution) + S = [ 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 ] + Si =[ 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d ] + + # Transformations for encryption + T1 = [ 0xc66363a5, 0xf87c7c84, 0xee777799, 0xf67b7b8d, 0xfff2f20d, 0xd66b6bbd, 0xde6f6fb1, 0x91c5c554, 0x60303050, 0x02010103, 0xce6767a9, 0x562b2b7d, 0xe7fefe19, 0xb5d7d762, 0x4dababe6, 0xec76769a, 0x8fcaca45, 0x1f82829d, 0x89c9c940, 0xfa7d7d87, 0xeffafa15, 0xb25959eb, 0x8e4747c9, 0xfbf0f00b, 0x41adadec, 0xb3d4d467, 0x5fa2a2fd, 0x45afafea, 0x239c9cbf, 0x53a4a4f7, 0xe4727296, 0x9bc0c05b, 0x75b7b7c2, 0xe1fdfd1c, 0x3d9393ae, 0x4c26266a, 0x6c36365a, 0x7e3f3f41, 0xf5f7f702, 0x83cccc4f, 0x6834345c, 0x51a5a5f4, 0xd1e5e534, 0xf9f1f108, 0xe2717193, 0xabd8d873, 0x62313153, 0x2a15153f, 0x0804040c, 0x95c7c752, 0x46232365, 0x9dc3c35e, 0x30181828, 0x379696a1, 0x0a05050f, 0x2f9a9ab5, 0x0e070709, 0x24121236, 0x1b80809b, 0xdfe2e23d, 0xcdebeb26, 0x4e272769, 0x7fb2b2cd, 0xea75759f, 0x1209091b, 0x1d83839e, 0x582c2c74, 0x341a1a2e, 0x361b1b2d, 0xdc6e6eb2, 0xb45a5aee, 0x5ba0a0fb, 0xa45252f6, 0x763b3b4d, 0xb7d6d661, 0x7db3b3ce, 0x5229297b, 0xdde3e33e, 0x5e2f2f71, 0x13848497, 0xa65353f5, 0xb9d1d168, 0x00000000, 0xc1eded2c, 0x40202060, 0xe3fcfc1f, 0x79b1b1c8, 0xb65b5bed, 0xd46a6abe, 0x8dcbcb46, 0x67bebed9, 0x7239394b, 0x944a4ade, 0x984c4cd4, 0xb05858e8, 0x85cfcf4a, 0xbbd0d06b, 0xc5efef2a, 0x4faaaae5, 0xedfbfb16, 0x864343c5, 0x9a4d4dd7, 0x66333355, 0x11858594, 0x8a4545cf, 0xe9f9f910, 0x04020206, 0xfe7f7f81, 0xa05050f0, 0x783c3c44, 0x259f9fba, 0x4ba8a8e3, 0xa25151f3, 0x5da3a3fe, 0x804040c0, 0x058f8f8a, 0x3f9292ad, 0x219d9dbc, 0x70383848, 0xf1f5f504, 0x63bcbcdf, 0x77b6b6c1, 0xafdada75, 0x42212163, 0x20101030, 0xe5ffff1a, 0xfdf3f30e, 0xbfd2d26d, 0x81cdcd4c, 0x180c0c14, 0x26131335, 0xc3ecec2f, 0xbe5f5fe1, 0x359797a2, 0x884444cc, 0x2e171739, 0x93c4c457, 0x55a7a7f2, 0xfc7e7e82, 0x7a3d3d47, 0xc86464ac, 0xba5d5de7, 0x3219192b, 0xe6737395, 0xc06060a0, 0x19818198, 0x9e4f4fd1, 0xa3dcdc7f, 0x44222266, 0x542a2a7e, 0x3b9090ab, 0x0b888883, 0x8c4646ca, 0xc7eeee29, 0x6bb8b8d3, 0x2814143c, 0xa7dede79, 0xbc5e5ee2, 0x160b0b1d, 0xaddbdb76, 0xdbe0e03b, 0x64323256, 0x743a3a4e, 0x140a0a1e, 0x924949db, 0x0c06060a, 0x4824246c, 0xb85c5ce4, 0x9fc2c25d, 0xbdd3d36e, 0x43acacef, 0xc46262a6, 0x399191a8, 0x319595a4, 0xd3e4e437, 0xf279798b, 0xd5e7e732, 0x8bc8c843, 0x6e373759, 0xda6d6db7, 0x018d8d8c, 0xb1d5d564, 0x9c4e4ed2, 0x49a9a9e0, 0xd86c6cb4, 0xac5656fa, 0xf3f4f407, 0xcfeaea25, 0xca6565af, 0xf47a7a8e, 0x47aeaee9, 0x10080818, 0x6fbabad5, 0xf0787888, 0x4a25256f, 0x5c2e2e72, 0x381c1c24, 0x57a6a6f1, 0x73b4b4c7, 0x97c6c651, 0xcbe8e823, 0xa1dddd7c, 0xe874749c, 0x3e1f1f21, 0x964b4bdd, 0x61bdbddc, 0x0d8b8b86, 0x0f8a8a85, 0xe0707090, 0x7c3e3e42, 0x71b5b5c4, 0xcc6666aa, 0x904848d8, 0x06030305, 0xf7f6f601, 0x1c0e0e12, 0xc26161a3, 0x6a35355f, 0xae5757f9, 0x69b9b9d0, 0x17868691, 0x99c1c158, 0x3a1d1d27, 0x279e9eb9, 0xd9e1e138, 0xebf8f813, 0x2b9898b3, 0x22111133, 0xd26969bb, 0xa9d9d970, 0x078e8e89, 0x339494a7, 0x2d9b9bb6, 0x3c1e1e22, 0x15878792, 0xc9e9e920, 0x87cece49, 0xaa5555ff, 0x50282878, 0xa5dfdf7a, 0x038c8c8f, 0x59a1a1f8, 0x09898980, 0x1a0d0d17, 0x65bfbfda, 0xd7e6e631, 0x844242c6, 0xd06868b8, 0x824141c3, 0x299999b0, 0x5a2d2d77, 0x1e0f0f11, 0x7bb0b0cb, 0xa85454fc, 0x6dbbbbd6, 0x2c16163a ] + T2 = [ 0xa5c66363, 0x84f87c7c, 0x99ee7777, 0x8df67b7b, 0x0dfff2f2, 0xbdd66b6b, 0xb1de6f6f, 0x5491c5c5, 0x50603030, 0x03020101, 0xa9ce6767, 0x7d562b2b, 0x19e7fefe, 0x62b5d7d7, 0xe64dabab, 0x9aec7676, 0x458fcaca, 0x9d1f8282, 0x4089c9c9, 0x87fa7d7d, 0x15effafa, 0xebb25959, 0xc98e4747, 0x0bfbf0f0, 0xec41adad, 0x67b3d4d4, 0xfd5fa2a2, 0xea45afaf, 0xbf239c9c, 0xf753a4a4, 0x96e47272, 0x5b9bc0c0, 0xc275b7b7, 0x1ce1fdfd, 0xae3d9393, 0x6a4c2626, 0x5a6c3636, 0x417e3f3f, 0x02f5f7f7, 0x4f83cccc, 0x5c683434, 0xf451a5a5, 0x34d1e5e5, 0x08f9f1f1, 0x93e27171, 0x73abd8d8, 0x53623131, 0x3f2a1515, 0x0c080404, 0x5295c7c7, 0x65462323, 0x5e9dc3c3, 0x28301818, 0xa1379696, 0x0f0a0505, 0xb52f9a9a, 0x090e0707, 0x36241212, 0x9b1b8080, 0x3ddfe2e2, 0x26cdebeb, 0x694e2727, 0xcd7fb2b2, 0x9fea7575, 0x1b120909, 0x9e1d8383, 0x74582c2c, 0x2e341a1a, 0x2d361b1b, 0xb2dc6e6e, 0xeeb45a5a, 0xfb5ba0a0, 0xf6a45252, 0x4d763b3b, 0x61b7d6d6, 0xce7db3b3, 0x7b522929, 0x3edde3e3, 0x715e2f2f, 0x97138484, 0xf5a65353, 0x68b9d1d1, 0x00000000, 0x2cc1eded, 0x60402020, 0x1fe3fcfc, 0xc879b1b1, 0xedb65b5b, 0xbed46a6a, 0x468dcbcb, 0xd967bebe, 0x4b723939, 0xde944a4a, 0xd4984c4c, 0xe8b05858, 0x4a85cfcf, 0x6bbbd0d0, 0x2ac5efef, 0xe54faaaa, 0x16edfbfb, 0xc5864343, 0xd79a4d4d, 0x55663333, 0x94118585, 0xcf8a4545, 0x10e9f9f9, 0x06040202, 0x81fe7f7f, 0xf0a05050, 0x44783c3c, 0xba259f9f, 0xe34ba8a8, 0xf3a25151, 0xfe5da3a3, 0xc0804040, 0x8a058f8f, 0xad3f9292, 0xbc219d9d, 0x48703838, 0x04f1f5f5, 0xdf63bcbc, 0xc177b6b6, 0x75afdada, 0x63422121, 0x30201010, 0x1ae5ffff, 0x0efdf3f3, 0x6dbfd2d2, 0x4c81cdcd, 0x14180c0c, 0x35261313, 0x2fc3ecec, 0xe1be5f5f, 0xa2359797, 0xcc884444, 0x392e1717, 0x5793c4c4, 0xf255a7a7, 0x82fc7e7e, 0x477a3d3d, 0xacc86464, 0xe7ba5d5d, 0x2b321919, 0x95e67373, 0xa0c06060, 0x98198181, 0xd19e4f4f, 0x7fa3dcdc, 0x66442222, 0x7e542a2a, 0xab3b9090, 0x830b8888, 0xca8c4646, 0x29c7eeee, 0xd36bb8b8, 0x3c281414, 0x79a7dede, 0xe2bc5e5e, 0x1d160b0b, 0x76addbdb, 0x3bdbe0e0, 0x56643232, 0x4e743a3a, 0x1e140a0a, 0xdb924949, 0x0a0c0606, 0x6c482424, 0xe4b85c5c, 0x5d9fc2c2, 0x6ebdd3d3, 0xef43acac, 0xa6c46262, 0xa8399191, 0xa4319595, 0x37d3e4e4, 0x8bf27979, 0x32d5e7e7, 0x438bc8c8, 0x596e3737, 0xb7da6d6d, 0x8c018d8d, 0x64b1d5d5, 0xd29c4e4e, 0xe049a9a9, 0xb4d86c6c, 0xfaac5656, 0x07f3f4f4, 0x25cfeaea, 0xafca6565, 0x8ef47a7a, 0xe947aeae, 0x18100808, 0xd56fbaba, 0x88f07878, 0x6f4a2525, 0x725c2e2e, 0x24381c1c, 0xf157a6a6, 0xc773b4b4, 0x5197c6c6, 0x23cbe8e8, 0x7ca1dddd, 0x9ce87474, 0x213e1f1f, 0xdd964b4b, 0xdc61bdbd, 0x860d8b8b, 0x850f8a8a, 0x90e07070, 0x427c3e3e, 0xc471b5b5, 0xaacc6666, 0xd8904848, 0x05060303, 0x01f7f6f6, 0x121c0e0e, 0xa3c26161, 0x5f6a3535, 0xf9ae5757, 0xd069b9b9, 0x91178686, 0x5899c1c1, 0x273a1d1d, 0xb9279e9e, 0x38d9e1e1, 0x13ebf8f8, 0xb32b9898, 0x33221111, 0xbbd26969, 0x70a9d9d9, 0x89078e8e, 0xa7339494, 0xb62d9b9b, 0x223c1e1e, 0x92158787, 0x20c9e9e9, 0x4987cece, 0xffaa5555, 0x78502828, 0x7aa5dfdf, 0x8f038c8c, 0xf859a1a1, 0x80098989, 0x171a0d0d, 0xda65bfbf, 0x31d7e6e6, 0xc6844242, 0xb8d06868, 0xc3824141, 0xb0299999, 0x775a2d2d, 0x111e0f0f, 0xcb7bb0b0, 0xfca85454, 0xd66dbbbb, 0x3a2c1616 ] + T3 = [ 0x63a5c663, 0x7c84f87c, 0x7799ee77, 0x7b8df67b, 0xf20dfff2, 0x6bbdd66b, 0x6fb1de6f, 0xc55491c5, 0x30506030, 0x01030201, 0x67a9ce67, 0x2b7d562b, 0xfe19e7fe, 0xd762b5d7, 0xabe64dab, 0x769aec76, 0xca458fca, 0x829d1f82, 0xc94089c9, 0x7d87fa7d, 0xfa15effa, 0x59ebb259, 0x47c98e47, 0xf00bfbf0, 0xadec41ad, 0xd467b3d4, 0xa2fd5fa2, 0xafea45af, 0x9cbf239c, 0xa4f753a4, 0x7296e472, 0xc05b9bc0, 0xb7c275b7, 0xfd1ce1fd, 0x93ae3d93, 0x266a4c26, 0x365a6c36, 0x3f417e3f, 0xf702f5f7, 0xcc4f83cc, 0x345c6834, 0xa5f451a5, 0xe534d1e5, 0xf108f9f1, 0x7193e271, 0xd873abd8, 0x31536231, 0x153f2a15, 0x040c0804, 0xc75295c7, 0x23654623, 0xc35e9dc3, 0x18283018, 0x96a13796, 0x050f0a05, 0x9ab52f9a, 0x07090e07, 0x12362412, 0x809b1b80, 0xe23ddfe2, 0xeb26cdeb, 0x27694e27, 0xb2cd7fb2, 0x759fea75, 0x091b1209, 0x839e1d83, 0x2c74582c, 0x1a2e341a, 0x1b2d361b, 0x6eb2dc6e, 0x5aeeb45a, 0xa0fb5ba0, 0x52f6a452, 0x3b4d763b, 0xd661b7d6, 0xb3ce7db3, 0x297b5229, 0xe33edde3, 0x2f715e2f, 0x84971384, 0x53f5a653, 0xd168b9d1, 0x00000000, 0xed2cc1ed, 0x20604020, 0xfc1fe3fc, 0xb1c879b1, 0x5bedb65b, 0x6abed46a, 0xcb468dcb, 0xbed967be, 0x394b7239, 0x4ade944a, 0x4cd4984c, 0x58e8b058, 0xcf4a85cf, 0xd06bbbd0, 0xef2ac5ef, 0xaae54faa, 0xfb16edfb, 0x43c58643, 0x4dd79a4d, 0x33556633, 0x85941185, 0x45cf8a45, 0xf910e9f9, 0x02060402, 0x7f81fe7f, 0x50f0a050, 0x3c44783c, 0x9fba259f, 0xa8e34ba8, 0x51f3a251, 0xa3fe5da3, 0x40c08040, 0x8f8a058f, 0x92ad3f92, 0x9dbc219d, 0x38487038, 0xf504f1f5, 0xbcdf63bc, 0xb6c177b6, 0xda75afda, 0x21634221, 0x10302010, 0xff1ae5ff, 0xf30efdf3, 0xd26dbfd2, 0xcd4c81cd, 0x0c14180c, 0x13352613, 0xec2fc3ec, 0x5fe1be5f, 0x97a23597, 0x44cc8844, 0x17392e17, 0xc45793c4, 0xa7f255a7, 0x7e82fc7e, 0x3d477a3d, 0x64acc864, 0x5de7ba5d, 0x192b3219, 0x7395e673, 0x60a0c060, 0x81981981, 0x4fd19e4f, 0xdc7fa3dc, 0x22664422, 0x2a7e542a, 0x90ab3b90, 0x88830b88, 0x46ca8c46, 0xee29c7ee, 0xb8d36bb8, 0x143c2814, 0xde79a7de, 0x5ee2bc5e, 0x0b1d160b, 0xdb76addb, 0xe03bdbe0, 0x32566432, 0x3a4e743a, 0x0a1e140a, 0x49db9249, 0x060a0c06, 0x246c4824, 0x5ce4b85c, 0xc25d9fc2, 0xd36ebdd3, 0xacef43ac, 0x62a6c462, 0x91a83991, 0x95a43195, 0xe437d3e4, 0x798bf279, 0xe732d5e7, 0xc8438bc8, 0x37596e37, 0x6db7da6d, 0x8d8c018d, 0xd564b1d5, 0x4ed29c4e, 0xa9e049a9, 0x6cb4d86c, 0x56faac56, 0xf407f3f4, 0xea25cfea, 0x65afca65, 0x7a8ef47a, 0xaee947ae, 0x08181008, 0xbad56fba, 0x7888f078, 0x256f4a25, 0x2e725c2e, 0x1c24381c, 0xa6f157a6, 0xb4c773b4, 0xc65197c6, 0xe823cbe8, 0xdd7ca1dd, 0x749ce874, 0x1f213e1f, 0x4bdd964b, 0xbddc61bd, 0x8b860d8b, 0x8a850f8a, 0x7090e070, 0x3e427c3e, 0xb5c471b5, 0x66aacc66, 0x48d89048, 0x03050603, 0xf601f7f6, 0x0e121c0e, 0x61a3c261, 0x355f6a35, 0x57f9ae57, 0xb9d069b9, 0x86911786, 0xc15899c1, 0x1d273a1d, 0x9eb9279e, 0xe138d9e1, 0xf813ebf8, 0x98b32b98, 0x11332211, 0x69bbd269, 0xd970a9d9, 0x8e89078e, 0x94a73394, 0x9bb62d9b, 0x1e223c1e, 0x87921587, 0xe920c9e9, 0xce4987ce, 0x55ffaa55, 0x28785028, 0xdf7aa5df, 0x8c8f038c, 0xa1f859a1, 0x89800989, 0x0d171a0d, 0xbfda65bf, 0xe631d7e6, 0x42c68442, 0x68b8d068, 0x41c38241, 0x99b02999, 0x2d775a2d, 0x0f111e0f, 0xb0cb7bb0, 0x54fca854, 0xbbd66dbb, 0x163a2c16 ] + T4 = [ 0x6363a5c6, 0x7c7c84f8, 0x777799ee, 0x7b7b8df6, 0xf2f20dff, 0x6b6bbdd6, 0x6f6fb1de, 0xc5c55491, 0x30305060, 0x01010302, 0x6767a9ce, 0x2b2b7d56, 0xfefe19e7, 0xd7d762b5, 0xababe64d, 0x76769aec, 0xcaca458f, 0x82829d1f, 0xc9c94089, 0x7d7d87fa, 0xfafa15ef, 0x5959ebb2, 0x4747c98e, 0xf0f00bfb, 0xadadec41, 0xd4d467b3, 0xa2a2fd5f, 0xafafea45, 0x9c9cbf23, 0xa4a4f753, 0x727296e4, 0xc0c05b9b, 0xb7b7c275, 0xfdfd1ce1, 0x9393ae3d, 0x26266a4c, 0x36365a6c, 0x3f3f417e, 0xf7f702f5, 0xcccc4f83, 0x34345c68, 0xa5a5f451, 0xe5e534d1, 0xf1f108f9, 0x717193e2, 0xd8d873ab, 0x31315362, 0x15153f2a, 0x04040c08, 0xc7c75295, 0x23236546, 0xc3c35e9d, 0x18182830, 0x9696a137, 0x05050f0a, 0x9a9ab52f, 0x0707090e, 0x12123624, 0x80809b1b, 0xe2e23ddf, 0xebeb26cd, 0x2727694e, 0xb2b2cd7f, 0x75759fea, 0x09091b12, 0x83839e1d, 0x2c2c7458, 0x1a1a2e34, 0x1b1b2d36, 0x6e6eb2dc, 0x5a5aeeb4, 0xa0a0fb5b, 0x5252f6a4, 0x3b3b4d76, 0xd6d661b7, 0xb3b3ce7d, 0x29297b52, 0xe3e33edd, 0x2f2f715e, 0x84849713, 0x5353f5a6, 0xd1d168b9, 0x00000000, 0xeded2cc1, 0x20206040, 0xfcfc1fe3, 0xb1b1c879, 0x5b5bedb6, 0x6a6abed4, 0xcbcb468d, 0xbebed967, 0x39394b72, 0x4a4ade94, 0x4c4cd498, 0x5858e8b0, 0xcfcf4a85, 0xd0d06bbb, 0xefef2ac5, 0xaaaae54f, 0xfbfb16ed, 0x4343c586, 0x4d4dd79a, 0x33335566, 0x85859411, 0x4545cf8a, 0xf9f910e9, 0x02020604, 0x7f7f81fe, 0x5050f0a0, 0x3c3c4478, 0x9f9fba25, 0xa8a8e34b, 0x5151f3a2, 0xa3a3fe5d, 0x4040c080, 0x8f8f8a05, 0x9292ad3f, 0x9d9dbc21, 0x38384870, 0xf5f504f1, 0xbcbcdf63, 0xb6b6c177, 0xdada75af, 0x21216342, 0x10103020, 0xffff1ae5, 0xf3f30efd, 0xd2d26dbf, 0xcdcd4c81, 0x0c0c1418, 0x13133526, 0xecec2fc3, 0x5f5fe1be, 0x9797a235, 0x4444cc88, 0x1717392e, 0xc4c45793, 0xa7a7f255, 0x7e7e82fc, 0x3d3d477a, 0x6464acc8, 0x5d5de7ba, 0x19192b32, 0x737395e6, 0x6060a0c0, 0x81819819, 0x4f4fd19e, 0xdcdc7fa3, 0x22226644, 0x2a2a7e54, 0x9090ab3b, 0x8888830b, 0x4646ca8c, 0xeeee29c7, 0xb8b8d36b, 0x14143c28, 0xdede79a7, 0x5e5ee2bc, 0x0b0b1d16, 0xdbdb76ad, 0xe0e03bdb, 0x32325664, 0x3a3a4e74, 0x0a0a1e14, 0x4949db92, 0x06060a0c, 0x24246c48, 0x5c5ce4b8, 0xc2c25d9f, 0xd3d36ebd, 0xacacef43, 0x6262a6c4, 0x9191a839, 0x9595a431, 0xe4e437d3, 0x79798bf2, 0xe7e732d5, 0xc8c8438b, 0x3737596e, 0x6d6db7da, 0x8d8d8c01, 0xd5d564b1, 0x4e4ed29c, 0xa9a9e049, 0x6c6cb4d8, 0x5656faac, 0xf4f407f3, 0xeaea25cf, 0x6565afca, 0x7a7a8ef4, 0xaeaee947, 0x08081810, 0xbabad56f, 0x787888f0, 0x25256f4a, 0x2e2e725c, 0x1c1c2438, 0xa6a6f157, 0xb4b4c773, 0xc6c65197, 0xe8e823cb, 0xdddd7ca1, 0x74749ce8, 0x1f1f213e, 0x4b4bdd96, 0xbdbddc61, 0x8b8b860d, 0x8a8a850f, 0x707090e0, 0x3e3e427c, 0xb5b5c471, 0x6666aacc, 0x4848d890, 0x03030506, 0xf6f601f7, 0x0e0e121c, 0x6161a3c2, 0x35355f6a, 0x5757f9ae, 0xb9b9d069, 0x86869117, 0xc1c15899, 0x1d1d273a, 0x9e9eb927, 0xe1e138d9, 0xf8f813eb, 0x9898b32b, 0x11113322, 0x6969bbd2, 0xd9d970a9, 0x8e8e8907, 0x9494a733, 0x9b9bb62d, 0x1e1e223c, 0x87879215, 0xe9e920c9, 0xcece4987, 0x5555ffaa, 0x28287850, 0xdfdf7aa5, 0x8c8c8f03, 0xa1a1f859, 0x89898009, 0x0d0d171a, 0xbfbfda65, 0xe6e631d7, 0x4242c684, 0x6868b8d0, 0x4141c382, 0x9999b029, 0x2d2d775a, 0x0f0f111e, 0xb0b0cb7b, 0x5454fca8, 0xbbbbd66d, 0x16163a2c ] + + # Transformations for decryption + T5 = [ 0x51f4a750, 0x7e416553, 0x1a17a4c3, 0x3a275e96, 0x3bab6bcb, 0x1f9d45f1, 0xacfa58ab, 0x4be30393, 0x2030fa55, 0xad766df6, 0x88cc7691, 0xf5024c25, 0x4fe5d7fc, 0xc52acbd7, 0x26354480, 0xb562a38f, 0xdeb15a49, 0x25ba1b67, 0x45ea0e98, 0x5dfec0e1, 0xc32f7502, 0x814cf012, 0x8d4697a3, 0x6bd3f9c6, 0x038f5fe7, 0x15929c95, 0xbf6d7aeb, 0x955259da, 0xd4be832d, 0x587421d3, 0x49e06929, 0x8ec9c844, 0x75c2896a, 0xf48e7978, 0x99583e6b, 0x27b971dd, 0xbee14fb6, 0xf088ad17, 0xc920ac66, 0x7dce3ab4, 0x63df4a18, 0xe51a3182, 0x97513360, 0x62537f45, 0xb16477e0, 0xbb6bae84, 0xfe81a01c, 0xf9082b94, 0x70486858, 0x8f45fd19, 0x94de6c87, 0x527bf8b7, 0xab73d323, 0x724b02e2, 0xe31f8f57, 0x6655ab2a, 0xb2eb2807, 0x2fb5c203, 0x86c57b9a, 0xd33708a5, 0x302887f2, 0x23bfa5b2, 0x02036aba, 0xed16825c, 0x8acf1c2b, 0xa779b492, 0xf307f2f0, 0x4e69e2a1, 0x65daf4cd, 0x0605bed5, 0xd134621f, 0xc4a6fe8a, 0x342e539d, 0xa2f355a0, 0x058ae132, 0xa4f6eb75, 0x0b83ec39, 0x4060efaa, 0x5e719f06, 0xbd6e1051, 0x3e218af9, 0x96dd063d, 0xdd3e05ae, 0x4de6bd46, 0x91548db5, 0x71c45d05, 0x0406d46f, 0x605015ff, 0x1998fb24, 0xd6bde997, 0x894043cc, 0x67d99e77, 0xb0e842bd, 0x07898b88, 0xe7195b38, 0x79c8eedb, 0xa17c0a47, 0x7c420fe9, 0xf8841ec9, 0x00000000, 0x09808683, 0x322bed48, 0x1e1170ac, 0x6c5a724e, 0xfd0efffb, 0x0f853856, 0x3daed51e, 0x362d3927, 0x0a0fd964, 0x685ca621, 0x9b5b54d1, 0x24362e3a, 0x0c0a67b1, 0x9357e70f, 0xb4ee96d2, 0x1b9b919e, 0x80c0c54f, 0x61dc20a2, 0x5a774b69, 0x1c121a16, 0xe293ba0a, 0xc0a02ae5, 0x3c22e043, 0x121b171d, 0x0e090d0b, 0xf28bc7ad, 0x2db6a8b9, 0x141ea9c8, 0x57f11985, 0xaf75074c, 0xee99ddbb, 0xa37f60fd, 0xf701269f, 0x5c72f5bc, 0x44663bc5, 0x5bfb7e34, 0x8b432976, 0xcb23c6dc, 0xb6edfc68, 0xb8e4f163, 0xd731dcca, 0x42638510, 0x13972240, 0x84c61120, 0x854a247d, 0xd2bb3df8, 0xaef93211, 0xc729a16d, 0x1d9e2f4b, 0xdcb230f3, 0x0d8652ec, 0x77c1e3d0, 0x2bb3166c, 0xa970b999, 0x119448fa, 0x47e96422, 0xa8fc8cc4, 0xa0f03f1a, 0x567d2cd8, 0x223390ef, 0x87494ec7, 0xd938d1c1, 0x8ccaa2fe, 0x98d40b36, 0xa6f581cf, 0xa57ade28, 0xdab78e26, 0x3fadbfa4, 0x2c3a9de4, 0x5078920d, 0x6a5fcc9b, 0x547e4662, 0xf68d13c2, 0x90d8b8e8, 0x2e39f75e, 0x82c3aff5, 0x9f5d80be, 0x69d0937c, 0x6fd52da9, 0xcf2512b3, 0xc8ac993b, 0x10187da7, 0xe89c636e, 0xdb3bbb7b, 0xcd267809, 0x6e5918f4, 0xec9ab701, 0x834f9aa8, 0xe6956e65, 0xaaffe67e, 0x21bccf08, 0xef15e8e6, 0xbae79bd9, 0x4a6f36ce, 0xea9f09d4, 0x29b07cd6, 0x31a4b2af, 0x2a3f2331, 0xc6a59430, 0x35a266c0, 0x744ebc37, 0xfc82caa6, 0xe090d0b0, 0x33a7d815, 0xf104984a, 0x41ecdaf7, 0x7fcd500e, 0x1791f62f, 0x764dd68d, 0x43efb04d, 0xccaa4d54, 0xe49604df, 0x9ed1b5e3, 0x4c6a881b, 0xc12c1fb8, 0x4665517f, 0x9d5eea04, 0x018c355d, 0xfa877473, 0xfb0b412e, 0xb3671d5a, 0x92dbd252, 0xe9105633, 0x6dd64713, 0x9ad7618c, 0x37a10c7a, 0x59f8148e, 0xeb133c89, 0xcea927ee, 0xb761c935, 0xe11ce5ed, 0x7a47b13c, 0x9cd2df59, 0x55f2733f, 0x1814ce79, 0x73c737bf, 0x53f7cdea, 0x5ffdaa5b, 0xdf3d6f14, 0x7844db86, 0xcaaff381, 0xb968c43e, 0x3824342c, 0xc2a3405f, 0x161dc372, 0xbce2250c, 0x283c498b, 0xff0d9541, 0x39a80171, 0x080cb3de, 0xd8b4e49c, 0x6456c190, 0x7bcb8461, 0xd532b670, 0x486c5c74, 0xd0b85742 ] + T6 = [ 0x5051f4a7, 0x537e4165, 0xc31a17a4, 0x963a275e, 0xcb3bab6b, 0xf11f9d45, 0xabacfa58, 0x934be303, 0x552030fa, 0xf6ad766d, 0x9188cc76, 0x25f5024c, 0xfc4fe5d7, 0xd7c52acb, 0x80263544, 0x8fb562a3, 0x49deb15a, 0x6725ba1b, 0x9845ea0e, 0xe15dfec0, 0x02c32f75, 0x12814cf0, 0xa38d4697, 0xc66bd3f9, 0xe7038f5f, 0x9515929c, 0xebbf6d7a, 0xda955259, 0x2dd4be83, 0xd3587421, 0x2949e069, 0x448ec9c8, 0x6a75c289, 0x78f48e79, 0x6b99583e, 0xdd27b971, 0xb6bee14f, 0x17f088ad, 0x66c920ac, 0xb47dce3a, 0x1863df4a, 0x82e51a31, 0x60975133, 0x4562537f, 0xe0b16477, 0x84bb6bae, 0x1cfe81a0, 0x94f9082b, 0x58704868, 0x198f45fd, 0x8794de6c, 0xb7527bf8, 0x23ab73d3, 0xe2724b02, 0x57e31f8f, 0x2a6655ab, 0x07b2eb28, 0x032fb5c2, 0x9a86c57b, 0xa5d33708, 0xf2302887, 0xb223bfa5, 0xba02036a, 0x5ced1682, 0x2b8acf1c, 0x92a779b4, 0xf0f307f2, 0xa14e69e2, 0xcd65daf4, 0xd50605be, 0x1fd13462, 0x8ac4a6fe, 0x9d342e53, 0xa0a2f355, 0x32058ae1, 0x75a4f6eb, 0x390b83ec, 0xaa4060ef, 0x065e719f, 0x51bd6e10, 0xf93e218a, 0x3d96dd06, 0xaedd3e05, 0x464de6bd, 0xb591548d, 0x0571c45d, 0x6f0406d4, 0xff605015, 0x241998fb, 0x97d6bde9, 0xcc894043, 0x7767d99e, 0xbdb0e842, 0x8807898b, 0x38e7195b, 0xdb79c8ee, 0x47a17c0a, 0xe97c420f, 0xc9f8841e, 0x00000000, 0x83098086, 0x48322bed, 0xac1e1170, 0x4e6c5a72, 0xfbfd0eff, 0x560f8538, 0x1e3daed5, 0x27362d39, 0x640a0fd9, 0x21685ca6, 0xd19b5b54, 0x3a24362e, 0xb10c0a67, 0x0f9357e7, 0xd2b4ee96, 0x9e1b9b91, 0x4f80c0c5, 0xa261dc20, 0x695a774b, 0x161c121a, 0x0ae293ba, 0xe5c0a02a, 0x433c22e0, 0x1d121b17, 0x0b0e090d, 0xadf28bc7, 0xb92db6a8, 0xc8141ea9, 0x8557f119, 0x4caf7507, 0xbbee99dd, 0xfda37f60, 0x9ff70126, 0xbc5c72f5, 0xc544663b, 0x345bfb7e, 0x768b4329, 0xdccb23c6, 0x68b6edfc, 0x63b8e4f1, 0xcad731dc, 0x10426385, 0x40139722, 0x2084c611, 0x7d854a24, 0xf8d2bb3d, 0x11aef932, 0x6dc729a1, 0x4b1d9e2f, 0xf3dcb230, 0xec0d8652, 0xd077c1e3, 0x6c2bb316, 0x99a970b9, 0xfa119448, 0x2247e964, 0xc4a8fc8c, 0x1aa0f03f, 0xd8567d2c, 0xef223390, 0xc787494e, 0xc1d938d1, 0xfe8ccaa2, 0x3698d40b, 0xcfa6f581, 0x28a57ade, 0x26dab78e, 0xa43fadbf, 0xe42c3a9d, 0x0d507892, 0x9b6a5fcc, 0x62547e46, 0xc2f68d13, 0xe890d8b8, 0x5e2e39f7, 0xf582c3af, 0xbe9f5d80, 0x7c69d093, 0xa96fd52d, 0xb3cf2512, 0x3bc8ac99, 0xa710187d, 0x6ee89c63, 0x7bdb3bbb, 0x09cd2678, 0xf46e5918, 0x01ec9ab7, 0xa8834f9a, 0x65e6956e, 0x7eaaffe6, 0x0821bccf, 0xe6ef15e8, 0xd9bae79b, 0xce4a6f36, 0xd4ea9f09, 0xd629b07c, 0xaf31a4b2, 0x312a3f23, 0x30c6a594, 0xc035a266, 0x37744ebc, 0xa6fc82ca, 0xb0e090d0, 0x1533a7d8, 0x4af10498, 0xf741ecda, 0x0e7fcd50, 0x2f1791f6, 0x8d764dd6, 0x4d43efb0, 0x54ccaa4d, 0xdfe49604, 0xe39ed1b5, 0x1b4c6a88, 0xb8c12c1f, 0x7f466551, 0x049d5eea, 0x5d018c35, 0x73fa8774, 0x2efb0b41, 0x5ab3671d, 0x5292dbd2, 0x33e91056, 0x136dd647, 0x8c9ad761, 0x7a37a10c, 0x8e59f814, 0x89eb133c, 0xeecea927, 0x35b761c9, 0xede11ce5, 0x3c7a47b1, 0x599cd2df, 0x3f55f273, 0x791814ce, 0xbf73c737, 0xea53f7cd, 0x5b5ffdaa, 0x14df3d6f, 0x867844db, 0x81caaff3, 0x3eb968c4, 0x2c382434, 0x5fc2a340, 0x72161dc3, 0x0cbce225, 0x8b283c49, 0x41ff0d95, 0x7139a801, 0xde080cb3, 0x9cd8b4e4, 0x906456c1, 0x617bcb84, 0x70d532b6, 0x74486c5c, 0x42d0b857 ] + T7 = [ 0xa75051f4, 0x65537e41, 0xa4c31a17, 0x5e963a27, 0x6bcb3bab, 0x45f11f9d, 0x58abacfa, 0x03934be3, 0xfa552030, 0x6df6ad76, 0x769188cc, 0x4c25f502, 0xd7fc4fe5, 0xcbd7c52a, 0x44802635, 0xa38fb562, 0x5a49deb1, 0x1b6725ba, 0x0e9845ea, 0xc0e15dfe, 0x7502c32f, 0xf012814c, 0x97a38d46, 0xf9c66bd3, 0x5fe7038f, 0x9c951592, 0x7aebbf6d, 0x59da9552, 0x832dd4be, 0x21d35874, 0x692949e0, 0xc8448ec9, 0x896a75c2, 0x7978f48e, 0x3e6b9958, 0x71dd27b9, 0x4fb6bee1, 0xad17f088, 0xac66c920, 0x3ab47dce, 0x4a1863df, 0x3182e51a, 0x33609751, 0x7f456253, 0x77e0b164, 0xae84bb6b, 0xa01cfe81, 0x2b94f908, 0x68587048, 0xfd198f45, 0x6c8794de, 0xf8b7527b, 0xd323ab73, 0x02e2724b, 0x8f57e31f, 0xab2a6655, 0x2807b2eb, 0xc2032fb5, 0x7b9a86c5, 0x08a5d337, 0x87f23028, 0xa5b223bf, 0x6aba0203, 0x825ced16, 0x1c2b8acf, 0xb492a779, 0xf2f0f307, 0xe2a14e69, 0xf4cd65da, 0xbed50605, 0x621fd134, 0xfe8ac4a6, 0x539d342e, 0x55a0a2f3, 0xe132058a, 0xeb75a4f6, 0xec390b83, 0xefaa4060, 0x9f065e71, 0x1051bd6e, 0x8af93e21, 0x063d96dd, 0x05aedd3e, 0xbd464de6, 0x8db59154, 0x5d0571c4, 0xd46f0406, 0x15ff6050, 0xfb241998, 0xe997d6bd, 0x43cc8940, 0x9e7767d9, 0x42bdb0e8, 0x8b880789, 0x5b38e719, 0xeedb79c8, 0x0a47a17c, 0x0fe97c42, 0x1ec9f884, 0x00000000, 0x86830980, 0xed48322b, 0x70ac1e11, 0x724e6c5a, 0xfffbfd0e, 0x38560f85, 0xd51e3dae, 0x3927362d, 0xd9640a0f, 0xa621685c, 0x54d19b5b, 0x2e3a2436, 0x67b10c0a, 0xe70f9357, 0x96d2b4ee, 0x919e1b9b, 0xc54f80c0, 0x20a261dc, 0x4b695a77, 0x1a161c12, 0xba0ae293, 0x2ae5c0a0, 0xe0433c22, 0x171d121b, 0x0d0b0e09, 0xc7adf28b, 0xa8b92db6, 0xa9c8141e, 0x198557f1, 0x074caf75, 0xddbbee99, 0x60fda37f, 0x269ff701, 0xf5bc5c72, 0x3bc54466, 0x7e345bfb, 0x29768b43, 0xc6dccb23, 0xfc68b6ed, 0xf163b8e4, 0xdccad731, 0x85104263, 0x22401397, 0x112084c6, 0x247d854a, 0x3df8d2bb, 0x3211aef9, 0xa16dc729, 0x2f4b1d9e, 0x30f3dcb2, 0x52ec0d86, 0xe3d077c1, 0x166c2bb3, 0xb999a970, 0x48fa1194, 0x642247e9, 0x8cc4a8fc, 0x3f1aa0f0, 0x2cd8567d, 0x90ef2233, 0x4ec78749, 0xd1c1d938, 0xa2fe8cca, 0x0b3698d4, 0x81cfa6f5, 0xde28a57a, 0x8e26dab7, 0xbfa43fad, 0x9de42c3a, 0x920d5078, 0xcc9b6a5f, 0x4662547e, 0x13c2f68d, 0xb8e890d8, 0xf75e2e39, 0xaff582c3, 0x80be9f5d, 0x937c69d0, 0x2da96fd5, 0x12b3cf25, 0x993bc8ac, 0x7da71018, 0x636ee89c, 0xbb7bdb3b, 0x7809cd26, 0x18f46e59, 0xb701ec9a, 0x9aa8834f, 0x6e65e695, 0xe67eaaff, 0xcf0821bc, 0xe8e6ef15, 0x9bd9bae7, 0x36ce4a6f, 0x09d4ea9f, 0x7cd629b0, 0xb2af31a4, 0x23312a3f, 0x9430c6a5, 0x66c035a2, 0xbc37744e, 0xcaa6fc82, 0xd0b0e090, 0xd81533a7, 0x984af104, 0xdaf741ec, 0x500e7fcd, 0xf62f1791, 0xd68d764d, 0xb04d43ef, 0x4d54ccaa, 0x04dfe496, 0xb5e39ed1, 0x881b4c6a, 0x1fb8c12c, 0x517f4665, 0xea049d5e, 0x355d018c, 0x7473fa87, 0x412efb0b, 0x1d5ab367, 0xd25292db, 0x5633e910, 0x47136dd6, 0x618c9ad7, 0x0c7a37a1, 0x148e59f8, 0x3c89eb13, 0x27eecea9, 0xc935b761, 0xe5ede11c, 0xb13c7a47, 0xdf599cd2, 0x733f55f2, 0xce791814, 0x37bf73c7, 0xcdea53f7, 0xaa5b5ffd, 0x6f14df3d, 0xdb867844, 0xf381caaf, 0xc43eb968, 0x342c3824, 0x405fc2a3, 0xc372161d, 0x250cbce2, 0x498b283c, 0x9541ff0d, 0x017139a8, 0xb3de080c, 0xe49cd8b4, 0xc1906456, 0x84617bcb, 0xb670d532, 0x5c74486c, 0x5742d0b8 ] + T8 = [ 0xf4a75051, 0x4165537e, 0x17a4c31a, 0x275e963a, 0xab6bcb3b, 0x9d45f11f, 0xfa58abac, 0xe303934b, 0x30fa5520, 0x766df6ad, 0xcc769188, 0x024c25f5, 0xe5d7fc4f, 0x2acbd7c5, 0x35448026, 0x62a38fb5, 0xb15a49de, 0xba1b6725, 0xea0e9845, 0xfec0e15d, 0x2f7502c3, 0x4cf01281, 0x4697a38d, 0xd3f9c66b, 0x8f5fe703, 0x929c9515, 0x6d7aebbf, 0x5259da95, 0xbe832dd4, 0x7421d358, 0xe0692949, 0xc9c8448e, 0xc2896a75, 0x8e7978f4, 0x583e6b99, 0xb971dd27, 0xe14fb6be, 0x88ad17f0, 0x20ac66c9, 0xce3ab47d, 0xdf4a1863, 0x1a3182e5, 0x51336097, 0x537f4562, 0x6477e0b1, 0x6bae84bb, 0x81a01cfe, 0x082b94f9, 0x48685870, 0x45fd198f, 0xde6c8794, 0x7bf8b752, 0x73d323ab, 0x4b02e272, 0x1f8f57e3, 0x55ab2a66, 0xeb2807b2, 0xb5c2032f, 0xc57b9a86, 0x3708a5d3, 0x2887f230, 0xbfa5b223, 0x036aba02, 0x16825ced, 0xcf1c2b8a, 0x79b492a7, 0x07f2f0f3, 0x69e2a14e, 0xdaf4cd65, 0x05bed506, 0x34621fd1, 0xa6fe8ac4, 0x2e539d34, 0xf355a0a2, 0x8ae13205, 0xf6eb75a4, 0x83ec390b, 0x60efaa40, 0x719f065e, 0x6e1051bd, 0x218af93e, 0xdd063d96, 0x3e05aedd, 0xe6bd464d, 0x548db591, 0xc45d0571, 0x06d46f04, 0x5015ff60, 0x98fb2419, 0xbde997d6, 0x4043cc89, 0xd99e7767, 0xe842bdb0, 0x898b8807, 0x195b38e7, 0xc8eedb79, 0x7c0a47a1, 0x420fe97c, 0x841ec9f8, 0x00000000, 0x80868309, 0x2bed4832, 0x1170ac1e, 0x5a724e6c, 0x0efffbfd, 0x8538560f, 0xaed51e3d, 0x2d392736, 0x0fd9640a, 0x5ca62168, 0x5b54d19b, 0x362e3a24, 0x0a67b10c, 0x57e70f93, 0xee96d2b4, 0x9b919e1b, 0xc0c54f80, 0xdc20a261, 0x774b695a, 0x121a161c, 0x93ba0ae2, 0xa02ae5c0, 0x22e0433c, 0x1b171d12, 0x090d0b0e, 0x8bc7adf2, 0xb6a8b92d, 0x1ea9c814, 0xf1198557, 0x75074caf, 0x99ddbbee, 0x7f60fda3, 0x01269ff7, 0x72f5bc5c, 0x663bc544, 0xfb7e345b, 0x4329768b, 0x23c6dccb, 0xedfc68b6, 0xe4f163b8, 0x31dccad7, 0x63851042, 0x97224013, 0xc6112084, 0x4a247d85, 0xbb3df8d2, 0xf93211ae, 0x29a16dc7, 0x9e2f4b1d, 0xb230f3dc, 0x8652ec0d, 0xc1e3d077, 0xb3166c2b, 0x70b999a9, 0x9448fa11, 0xe9642247, 0xfc8cc4a8, 0xf03f1aa0, 0x7d2cd856, 0x3390ef22, 0x494ec787, 0x38d1c1d9, 0xcaa2fe8c, 0xd40b3698, 0xf581cfa6, 0x7ade28a5, 0xb78e26da, 0xadbfa43f, 0x3a9de42c, 0x78920d50, 0x5fcc9b6a, 0x7e466254, 0x8d13c2f6, 0xd8b8e890, 0x39f75e2e, 0xc3aff582, 0x5d80be9f, 0xd0937c69, 0xd52da96f, 0x2512b3cf, 0xac993bc8, 0x187da710, 0x9c636ee8, 0x3bbb7bdb, 0x267809cd, 0x5918f46e, 0x9ab701ec, 0x4f9aa883, 0x956e65e6, 0xffe67eaa, 0xbccf0821, 0x15e8e6ef, 0xe79bd9ba, 0x6f36ce4a, 0x9f09d4ea, 0xb07cd629, 0xa4b2af31, 0x3f23312a, 0xa59430c6, 0xa266c035, 0x4ebc3774, 0x82caa6fc, 0x90d0b0e0, 0xa7d81533, 0x04984af1, 0xecdaf741, 0xcd500e7f, 0x91f62f17, 0x4dd68d76, 0xefb04d43, 0xaa4d54cc, 0x9604dfe4, 0xd1b5e39e, 0x6a881b4c, 0x2c1fb8c1, 0x65517f46, 0x5eea049d, 0x8c355d01, 0x877473fa, 0x0b412efb, 0x671d5ab3, 0xdbd25292, 0x105633e9, 0xd647136d, 0xd7618c9a, 0xa10c7a37, 0xf8148e59, 0x133c89eb, 0xa927eece, 0x61c935b7, 0x1ce5ede1, 0x47b13c7a, 0xd2df599c, 0xf2733f55, 0x14ce7918, 0xc737bf73, 0xf7cdea53, 0xfdaa5b5f, 0x3d6f14df, 0x44db8678, 0xaff381ca, 0x68c43eb9, 0x24342c38, 0xa3405fc2, 0x1dc37216, 0xe2250cbc, 0x3c498b28, 0x0d9541ff, 0xa8017139, 0x0cb3de08, 0xb4e49cd8, 0x56c19064, 0xcb84617b, 0x32b670d5, 0x6c5c7448, 0xb85742d0 ] + + # Transformations for decryption key expansion + U1 = [ 0x00000000, 0x0e090d0b, 0x1c121a16, 0x121b171d, 0x3824342c, 0x362d3927, 0x24362e3a, 0x2a3f2331, 0x70486858, 0x7e416553, 0x6c5a724e, 0x62537f45, 0x486c5c74, 0x4665517f, 0x547e4662, 0x5a774b69, 0xe090d0b0, 0xee99ddbb, 0xfc82caa6, 0xf28bc7ad, 0xd8b4e49c, 0xd6bde997, 0xc4a6fe8a, 0xcaaff381, 0x90d8b8e8, 0x9ed1b5e3, 0x8ccaa2fe, 0x82c3aff5, 0xa8fc8cc4, 0xa6f581cf, 0xb4ee96d2, 0xbae79bd9, 0xdb3bbb7b, 0xd532b670, 0xc729a16d, 0xc920ac66, 0xe31f8f57, 0xed16825c, 0xff0d9541, 0xf104984a, 0xab73d323, 0xa57ade28, 0xb761c935, 0xb968c43e, 0x9357e70f, 0x9d5eea04, 0x8f45fd19, 0x814cf012, 0x3bab6bcb, 0x35a266c0, 0x27b971dd, 0x29b07cd6, 0x038f5fe7, 0x0d8652ec, 0x1f9d45f1, 0x119448fa, 0x4be30393, 0x45ea0e98, 0x57f11985, 0x59f8148e, 0x73c737bf, 0x7dce3ab4, 0x6fd52da9, 0x61dc20a2, 0xad766df6, 0xa37f60fd, 0xb16477e0, 0xbf6d7aeb, 0x955259da, 0x9b5b54d1, 0x894043cc, 0x87494ec7, 0xdd3e05ae, 0xd33708a5, 0xc12c1fb8, 0xcf2512b3, 0xe51a3182, 0xeb133c89, 0xf9082b94, 0xf701269f, 0x4de6bd46, 0x43efb04d, 0x51f4a750, 0x5ffdaa5b, 0x75c2896a, 0x7bcb8461, 0x69d0937c, 0x67d99e77, 0x3daed51e, 0x33a7d815, 0x21bccf08, 0x2fb5c203, 0x058ae132, 0x0b83ec39, 0x1998fb24, 0x1791f62f, 0x764dd68d, 0x7844db86, 0x6a5fcc9b, 0x6456c190, 0x4e69e2a1, 0x4060efaa, 0x527bf8b7, 0x5c72f5bc, 0x0605bed5, 0x080cb3de, 0x1a17a4c3, 0x141ea9c8, 0x3e218af9, 0x302887f2, 0x223390ef, 0x2c3a9de4, 0x96dd063d, 0x98d40b36, 0x8acf1c2b, 0x84c61120, 0xaef93211, 0xa0f03f1a, 0xb2eb2807, 0xbce2250c, 0xe6956e65, 0xe89c636e, 0xfa877473, 0xf48e7978, 0xdeb15a49, 0xd0b85742, 0xc2a3405f, 0xccaa4d54, 0x41ecdaf7, 0x4fe5d7fc, 0x5dfec0e1, 0x53f7cdea, 0x79c8eedb, 0x77c1e3d0, 0x65daf4cd, 0x6bd3f9c6, 0x31a4b2af, 0x3fadbfa4, 0x2db6a8b9, 0x23bfa5b2, 0x09808683, 0x07898b88, 0x15929c95, 0x1b9b919e, 0xa17c0a47, 0xaf75074c, 0xbd6e1051, 0xb3671d5a, 0x99583e6b, 0x97513360, 0x854a247d, 0x8b432976, 0xd134621f, 0xdf3d6f14, 0xcd267809, 0xc32f7502, 0xe9105633, 0xe7195b38, 0xf5024c25, 0xfb0b412e, 0x9ad7618c, 0x94de6c87, 0x86c57b9a, 0x88cc7691, 0xa2f355a0, 0xacfa58ab, 0xbee14fb6, 0xb0e842bd, 0xea9f09d4, 0xe49604df, 0xf68d13c2, 0xf8841ec9, 0xd2bb3df8, 0xdcb230f3, 0xcea927ee, 0xc0a02ae5, 0x7a47b13c, 0x744ebc37, 0x6655ab2a, 0x685ca621, 0x42638510, 0x4c6a881b, 0x5e719f06, 0x5078920d, 0x0a0fd964, 0x0406d46f, 0x161dc372, 0x1814ce79, 0x322bed48, 0x3c22e043, 0x2e39f75e, 0x2030fa55, 0xec9ab701, 0xe293ba0a, 0xf088ad17, 0xfe81a01c, 0xd4be832d, 0xdab78e26, 0xc8ac993b, 0xc6a59430, 0x9cd2df59, 0x92dbd252, 0x80c0c54f, 0x8ec9c844, 0xa4f6eb75, 0xaaffe67e, 0xb8e4f163, 0xb6edfc68, 0x0c0a67b1, 0x02036aba, 0x10187da7, 0x1e1170ac, 0x342e539d, 0x3a275e96, 0x283c498b, 0x26354480, 0x7c420fe9, 0x724b02e2, 0x605015ff, 0x6e5918f4, 0x44663bc5, 0x4a6f36ce, 0x587421d3, 0x567d2cd8, 0x37a10c7a, 0x39a80171, 0x2bb3166c, 0x25ba1b67, 0x0f853856, 0x018c355d, 0x13972240, 0x1d9e2f4b, 0x47e96422, 0x49e06929, 0x5bfb7e34, 0x55f2733f, 0x7fcd500e, 0x71c45d05, 0x63df4a18, 0x6dd64713, 0xd731dcca, 0xd938d1c1, 0xcb23c6dc, 0xc52acbd7, 0xef15e8e6, 0xe11ce5ed, 0xf307f2f0, 0xfd0efffb, 0xa779b492, 0xa970b999, 0xbb6bae84, 0xb562a38f, 0x9f5d80be, 0x91548db5, 0x834f9aa8, 0x8d4697a3 ] + U2 = [ 0x00000000, 0x0b0e090d, 0x161c121a, 0x1d121b17, 0x2c382434, 0x27362d39, 0x3a24362e, 0x312a3f23, 0x58704868, 0x537e4165, 0x4e6c5a72, 0x4562537f, 0x74486c5c, 0x7f466551, 0x62547e46, 0x695a774b, 0xb0e090d0, 0xbbee99dd, 0xa6fc82ca, 0xadf28bc7, 0x9cd8b4e4, 0x97d6bde9, 0x8ac4a6fe, 0x81caaff3, 0xe890d8b8, 0xe39ed1b5, 0xfe8ccaa2, 0xf582c3af, 0xc4a8fc8c, 0xcfa6f581, 0xd2b4ee96, 0xd9bae79b, 0x7bdb3bbb, 0x70d532b6, 0x6dc729a1, 0x66c920ac, 0x57e31f8f, 0x5ced1682, 0x41ff0d95, 0x4af10498, 0x23ab73d3, 0x28a57ade, 0x35b761c9, 0x3eb968c4, 0x0f9357e7, 0x049d5eea, 0x198f45fd, 0x12814cf0, 0xcb3bab6b, 0xc035a266, 0xdd27b971, 0xd629b07c, 0xe7038f5f, 0xec0d8652, 0xf11f9d45, 0xfa119448, 0x934be303, 0x9845ea0e, 0x8557f119, 0x8e59f814, 0xbf73c737, 0xb47dce3a, 0xa96fd52d, 0xa261dc20, 0xf6ad766d, 0xfda37f60, 0xe0b16477, 0xebbf6d7a, 0xda955259, 0xd19b5b54, 0xcc894043, 0xc787494e, 0xaedd3e05, 0xa5d33708, 0xb8c12c1f, 0xb3cf2512, 0x82e51a31, 0x89eb133c, 0x94f9082b, 0x9ff70126, 0x464de6bd, 0x4d43efb0, 0x5051f4a7, 0x5b5ffdaa, 0x6a75c289, 0x617bcb84, 0x7c69d093, 0x7767d99e, 0x1e3daed5, 0x1533a7d8, 0x0821bccf, 0x032fb5c2, 0x32058ae1, 0x390b83ec, 0x241998fb, 0x2f1791f6, 0x8d764dd6, 0x867844db, 0x9b6a5fcc, 0x906456c1, 0xa14e69e2, 0xaa4060ef, 0xb7527bf8, 0xbc5c72f5, 0xd50605be, 0xde080cb3, 0xc31a17a4, 0xc8141ea9, 0xf93e218a, 0xf2302887, 0xef223390, 0xe42c3a9d, 0x3d96dd06, 0x3698d40b, 0x2b8acf1c, 0x2084c611, 0x11aef932, 0x1aa0f03f, 0x07b2eb28, 0x0cbce225, 0x65e6956e, 0x6ee89c63, 0x73fa8774, 0x78f48e79, 0x49deb15a, 0x42d0b857, 0x5fc2a340, 0x54ccaa4d, 0xf741ecda, 0xfc4fe5d7, 0xe15dfec0, 0xea53f7cd, 0xdb79c8ee, 0xd077c1e3, 0xcd65daf4, 0xc66bd3f9, 0xaf31a4b2, 0xa43fadbf, 0xb92db6a8, 0xb223bfa5, 0x83098086, 0x8807898b, 0x9515929c, 0x9e1b9b91, 0x47a17c0a, 0x4caf7507, 0x51bd6e10, 0x5ab3671d, 0x6b99583e, 0x60975133, 0x7d854a24, 0x768b4329, 0x1fd13462, 0x14df3d6f, 0x09cd2678, 0x02c32f75, 0x33e91056, 0x38e7195b, 0x25f5024c, 0x2efb0b41, 0x8c9ad761, 0x8794de6c, 0x9a86c57b, 0x9188cc76, 0xa0a2f355, 0xabacfa58, 0xb6bee14f, 0xbdb0e842, 0xd4ea9f09, 0xdfe49604, 0xc2f68d13, 0xc9f8841e, 0xf8d2bb3d, 0xf3dcb230, 0xeecea927, 0xe5c0a02a, 0x3c7a47b1, 0x37744ebc, 0x2a6655ab, 0x21685ca6, 0x10426385, 0x1b4c6a88, 0x065e719f, 0x0d507892, 0x640a0fd9, 0x6f0406d4, 0x72161dc3, 0x791814ce, 0x48322bed, 0x433c22e0, 0x5e2e39f7, 0x552030fa, 0x01ec9ab7, 0x0ae293ba, 0x17f088ad, 0x1cfe81a0, 0x2dd4be83, 0x26dab78e, 0x3bc8ac99, 0x30c6a594, 0x599cd2df, 0x5292dbd2, 0x4f80c0c5, 0x448ec9c8, 0x75a4f6eb, 0x7eaaffe6, 0x63b8e4f1, 0x68b6edfc, 0xb10c0a67, 0xba02036a, 0xa710187d, 0xac1e1170, 0x9d342e53, 0x963a275e, 0x8b283c49, 0x80263544, 0xe97c420f, 0xe2724b02, 0xff605015, 0xf46e5918, 0xc544663b, 0xce4a6f36, 0xd3587421, 0xd8567d2c, 0x7a37a10c, 0x7139a801, 0x6c2bb316, 0x6725ba1b, 0x560f8538, 0x5d018c35, 0x40139722, 0x4b1d9e2f, 0x2247e964, 0x2949e069, 0x345bfb7e, 0x3f55f273, 0x0e7fcd50, 0x0571c45d, 0x1863df4a, 0x136dd647, 0xcad731dc, 0xc1d938d1, 0xdccb23c6, 0xd7c52acb, 0xe6ef15e8, 0xede11ce5, 0xf0f307f2, 0xfbfd0eff, 0x92a779b4, 0x99a970b9, 0x84bb6bae, 0x8fb562a3, 0xbe9f5d80, 0xb591548d, 0xa8834f9a, 0xa38d4697 ] + U3 = [ 0x00000000, 0x0d0b0e09, 0x1a161c12, 0x171d121b, 0x342c3824, 0x3927362d, 0x2e3a2436, 0x23312a3f, 0x68587048, 0x65537e41, 0x724e6c5a, 0x7f456253, 0x5c74486c, 0x517f4665, 0x4662547e, 0x4b695a77, 0xd0b0e090, 0xddbbee99, 0xcaa6fc82, 0xc7adf28b, 0xe49cd8b4, 0xe997d6bd, 0xfe8ac4a6, 0xf381caaf, 0xb8e890d8, 0xb5e39ed1, 0xa2fe8cca, 0xaff582c3, 0x8cc4a8fc, 0x81cfa6f5, 0x96d2b4ee, 0x9bd9bae7, 0xbb7bdb3b, 0xb670d532, 0xa16dc729, 0xac66c920, 0x8f57e31f, 0x825ced16, 0x9541ff0d, 0x984af104, 0xd323ab73, 0xde28a57a, 0xc935b761, 0xc43eb968, 0xe70f9357, 0xea049d5e, 0xfd198f45, 0xf012814c, 0x6bcb3bab, 0x66c035a2, 0x71dd27b9, 0x7cd629b0, 0x5fe7038f, 0x52ec0d86, 0x45f11f9d, 0x48fa1194, 0x03934be3, 0x0e9845ea, 0x198557f1, 0x148e59f8, 0x37bf73c7, 0x3ab47dce, 0x2da96fd5, 0x20a261dc, 0x6df6ad76, 0x60fda37f, 0x77e0b164, 0x7aebbf6d, 0x59da9552, 0x54d19b5b, 0x43cc8940, 0x4ec78749, 0x05aedd3e, 0x08a5d337, 0x1fb8c12c, 0x12b3cf25, 0x3182e51a, 0x3c89eb13, 0x2b94f908, 0x269ff701, 0xbd464de6, 0xb04d43ef, 0xa75051f4, 0xaa5b5ffd, 0x896a75c2, 0x84617bcb, 0x937c69d0, 0x9e7767d9, 0xd51e3dae, 0xd81533a7, 0xcf0821bc, 0xc2032fb5, 0xe132058a, 0xec390b83, 0xfb241998, 0xf62f1791, 0xd68d764d, 0xdb867844, 0xcc9b6a5f, 0xc1906456, 0xe2a14e69, 0xefaa4060, 0xf8b7527b, 0xf5bc5c72, 0xbed50605, 0xb3de080c, 0xa4c31a17, 0xa9c8141e, 0x8af93e21, 0x87f23028, 0x90ef2233, 0x9de42c3a, 0x063d96dd, 0x0b3698d4, 0x1c2b8acf, 0x112084c6, 0x3211aef9, 0x3f1aa0f0, 0x2807b2eb, 0x250cbce2, 0x6e65e695, 0x636ee89c, 0x7473fa87, 0x7978f48e, 0x5a49deb1, 0x5742d0b8, 0x405fc2a3, 0x4d54ccaa, 0xdaf741ec, 0xd7fc4fe5, 0xc0e15dfe, 0xcdea53f7, 0xeedb79c8, 0xe3d077c1, 0xf4cd65da, 0xf9c66bd3, 0xb2af31a4, 0xbfa43fad, 0xa8b92db6, 0xa5b223bf, 0x86830980, 0x8b880789, 0x9c951592, 0x919e1b9b, 0x0a47a17c, 0x074caf75, 0x1051bd6e, 0x1d5ab367, 0x3e6b9958, 0x33609751, 0x247d854a, 0x29768b43, 0x621fd134, 0x6f14df3d, 0x7809cd26, 0x7502c32f, 0x5633e910, 0x5b38e719, 0x4c25f502, 0x412efb0b, 0x618c9ad7, 0x6c8794de, 0x7b9a86c5, 0x769188cc, 0x55a0a2f3, 0x58abacfa, 0x4fb6bee1, 0x42bdb0e8, 0x09d4ea9f, 0x04dfe496, 0x13c2f68d, 0x1ec9f884, 0x3df8d2bb, 0x30f3dcb2, 0x27eecea9, 0x2ae5c0a0, 0xb13c7a47, 0xbc37744e, 0xab2a6655, 0xa621685c, 0x85104263, 0x881b4c6a, 0x9f065e71, 0x920d5078, 0xd9640a0f, 0xd46f0406, 0xc372161d, 0xce791814, 0xed48322b, 0xe0433c22, 0xf75e2e39, 0xfa552030, 0xb701ec9a, 0xba0ae293, 0xad17f088, 0xa01cfe81, 0x832dd4be, 0x8e26dab7, 0x993bc8ac, 0x9430c6a5, 0xdf599cd2, 0xd25292db, 0xc54f80c0, 0xc8448ec9, 0xeb75a4f6, 0xe67eaaff, 0xf163b8e4, 0xfc68b6ed, 0x67b10c0a, 0x6aba0203, 0x7da71018, 0x70ac1e11, 0x539d342e, 0x5e963a27, 0x498b283c, 0x44802635, 0x0fe97c42, 0x02e2724b, 0x15ff6050, 0x18f46e59, 0x3bc54466, 0x36ce4a6f, 0x21d35874, 0x2cd8567d, 0x0c7a37a1, 0x017139a8, 0x166c2bb3, 0x1b6725ba, 0x38560f85, 0x355d018c, 0x22401397, 0x2f4b1d9e, 0x642247e9, 0x692949e0, 0x7e345bfb, 0x733f55f2, 0x500e7fcd, 0x5d0571c4, 0x4a1863df, 0x47136dd6, 0xdccad731, 0xd1c1d938, 0xc6dccb23, 0xcbd7c52a, 0xe8e6ef15, 0xe5ede11c, 0xf2f0f307, 0xfffbfd0e, 0xb492a779, 0xb999a970, 0xae84bb6b, 0xa38fb562, 0x80be9f5d, 0x8db59154, 0x9aa8834f, 0x97a38d46 ] + U4 = [ 0x00000000, 0x090d0b0e, 0x121a161c, 0x1b171d12, 0x24342c38, 0x2d392736, 0x362e3a24, 0x3f23312a, 0x48685870, 0x4165537e, 0x5a724e6c, 0x537f4562, 0x6c5c7448, 0x65517f46, 0x7e466254, 0x774b695a, 0x90d0b0e0, 0x99ddbbee, 0x82caa6fc, 0x8bc7adf2, 0xb4e49cd8, 0xbde997d6, 0xa6fe8ac4, 0xaff381ca, 0xd8b8e890, 0xd1b5e39e, 0xcaa2fe8c, 0xc3aff582, 0xfc8cc4a8, 0xf581cfa6, 0xee96d2b4, 0xe79bd9ba, 0x3bbb7bdb, 0x32b670d5, 0x29a16dc7, 0x20ac66c9, 0x1f8f57e3, 0x16825ced, 0x0d9541ff, 0x04984af1, 0x73d323ab, 0x7ade28a5, 0x61c935b7, 0x68c43eb9, 0x57e70f93, 0x5eea049d, 0x45fd198f, 0x4cf01281, 0xab6bcb3b, 0xa266c035, 0xb971dd27, 0xb07cd629, 0x8f5fe703, 0x8652ec0d, 0x9d45f11f, 0x9448fa11, 0xe303934b, 0xea0e9845, 0xf1198557, 0xf8148e59, 0xc737bf73, 0xce3ab47d, 0xd52da96f, 0xdc20a261, 0x766df6ad, 0x7f60fda3, 0x6477e0b1, 0x6d7aebbf, 0x5259da95, 0x5b54d19b, 0x4043cc89, 0x494ec787, 0x3e05aedd, 0x3708a5d3, 0x2c1fb8c1, 0x2512b3cf, 0x1a3182e5, 0x133c89eb, 0x082b94f9, 0x01269ff7, 0xe6bd464d, 0xefb04d43, 0xf4a75051, 0xfdaa5b5f, 0xc2896a75, 0xcb84617b, 0xd0937c69, 0xd99e7767, 0xaed51e3d, 0xa7d81533, 0xbccf0821, 0xb5c2032f, 0x8ae13205, 0x83ec390b, 0x98fb2419, 0x91f62f17, 0x4dd68d76, 0x44db8678, 0x5fcc9b6a, 0x56c19064, 0x69e2a14e, 0x60efaa40, 0x7bf8b752, 0x72f5bc5c, 0x05bed506, 0x0cb3de08, 0x17a4c31a, 0x1ea9c814, 0x218af93e, 0x2887f230, 0x3390ef22, 0x3a9de42c, 0xdd063d96, 0xd40b3698, 0xcf1c2b8a, 0xc6112084, 0xf93211ae, 0xf03f1aa0, 0xeb2807b2, 0xe2250cbc, 0x956e65e6, 0x9c636ee8, 0x877473fa, 0x8e7978f4, 0xb15a49de, 0xb85742d0, 0xa3405fc2, 0xaa4d54cc, 0xecdaf741, 0xe5d7fc4f, 0xfec0e15d, 0xf7cdea53, 0xc8eedb79, 0xc1e3d077, 0xdaf4cd65, 0xd3f9c66b, 0xa4b2af31, 0xadbfa43f, 0xb6a8b92d, 0xbfa5b223, 0x80868309, 0x898b8807, 0x929c9515, 0x9b919e1b, 0x7c0a47a1, 0x75074caf, 0x6e1051bd, 0x671d5ab3, 0x583e6b99, 0x51336097, 0x4a247d85, 0x4329768b, 0x34621fd1, 0x3d6f14df, 0x267809cd, 0x2f7502c3, 0x105633e9, 0x195b38e7, 0x024c25f5, 0x0b412efb, 0xd7618c9a, 0xde6c8794, 0xc57b9a86, 0xcc769188, 0xf355a0a2, 0xfa58abac, 0xe14fb6be, 0xe842bdb0, 0x9f09d4ea, 0x9604dfe4, 0x8d13c2f6, 0x841ec9f8, 0xbb3df8d2, 0xb230f3dc, 0xa927eece, 0xa02ae5c0, 0x47b13c7a, 0x4ebc3774, 0x55ab2a66, 0x5ca62168, 0x63851042, 0x6a881b4c, 0x719f065e, 0x78920d50, 0x0fd9640a, 0x06d46f04, 0x1dc37216, 0x14ce7918, 0x2bed4832, 0x22e0433c, 0x39f75e2e, 0x30fa5520, 0x9ab701ec, 0x93ba0ae2, 0x88ad17f0, 0x81a01cfe, 0xbe832dd4, 0xb78e26da, 0xac993bc8, 0xa59430c6, 0xd2df599c, 0xdbd25292, 0xc0c54f80, 0xc9c8448e, 0xf6eb75a4, 0xffe67eaa, 0xe4f163b8, 0xedfc68b6, 0x0a67b10c, 0x036aba02, 0x187da710, 0x1170ac1e, 0x2e539d34, 0x275e963a, 0x3c498b28, 0x35448026, 0x420fe97c, 0x4b02e272, 0x5015ff60, 0x5918f46e, 0x663bc544, 0x6f36ce4a, 0x7421d358, 0x7d2cd856, 0xa10c7a37, 0xa8017139, 0xb3166c2b, 0xba1b6725, 0x8538560f, 0x8c355d01, 0x97224013, 0x9e2f4b1d, 0xe9642247, 0xe0692949, 0xfb7e345b, 0xf2733f55, 0xcd500e7f, 0xc45d0571, 0xdf4a1863, 0xd647136d, 0x31dccad7, 0x38d1c1d9, 0x23c6dccb, 0x2acbd7c5, 0x15e8e6ef, 0x1ce5ede1, 0x07f2f0f3, 0x0efffbfd, 0x79b492a7, 0x70b999a9, 0x6bae84bb, 0x62a38fb5, 0x5d80be9f, 0x548db591, 0x4f9aa883, 0x4697a38d ] + + def __init__(self, key): + + if len(key) not in (16, 24, 32): + raise ValueError('Invalid key size') + + rounds = self.number_of_rounds[len(key)] + + # Encryption round keys + self._Ke = [[0] * 4 for i in xrange(rounds + 1)] + + # Decryption round keys + self._Kd = [[0] * 4 for i in xrange(rounds + 1)] + + round_key_count = (rounds + 1) * 4 + KC = len(key) // 4 + + # Convert the key into ints + tk = [ struct.unpack('>i', key[i:i + 4])[0] for i in xrange(0, len(key), 4) ] + + # Copy values into round key arrays + for i in xrange(0, KC): + self._Ke[i // 4][i % 4] = tk[i] + self._Kd[rounds - (i // 4)][i % 4] = tk[i] + + # Key expansion (fips-197 section 5.2) + rconpointer = 0 + t = KC + while t < round_key_count: + + tt = tk[KC - 1] + tk[0] ^= ((self.S[(tt >> 16) & 0xFF] << 24) ^ + (self.S[(tt >> 8) & 0xFF] << 16) ^ + (self.S[ tt & 0xFF] << 8) ^ + self.S[(tt >> 24) & 0xFF] ^ + (self.rcon[rconpointer] << 24)) + rconpointer += 1 + + if KC != 8: + for i in xrange(1, KC): + tk[i] ^= tk[i - 1] + + # Key expansion for 256-bit keys is "slightly different" (fips-197) + else: + for i in xrange(1, KC // 2): + tk[i] ^= tk[i - 1] + tt = tk[KC // 2 - 1] + + tk[KC // 2] ^= (self.S[ tt & 0xFF] ^ + (self.S[(tt >> 8) & 0xFF] << 8) ^ + (self.S[(tt >> 16) & 0xFF] << 16) ^ + (self.S[(tt >> 24) & 0xFF] << 24)) + + for i in xrange(KC // 2 + 1, KC): + tk[i] ^= tk[i - 1] + + # Copy values into round key arrays + j = 0 + while j < KC and t < round_key_count: + self._Ke[t // 4][t % 4] = tk[j] + self._Kd[rounds - (t // 4)][t % 4] = tk[j] + j += 1 + t += 1 + + # Inverse-Cipher-ify the decryption round key (fips-197 section 5.3) + for r in xrange(1, rounds): + for j in xrange(0, 4): + tt = self._Kd[r][j] + self._Kd[r][j] = (self.U1[(tt >> 24) & 0xFF] ^ + self.U2[(tt >> 16) & 0xFF] ^ + self.U3[(tt >> 8) & 0xFF] ^ + self.U4[ tt & 0xFF]) + + def encrypt(self, plaintext): + 'Encrypt a block of plain text using the AES block cipher.' + + if len(plaintext) != 16: + raise ValueError('wrong block length') + + rounds = len(self._Ke) - 1 + (s1, s2, s3) = [1, 2, 3] + a = [0, 0, 0, 0] + + # Convert plaintext to (ints ^ key) + t = [(_compact_word(plaintext[4 * i:4 * i + 4]) ^ self._Ke[0][i]) for i in xrange(0, 4)] + + # Apply round transforms + for r in xrange(1, rounds): + for i in xrange(0, 4): + a[i] = (self.T1[(t[ i ] >> 24) & 0xFF] ^ + self.T2[(t[(i + s1) % 4] >> 16) & 0xFF] ^ + self.T3[(t[(i + s2) % 4] >> 8) & 0xFF] ^ + self.T4[ t[(i + s3) % 4] & 0xFF] ^ + self._Ke[r][i]) + t = copy.copy(a) + + # The last round is special + result = [ ] + for i in xrange(0, 4): + tt = self._Ke[rounds][i] + result.append((self.S[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF) + result.append((self.S[(t[(i + s1) % 4] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF) + result.append((self.S[(t[(i + s2) % 4] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF) + result.append((self.S[ t[(i + s3) % 4] & 0xFF] ^ tt ) & 0xFF) + + return result + + def decrypt(self, ciphertext): + 'Decrypt a block of cipher text using the AES block cipher.' + + if len(ciphertext) != 16: + raise ValueError('wrong block length') + + rounds = len(self._Kd) - 1 + (s1, s2, s3) = [3, 2, 1] + a = [0, 0, 0, 0] + + # Convert ciphertext to (ints ^ key) + t = [(_compact_word(ciphertext[4 * i:4 * i + 4]) ^ self._Kd[0][i]) for i in xrange(0, 4)] + + # Apply round transforms + for r in xrange(1, rounds): + for i in xrange(0, 4): + a[i] = (self.T5[(t[ i ] >> 24) & 0xFF] ^ + self.T6[(t[(i + s1) % 4] >> 16) & 0xFF] ^ + self.T7[(t[(i + s2) % 4] >> 8) & 0xFF] ^ + self.T8[ t[(i + s3) % 4] & 0xFF] ^ + self._Kd[r][i]) + t = copy.copy(a) + + # The last round is special + result = [ ] + for i in xrange(0, 4): + tt = self._Kd[rounds][i] + result.append((self.Si[(t[ i ] >> 24) & 0xFF] ^ (tt >> 24)) & 0xFF) + result.append((self.Si[(t[(i + s1) % 4] >> 16) & 0xFF] ^ (tt >> 16)) & 0xFF) + result.append((self.Si[(t[(i + s2) % 4] >> 8) & 0xFF] ^ (tt >> 8)) & 0xFF) + result.append((self.Si[ t[(i + s3) % 4] & 0xFF] ^ tt ) & 0xFF) + + return result + + +class Counter(object): + '''A counter object for the Counter (CTR) mode of operation. + + To create a custom counter, you can usually just override the + increment method.''' + + def __init__(self, initial_value = 1): + + # Convert the value into an array of bytes long + self._counter = [ ((initial_value >> i) % 256) for i in xrange(128 - 8, -1, -8) ] + + value = property(lambda s: s._counter) + + def increment(self): + '''Increment the counter (overflow rolls back to 0).''' + + for i in xrange(len(self._counter) - 1, -1, -1): + self._counter[i] += 1 + + if self._counter[i] < 256: break + + # Carry the one + self._counter[i] = 0 + + # Overflow + else: + self._counter = [ 0 ] * len(self._counter) + + +class AESBlockModeOfOperation(object): + '''Super-class for AES modes of operation that require blocks.''' + def __init__(self, key): + self._aes = AES(key) + + def decrypt(self, ciphertext): + raise Exception('not implemented') + + def encrypt(self, plaintext): + raise Exception('not implemented') + + +class AESStreamModeOfOperation(AESBlockModeOfOperation): + '''Super-class for AES modes of operation that are stream-ciphers.''' + +class AESSegmentModeOfOperation(AESStreamModeOfOperation): + '''Super-class for AES modes of operation that segment data.''' + + segment_bytes = 16 + + + +class AESModeOfOperationECB(AESBlockModeOfOperation): + '''AES Electronic Codebook Mode of Operation. + + o Block-cipher, so data must be padded to 16 byte boundaries + + Security Notes: + o This mode is not recommended + o Any two identical blocks produce identical encrypted values, + exposing data patterns. (See the image of Tux on wikipedia) + + Also see: + o https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Electronic_codebook_.28ECB.29 + o See NIST SP800-38A (http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf); section 6.1''' + + + name = "Electronic Codebook (ECB)" + + def encrypt(self, plaintext): + if len(plaintext) != 16: + raise ValueError('plaintext block must be 16 bytes') + + plaintext = _string_to_bytes(plaintext) + return _bytes_to_string(self._aes.encrypt(plaintext)) + + def decrypt(self, ciphertext): + if len(ciphertext) != 16: + raise ValueError('ciphertext block must be 16 bytes') + + ciphertext = _string_to_bytes(ciphertext) + return _bytes_to_string(self._aes.decrypt(ciphertext)) + + + +class AESModeOfOperationCBC(AESBlockModeOfOperation): + '''AES Cipher-Block Chaining Mode of Operation. + + o The Initialization Vector (IV) + o Block-cipher, so data must be padded to 16 byte boundaries + o An incorrect initialization vector will only cause the first + block to be corrupt; all other blocks will be intact + o A corrupt bit in the cipher text will cause a block to be + corrupted, and the next block to be inverted, but all other + blocks will be intact. + + Security Notes: + o This method (and CTR) ARE recommended. + + Also see: + o https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher-block_chaining_.28CBC.29 + o See NIST SP800-38A (http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf); section 6.2''' + + + name = "Cipher-Block Chaining (CBC)" + + def __init__(self, key, iv = None): + if iv is None: + self._last_cipherblock = [ 0 ] * 16 + elif len(iv) != 16: + raise ValueError('initialization vector must be 16 bytes') + else: + self._last_cipherblock = _string_to_bytes(iv) + + AESBlockModeOfOperation.__init__(self, key) + + def encrypt(self, plaintext): + if len(plaintext) != 16: + raise ValueError('plaintext block must be 16 bytes') + + plaintext = _string_to_bytes(plaintext) + precipherblock = [ (p ^ l) for (p, l) in zip(plaintext, self._last_cipherblock) ] + self._last_cipherblock = self._aes.encrypt(precipherblock) + + return _bytes_to_string(self._last_cipherblock) + + def decrypt(self, ciphertext): + if len(ciphertext) != 16: + raise ValueError('ciphertext block must be 16 bytes') + + cipherblock = _string_to_bytes(ciphertext) + plaintext = [ (p ^ l) for (p, l) in zip(self._aes.decrypt(cipherblock), self._last_cipherblock) ] + self._last_cipherblock = cipherblock + + return _bytes_to_string(plaintext) + + + +class AESModeOfOperationCFB(AESSegmentModeOfOperation): + '''AES Cipher Feedback Mode of Operation. + + o A stream-cipher, so input does not need to be padded to blocks, + but does need to be padded to segment_size + + Also see: + o https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Cipher_feedback_.28CFB.29 + o See NIST SP800-38A (http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf); section 6.3''' + + + name = "Cipher Feedback (CFB)" + + def __init__(self, key, iv, segment_size = 1): + if segment_size == 0: segment_size = 1 + + if iv is None: + self._shift_register = [ 0 ] * 16 + elif len(iv) != 16: + raise ValueError('initialization vector must be 16 bytes') + else: + self._shift_register = _string_to_bytes(iv) + + self._segment_bytes = segment_size + + AESBlockModeOfOperation.__init__(self, key) + + segment_bytes = property(lambda s: s._segment_bytes) + + def encrypt(self, plaintext): + if len(plaintext) % self._segment_bytes != 0: + raise ValueError('plaintext block must be a multiple of segment_size') + + plaintext = _string_to_bytes(plaintext) + + # Break block into segments + encrypted = [ ] + for i in xrange(0, len(plaintext), self._segment_bytes): + plaintext_segment = plaintext[i: i + self._segment_bytes] + xor_segment = self._aes.encrypt(self._shift_register)[:len(plaintext_segment)] + cipher_segment = [ (p ^ x) for (p, x) in zip(plaintext_segment, xor_segment) ] + + # Shift the top bits out and the ciphertext in + self._shift_register = _concat_list(self._shift_register[len(cipher_segment):], cipher_segment) + + encrypted.extend(cipher_segment) + + return _bytes_to_string(encrypted) + + def decrypt(self, ciphertext): + if len(ciphertext) % self._segment_bytes != 0: + raise ValueError('ciphertext block must be a multiple of segment_size') + + ciphertext = _string_to_bytes(ciphertext) + + # Break block into segments + decrypted = [ ] + for i in xrange(0, len(ciphertext), self._segment_bytes): + cipher_segment = ciphertext[i: i + self._segment_bytes] + xor_segment = self._aes.encrypt(self._shift_register)[:len(cipher_segment)] + plaintext_segment = [ (p ^ x) for (p, x) in zip(cipher_segment, xor_segment) ] + + # Shift the top bits out and the ciphertext in + self._shift_register = _concat_list(self._shift_register[len(cipher_segment):], cipher_segment) + + decrypted.extend(plaintext_segment) + + return _bytes_to_string(decrypted) + + + +class AESModeOfOperationOFB(AESStreamModeOfOperation): + '''AES Output Feedback Mode of Operation. + + o A stream-cipher, so input does not need to be padded to blocks, + allowing arbitrary length data. + o A bit twiddled in the cipher text, twiddles the same bit in the + same bit in the plain text, which can be useful for error + correction techniques. + + Also see: + o https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Output_feedback_.28OFB.29 + o See NIST SP800-38A (http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf); section 6.4''' + + + name = "Output Feedback (OFB)" + + def __init__(self, key, iv = None): + if iv is None: + self._last_precipherblock = [ 0 ] * 16 + elif len(iv) != 16: + raise ValueError('initialization vector must be 16 bytes') + else: + self._last_precipherblock = _string_to_bytes(iv) + + self._remaining_block = [ ] + + AESBlockModeOfOperation.__init__(self, key) + + def encrypt(self, plaintext): + encrypted = [ ] + for p in _string_to_bytes(plaintext): + if len(self._remaining_block) == 0: + self._remaining_block = self._aes.encrypt(self._last_precipherblock) + self._last_precipherblock = [ ] + precipherbyte = self._remaining_block.pop(0) + self._last_precipherblock.append(precipherbyte) + cipherbyte = p ^ precipherbyte + encrypted.append(cipherbyte) + + return _bytes_to_string(encrypted) + + def decrypt(self, ciphertext): + # AES-OFB is symetric + return self.encrypt(ciphertext) + + + +class AESModeOfOperationCTR(AESStreamModeOfOperation): + '''AES Counter Mode of Operation. + + o A stream-cipher, so input does not need to be padded to blocks, + allowing arbitrary length data. + o The counter must be the same size as the key size (ie. len(key)) + o Each block independant of the other, so a corrupt byte will not + damage future blocks. + o Each block has a uniue counter value associated with it, which + contributes to the encrypted value, so no data patterns are + leaked. + o Also known as: Counter Mode (CM), Integer Counter Mode (ICM) and + Segmented Integer Counter (SIC + + Security Notes: + o This method (and CBC) ARE recommended. + o Each message block is associated with a counter value which must be + unique for ALL messages with the same key. Otherwise security may be + compromised. + + Also see: + + o https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Counter_.28CTR.29 + o See NIST SP800-38A (http://csrc.nist.gov/publications/nistpubs/800-38a/sp800-38a.pdf); section 6.5 + and Appendix B for managing the initial counter''' + + + name = "Counter (CTR)" + + def __init__(self, key, counter = None): + AESBlockModeOfOperation.__init__(self, key) + + if counter is None: + counter = Counter() + + self._counter = counter + self._remaining_counter = [ ] + + def encrypt(self, plaintext): + while len(self._remaining_counter) < len(plaintext): + self._remaining_counter += self._aes.encrypt(self._counter.value) + self._counter.increment() + + plaintext = _string_to_bytes(plaintext) + + encrypted = [ (p ^ c) for (p, c) in zip(plaintext, self._remaining_counter) ] + self._remaining_counter = self._remaining_counter[len(encrypted):] + + return _bytes_to_string(encrypted) + + def decrypt(self, crypttext): + # AES-CTR is symetric + return self.encrypt(crypttext) + + +# Simple lookup table for each mode +AESModesOfOperation = dict( + ctr = AESModeOfOperationCTR, + cbc = AESModeOfOperationCBC, + cfb = AESModeOfOperationCFB, + ecb = AESModeOfOperationECB, + ofb = AESModeOfOperationOFB, +) diff --git a/module/scripts/lib/pyaes/blockfeeder.py b/module/scripts/lib/pyaes/blockfeeder.py new file mode 100644 index 0000000..b9a904d --- /dev/null +++ b/module/scripts/lib/pyaes/blockfeeder.py @@ -0,0 +1,227 @@ +# The MIT License (MIT) +# +# Copyright (c) 2014 Richard Moore +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + + +from .aes import AESBlockModeOfOperation, AESSegmentModeOfOperation, AESStreamModeOfOperation +from .util import append_PKCS7_padding, strip_PKCS7_padding, to_bufferable + + +# First we inject three functions to each of the modes of operations +# +# _can_consume(size) +# - Given a size, determine how many bytes could be consumed in +# a single call to either the decrypt or encrypt method +# +# _final_encrypt(data, padding = PADDING_DEFAULT) +# - call and return encrypt on this (last) chunk of data, +# padding as necessary; this will always be at least 16 +# bytes unless the total incoming input was less than 16 +# bytes +# +# _final_decrypt(data, padding = PADDING_DEFAULT) +# - same as _final_encrypt except for decrypt, for +# stripping off padding +# + +PADDING_NONE = 'none' +PADDING_DEFAULT = 'default' + +# @TODO: Ciphertext stealing and explicit PKCS#7 +# PADDING_CIPHERTEXT_STEALING +# PADDING_PKCS7 + +# ECB and CBC are block-only ciphers + +def _block_can_consume(self, size): + if size >= 16: return 16 + return 0 + +# After padding, we may have more than one block +def _block_final_encrypt(self, data, padding = PADDING_DEFAULT): + if padding == PADDING_DEFAULT: + data = append_PKCS7_padding(data) + + elif padding == PADDING_NONE: + if len(data) != 16: + raise Exception('invalid data length for final block') + else: + raise Exception('invalid padding option') + + if len(data) == 32: + return self.encrypt(data[:16]) + self.encrypt(data[16:]) + + return self.encrypt(data) + + +def _block_final_decrypt(self, data, padding = PADDING_DEFAULT): + if padding == PADDING_DEFAULT: + return strip_PKCS7_padding(self.decrypt(data)) + + if padding == PADDING_NONE: + if len(data) != 16: + raise Exception('invalid data length for final block') + return self.decrypt(data) + + raise Exception('invalid padding option') + +AESBlockModeOfOperation._can_consume = _block_can_consume +AESBlockModeOfOperation._final_encrypt = _block_final_encrypt +AESBlockModeOfOperation._final_decrypt = _block_final_decrypt + + + +# CFB is a segment cipher + +def _segment_can_consume(self, size): + return self.segment_bytes * int(size // self.segment_bytes) + +# CFB can handle a non-segment-sized block at the end using the remaining cipherblock +def _segment_final_encrypt(self, data, padding = PADDING_DEFAULT): + if padding != PADDING_DEFAULT: + raise Exception('invalid padding option') + + faux_padding = (chr(0) * (self.segment_bytes - (len(data) % self.segment_bytes))) + padded = data + to_bufferable(faux_padding) + return self.encrypt(padded)[:len(data)] + +# CFB can handle a non-segment-sized block at the end using the remaining cipherblock +def _segment_final_decrypt(self, data, padding = PADDING_DEFAULT): + if padding != PADDING_DEFAULT: + raise Exception('invalid padding option') + + faux_padding = (chr(0) * (self.segment_bytes - (len(data) % self.segment_bytes))) + padded = data + to_bufferable(faux_padding) + return self.decrypt(padded)[:len(data)] + +AESSegmentModeOfOperation._can_consume = _segment_can_consume +AESSegmentModeOfOperation._final_encrypt = _segment_final_encrypt +AESSegmentModeOfOperation._final_decrypt = _segment_final_decrypt + + + +# OFB and CTR are stream ciphers + +def _stream_can_consume(self, size): + return size + +def _stream_final_encrypt(self, data, padding = PADDING_DEFAULT): + if padding not in [PADDING_NONE, PADDING_DEFAULT]: + raise Exception('invalid padding option') + + return self.encrypt(data) + +def _stream_final_decrypt(self, data, padding = PADDING_DEFAULT): + if padding not in [PADDING_NONE, PADDING_DEFAULT]: + raise Exception('invalid padding option') + + return self.decrypt(data) + +AESStreamModeOfOperation._can_consume = _stream_can_consume +AESStreamModeOfOperation._final_encrypt = _stream_final_encrypt +AESStreamModeOfOperation._final_decrypt = _stream_final_decrypt + + + +class BlockFeeder(object): + '''The super-class for objects to handle chunking a stream of bytes + into the appropriate block size for the underlying mode of operation + and applying (or stripping) padding, as necessary.''' + + def __init__(self, mode, feed, final, padding = PADDING_DEFAULT): + self._mode = mode + self._feed = feed + self._final = final + self._buffer = to_bufferable("") + self._padding = padding + + def feed(self, data = None): + '''Provide bytes to encrypt (or decrypt), returning any bytes + possible from this or any previous calls to feed. + + Call with None or an empty string to flush the mode of + operation and return any final bytes; no further calls to + feed may be made.''' + + if self._buffer is None: + raise ValueError('already finished feeder') + + # Finalize; process the spare bytes we were keeping + if data is None: + result = self._final(self._buffer, self._padding) + self._buffer = None + return result + + self._buffer += to_bufferable(data) + + # We keep 16 bytes around so we can determine padding + result = to_bufferable('') + while len(self._buffer) > 16: + can_consume = self._mode._can_consume(len(self._buffer) - 16) + if can_consume == 0: break + result += self._feed(self._buffer[:can_consume]) + self._buffer = self._buffer[can_consume:] + + return result + + +class Encrypter(BlockFeeder): + 'Accepts bytes of plaintext and returns encrypted ciphertext.' + + def __init__(self, mode, padding = PADDING_DEFAULT): + BlockFeeder.__init__(self, mode, mode.encrypt, mode._final_encrypt, padding) + + +class Decrypter(BlockFeeder): + 'Accepts bytes of ciphertext and returns decrypted plaintext.' + + def __init__(self, mode, padding = PADDING_DEFAULT): + BlockFeeder.__init__(self, mode, mode.decrypt, mode._final_decrypt, padding) + + +# 8kb blocks +BLOCK_SIZE = (1 << 13) + +def _feed_stream(feeder, in_stream, out_stream, block_size = BLOCK_SIZE): + 'Uses feeder to read and convert from in_stream and write to out_stream.' + + while True: + chunk = in_stream.read(block_size) + if not chunk: + break + converted = feeder.feed(chunk) + out_stream.write(converted) + converted = feeder.feed() + out_stream.write(converted) + + +def encrypt_stream(mode, in_stream, out_stream, block_size = BLOCK_SIZE, padding = PADDING_DEFAULT): + 'Encrypts a stream of bytes from in_stream to out_stream using mode.' + + encrypter = Encrypter(mode, padding = padding) + _feed_stream(encrypter, in_stream, out_stream, block_size) + + +def decrypt_stream(mode, in_stream, out_stream, block_size = BLOCK_SIZE, padding = PADDING_DEFAULT): + 'Decrypts a stream of bytes from in_stream to out_stream using mode.' + + decrypter = Decrypter(mode, padding = padding) + _feed_stream(decrypter, in_stream, out_stream, block_size) diff --git a/module/scripts/lib/pyaes/util.py b/module/scripts/lib/pyaes/util.py new file mode 100644 index 0000000..081a375 --- /dev/null +++ b/module/scripts/lib/pyaes/util.py @@ -0,0 +1,60 @@ +# The MIT License (MIT) +# +# Copyright (c) 2014 Richard Moore +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +# Why to_bufferable? +# Python 3 is very different from Python 2.x when it comes to strings of text +# and strings of bytes; in Python 3, strings of bytes do not exist, instead to +# represent arbitrary binary data, we must use the "bytes" object. This method +# ensures the object behaves as we need it to. + +def to_bufferable(binary): + return binary + +def _get_byte(c): + return ord(c) + +try: + xrange +except: + + def to_bufferable(binary): + if isinstance(binary, bytes): + return binary + return bytes(ord(b) for b in binary) + + def _get_byte(c): + return c + +def append_PKCS7_padding(data): + pad = 16 - (len(data) % 16) + return data + to_bufferable(chr(pad) * pad) + +def strip_PKCS7_padding(data): + if len(data) % 16 != 0: + raise ValueError("invalid length") + + pad = _get_byte(data[-1]) + + if pad > 16: + raise ValueError("invalid padding byte") + + return data[:-pad] diff --git a/module/scripts/lib/pyasn1/__init__.py b/module/scripts/lib/pyasn1/__init__.py new file mode 100644 index 0000000..14c960c --- /dev/null +++ b/module/scripts/lib/pyasn1/__init__.py @@ -0,0 +1,2 @@ +# https://www.python.org/dev/peps/pep-0396/ +__version__ = '0.6.5' diff --git a/module/scripts/lib/pyasn1/codec/__init__.py b/module/scripts/lib/pyasn1/codec/__init__.py new file mode 100644 index 0000000..8c3066b --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/__init__.py @@ -0,0 +1 @@ +# This file is necessary to make this directory a package. diff --git a/module/scripts/lib/pyasn1/codec/ber/__init__.py b/module/scripts/lib/pyasn1/codec/ber/__init__.py new file mode 100644 index 0000000..8c3066b --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/ber/__init__.py @@ -0,0 +1 @@ +# This file is necessary to make this directory a package. diff --git a/module/scripts/lib/pyasn1/codec/ber/decoder.py b/module/scripts/lib/pyasn1/codec/ber/decoder.py new file mode 100644 index 0000000..1520b7f --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/ber/decoder.py @@ -0,0 +1,2262 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import io +import os +import sys +import warnings + +from pyasn1 import debug +from pyasn1 import error +from pyasn1.codec.ber import eoo +from pyasn1.codec.streaming import asSeekableStream +from pyasn1.codec.streaming import isEndOfStream +from pyasn1.codec.streaming import peekIntoStream +from pyasn1.codec.streaming import readFromStream +from pyasn1.compat import _MISSING +from pyasn1.error import PyAsn1Error +from pyasn1.type import base +from pyasn1.type import char +from pyasn1.type import tag +from pyasn1.type import tagmap +from pyasn1.type import univ +from pyasn1.type import useful + +__all__ = ['StreamingDecoder', 'Decoder', 'decode'] + +LOG = debug.registerLoggee(__name__, flags=debug.DEBUG_DECODER) + +noValue = base.noValue + +SubstrateUnderrunError = error.SubstrateUnderrunError + +# Maximum number of continuation octets (high-bit set) allowed per OID arc. +# 20 octets allows up to 140-bit integers, supporting UUID-based OIDs +MAX_OID_ARC_CONTINUATION_OCTETS = 20 + +# Maximum number of octets in a long-form tag ID (20 octets = up to +# 140-bit tag IDs, matching the OID arc limit) +MAX_TAG_OCTETS = 20 +MAX_NESTING_DEPTH = 100 + +# Maximum number of bytes in a BER length field (8 bytes = up to 2^64-1) +MAX_LENGTH_OCTETS = 8 + + +class AbstractPayloadDecoder(object): + protoComponent = None + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + """Decode value with fixed byte length. + + The decoder is allowed to consume as many bytes as necessary. + """ + raise error.PyAsn1Error('SingleItemDecoder not implemented for %s' % (tagSet,)) # TODO: Seems more like an NotImplementedError? + + def indefLenValueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + """Decode value with undefined length. + + The decoder is allowed to consume as many bytes as necessary. + """ + raise error.PyAsn1Error('Indefinite length mode decoder not implemented for %s' % (tagSet,)) # TODO: Seems more like an NotImplementedError? + + @staticmethod + def _passAsn1Object(asn1Object, options): + if 'asn1Object' not in options: + options['asn1Object'] = asn1Object + + return options + + +class AbstractSimplePayloadDecoder(AbstractPayloadDecoder): + @staticmethod + def substrateCollector(asn1Object, substrate, length, options): + for chunk in readFromStream(substrate, length, options): + yield chunk + + def _createComponent(self, asn1Spec, tagSet, value, **options): + if options.get('native'): + return value + elif asn1Spec is None: + return self.protoComponent.clone(value, tagSet=tagSet) + elif value is noValue: + return asn1Spec + else: + return asn1Spec.clone(value) + + +class RawPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.Any('') + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if substrateFun: + asn1Object = self._createComponent(asn1Spec, tagSet, '', **options) + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + for value in decodeFun(substrate, asn1Spec, tagSet, length, **options): + yield value + + def indefLenValueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if substrateFun: + asn1Object = self._createComponent(asn1Spec, tagSet, '', **options) + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + while True: + for value in decodeFun( + substrate, asn1Spec, tagSet, length, + allowEoo=True, **options): + + if value is eoo.endOfOctets: + return + + yield value + + +rawPayloadDecoder = RawPayloadDecoder() + + +class IntegerPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.Integer(0) + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + + if tagSet[0].tagFormat != tag.tagFormatSimple: + raise error.PyAsn1Error('Simple tag format expected') + + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + if chunk: + value = int.from_bytes(bytes(chunk), 'big', signed=True) + + else: + value = 0 + + yield self._createComponent(asn1Spec, tagSet, value, **options) + + +class BooleanPayloadDecoder(IntegerPayloadDecoder): + protoComponent = univ.Boolean(0) + + def _createComponent(self, asn1Spec, tagSet, value, **options): + return IntegerPayloadDecoder._createComponent( + self, asn1Spec, tagSet, value and 1 or 0, **options) + + +class BitStringPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.BitString(()) + supportConstructedForm = True + + @staticmethod + def _appendFragment(assembled, component, bitLength): + trailingBits = component[0] + if trailingBits > 7: + raise error.PyAsn1Error( + 'Trailing bits overflow %s' % trailingBits + ) + + payload = component[1:] + if not payload: + if trailingBits: + raise error.PyAsn1Error('Trailing bits overflow %s' % trailingBits) + return bitLength + + if not trailingBits and not bitLength % 8: + assembled.extend(payload) + return bitLength + len(payload) * 8 + + for idx, byte in enumerate(payload): + significantBits = 8 + if idx == len(payload) - 1: + significantBits -= trailingBits + byte >>= trailingBits + + usedBits = bitLength % 8 + if not usedBits: + assembled.append(byte << (8 - significantBits)) + elif usedBits + significantBits <= 8: + assembled[-1] |= byte << (8 - usedBits - significantBits) + else: + remainingBits = usedBits + significantBits - 8 + assembled[-1] |= byte >> remainingBits + assembled.append((byte << (8 - remainingBits)) & 0xff) + + bitLength += significantBits + + return bitLength + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + + if substrateFun: + asn1Object = self._createComponent(asn1Spec, tagSet, noValue, **options) + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + if not length: + raise error.PyAsn1Error('Empty BIT STRING substrate') + + for chunk in isEndOfStream(substrate): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + if chunk: + raise error.PyAsn1Error('Empty BIT STRING substrate') + + if tagSet[0].tagFormat == tag.tagFormatSimple: # XXX what tag to check? + + for trailingBits in readFromStream(substrate, 1, options): + if isinstance(trailingBits, SubstrateUnderrunError): + yield trailingBits + + trailingBits = ord(trailingBits) + if trailingBits > 7: + raise error.PyAsn1Error( + 'Trailing bits overflow %s' % trailingBits + ) + + for chunk in readFromStream(substrate, length - 1, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + value = self.protoComponent.fromOctetString( + chunk, internalFormat=True, padding=trailingBits) + + yield self._createComponent(asn1Spec, tagSet, value, **options) + + return + + if not self.supportConstructedForm: + raise error.PyAsn1Error('Constructed encoding form prohibited ' + 'at %s' % self.__class__.__name__) + + if LOG: + LOG('assembling constructed serialization') + + # All inner fragments are of the same type, treat them as octet string + substrateFun = self.substrateCollector + + assembled = bytearray() + bitLength = 0 + + current_position = substrate.tell() + + while substrate.tell() - current_position < length: + for component in decodeFun( + substrate, self.protoComponent, substrateFun=substrateFun, + **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + bitLength = self._appendFragment(assembled, component, bitLength) + + bitString = self.protoComponent.fromOctetString( + assembled, internalFormat=True, padding=(-bitLength) % 8 + ) + + yield self._createComponent(asn1Spec, tagSet, bitString, **options) + + def indefLenValueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + + if substrateFun: + asn1Object = self._createComponent(asn1Spec, tagSet, noValue, **options) + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + # All inner fragments are of the same type, treat them as octet string + substrateFun = self.substrateCollector + + assembled = bytearray() + bitLength = 0 + + while True: # loop over fragments + + for component in decodeFun( + substrate, self.protoComponent, substrateFun=substrateFun, + allowEoo=True, **options): + + if component is eoo.endOfOctets: + break + + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + bitLength = self._appendFragment(assembled, component, bitLength) + + bitString = self.protoComponent.fromOctetString( + assembled, internalFormat=True, padding=(-bitLength) % 8 + ) + + yield self._createComponent(asn1Spec, tagSet, bitString, **options) + + +class OctetStringPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.OctetString('') + supportConstructedForm = True + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if substrateFun: + asn1Object = self._createComponent(asn1Spec, tagSet, noValue, **options) + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + if tagSet[0].tagFormat == tag.tagFormatSimple: # XXX what tag to check? + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + yield self._createComponent(asn1Spec, tagSet, chunk, **options) + + return + + if not self.supportConstructedForm: + raise error.PyAsn1Error('Constructed encoding form prohibited at %s' % self.__class__.__name__) + + if LOG: + LOG('assembling constructed serialization') + + # Assemble the logical value of each fragment, including nested ones. + fragments = [] + + original_position = substrate.tell() + # head = popSubstream(substrate, length) + while substrate.tell() - original_position < length: + for component in decodeFun( + substrate, self.protoComponent, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + fragments.append(bytes(component)) + + yield self._createComponent(asn1Spec, tagSet, b''.join(fragments), **options) + + def indefLenValueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if substrateFun and substrateFun is not self.substrateCollector: + asn1Object = self._createComponent(asn1Spec, tagSet, noValue, **options) + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + # Assemble the logical value of each fragment, including nested ones. + fragments = [] + + while True: # loop over fragments + + for component in decodeFun( + substrate, self.protoComponent, allowEoo=True, **options): + + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + if component is eoo.endOfOctets: + break + + fragments.append(bytes(component)) + + yield self._createComponent(asn1Spec, tagSet, b''.join(fragments), **options) + + +class NullPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.Null('') + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + + if tagSet[0].tagFormat != tag.tagFormatSimple: + raise error.PyAsn1Error('Simple tag format expected') + + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + component = self._createComponent(asn1Spec, tagSet, '', **options) + + if chunk: + raise error.PyAsn1Error('Unexpected %d-octet substrate for Null' % length) + + yield component + + +class ObjectIdentifierPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.ObjectIdentifier(()) + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if tagSet[0].tagFormat != tag.tagFormatSimple: + raise error.PyAsn1Error('Simple tag format expected') + + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + if not chunk: + raise error.PyAsn1Error('Empty substrate') + + oid = [] + index = 0 + substrateLen = len(chunk) + while index < substrateLen: + subId = chunk[index] + index += 1 + if subId < 128: + oid.append(subId) + elif subId > 128: + # Construct subid from a number of octets + nextSubId = subId + subId = 0 + continuationOctetCount = 0 + while nextSubId >= 128: + continuationOctetCount += 1 + if continuationOctetCount > MAX_OID_ARC_CONTINUATION_OCTETS: + raise error.PyAsn1Error( + 'OID arc exceeds maximum continuation octets limit (%d) ' + 'at position %d' % (MAX_OID_ARC_CONTINUATION_OCTETS, index) + ) + subId = (subId << 7) + (nextSubId & 0x7F) + if index >= substrateLen: + raise error.SubstrateUnderrunError( + 'Short substrate for sub-OID past %s' % (tuple(oid),) + ) + nextSubId = chunk[index] + index += 1 + oid.append((subId << 7) + nextSubId) + elif subId == 128: + # ASN.1 spec forbids leading zeros (0x80) in OID + # encoding, tolerating it opens a vulnerability. See + # https://www.esat.kuleuven.be/cosic/publications/article-1432.pdf + # page 7 + raise error.PyAsn1Error('Invalid octet 0x80 in OID encoding') + + # Decode two leading arcs + if 0 <= oid[0] <= 39: + oid.insert(0, 0) + elif 40 <= oid[0] <= 79: + oid[0] -= 40 + oid.insert(0, 1) + elif oid[0] >= 80: + oid[0] -= 80 + oid.insert(0, 2) + else: + raise error.PyAsn1Error('Malformed first OID octet: %s' % chunk[0]) + + yield self._createComponent(asn1Spec, tagSet, tuple(oid), **options) + + +class RelativeOIDPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.RelativeOID(()) + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if tagSet[0].tagFormat != tag.tagFormatSimple: + raise error.PyAsn1Error('Simple tag format expected') + + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + if not chunk: + raise error.PyAsn1Error('Empty substrate') + + reloid = [] + index = 0 + substrateLen = len(chunk) + while index < substrateLen: + subId = chunk[index] + index += 1 + if subId < 128: + reloid.append(subId) + elif subId > 128: + # Construct subid from a number of octets + nextSubId = subId + subId = 0 + continuationOctetCount = 0 + while nextSubId >= 128: + continuationOctetCount += 1 + if continuationOctetCount > MAX_OID_ARC_CONTINUATION_OCTETS: + raise error.PyAsn1Error( + 'RELATIVE-OID arc exceeds maximum continuation octets limit (%d) ' + 'at position %d' % (MAX_OID_ARC_CONTINUATION_OCTETS, index) + ) + subId = (subId << 7) + (nextSubId & 0x7F) + if index >= substrateLen: + raise error.SubstrateUnderrunError( + 'Short substrate for sub-OID past %s' % (tuple(reloid),) + ) + nextSubId = chunk[index] + index += 1 + reloid.append((subId << 7) + nextSubId) + elif subId == 128: + # ASN.1 spec forbids leading zeros (0x80) in OID + # encoding, tolerating it opens a vulnerability. See + # https://www.esat.kuleuven.be/cosic/publications/article-1432.pdf + # page 7 + raise error.PyAsn1Error('Invalid octet 0x80 in RELATIVE-OID encoding') + + yield self._createComponent(asn1Spec, tagSet, tuple(reloid), **options) + + +class RealPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.Real() + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if tagSet[0].tagFormat != tag.tagFormatSimple: + raise error.PyAsn1Error('Simple tag format expected') + + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + if not chunk: + yield self._createComponent(asn1Spec, tagSet, 0.0, **options) + return + + fo = chunk[0] + chunk = chunk[1:] + if fo & 0x80: # binary encoding + if not chunk: + raise error.PyAsn1Error("Incomplete floating-point value") + + if LOG: + LOG('decoding binary encoded REAL') + + n = (fo & 0x03) + 1 + + if n == 4: + n = chunk[0] + chunk = chunk[1:] + + eo, chunk = chunk[:n], chunk[n:] + + if not eo or not chunk: + raise error.PyAsn1Error('Real exponent screwed') + + e = eo[0] & 0x80 and -1 or 0 + + while eo: # exponent + e <<= 8 + e |= eo[0] + eo = eo[1:] + + b = fo >> 4 & 0x03 # base bits + + if b > 2: + raise error.PyAsn1Error('Illegal Real base') + + if b == 1: # encbase = 8 + e *= 3 + + elif b == 2: # encbase = 16 + e *= 4 + p = int.from_bytes(bytes(chunk), 'big') + + if fo & 0x40: # sign bit + p = -p + + sf = fo >> 2 & 0x03 # scale bits + p *= 2 ** sf + value = (p, 2, e) + + elif fo & 0x40: # infinite value + if LOG: + LOG('decoding infinite REAL') + + value = fo & 0x01 and '-inf' or 'inf' + + elif fo & 0xc0 == 0: # character encoding + if not chunk: + raise error.PyAsn1Error("Incomplete floating-point value") + + if LOG: + LOG('decoding character encoded REAL') + + try: + if fo & 0x3 == 0x1: # NR1 + value = (int(chunk), 10, 0) + + elif fo & 0x3 == 0x2: # NR2 + value = float(chunk) + + elif fo & 0x3 == 0x3: # NR3 + value = float(chunk) + + else: + raise error.SubstrateUnderrunError( + 'Unknown NR (tag %s)' % fo + ) + + except ValueError: + raise error.SubstrateUnderrunError( + 'Bad character Real syntax' + ) + + else: + raise error.SubstrateUnderrunError( + 'Unknown encoding (tag %s)' % fo + ) + + yield self._createComponent(asn1Spec, tagSet, value, **options) + + +class AbstractConstructedPayloadDecoder(AbstractPayloadDecoder): + protoComponent = None + + +class ConstructedPayloadDecoderBase(AbstractConstructedPayloadDecoder): + protoRecordComponent = None + protoSequenceComponent = None + + def _getComponentTagMap(self, asn1Object, idx): + raise NotImplementedError + + def _getComponentPositionByType(self, asn1Object, tagSet, idx): + raise NotImplementedError + + def _decodeComponentsSchemaless( + self, substrate, tagSet=None, decodeFun=None, + length=None, **options): + + asn1Object = None + + components = [] + componentTypes = set() + + original_position = substrate.tell() + + while length == -1 or substrate.tell() < original_position + length: + for component in decodeFun(substrate, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + if length == -1 and component is eoo.endOfOctets: + break + + components.append(component) + # Two distinct types settle the choice; keep the set bounded + # to avoid quadratic comparisons for colliding tag hashes. + if len(componentTypes) < 2: + componentTypes.add(component.tagSet) + + # Now we have to guess is it SEQUENCE/SET or SEQUENCE OF/SET OF + # The heuristics is: + # * 1+ components of different types -> likely SEQUENCE/SET + # * otherwise -> likely SEQUENCE OF/SET OF + if len(componentTypes) > 1: + protoComponent = self.protoRecordComponent + + else: + protoComponent = self.protoSequenceComponent + + asn1Object = protoComponent.clone( + # construct tagSet from base tag from prototype ASN.1 object + # and additional tags recovered from the substrate + tagSet=tag.TagSet(protoComponent.tagSet.baseTag, *tagSet.superTags) + ) + + if LOG: + LOG('guessed %r container type (pass `asn1Spec` to guide the ' + 'decoder)' % asn1Object) + + for idx, component in enumerate(components): + asn1Object.setComponentByPosition( + idx, component, + verifyConstraints=False, + matchTags=False, matchConstraints=False + ) + + yield asn1Object + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if tagSet[0].tagFormat != tag.tagFormatConstructed: + raise error.PyAsn1Error('Constructed tag format expected') + + original_position = substrate.tell() + + if substrateFun: + if asn1Spec is not None: + asn1Object = asn1Spec.clone() + + elif self.protoComponent is not None: + asn1Object = self.protoComponent.clone(tagSet=tagSet) + + else: + asn1Object = self.protoRecordComponent, self.protoSequenceComponent + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + if asn1Spec is None: + for asn1Object in self._decodeComponentsSchemaless( + substrate, tagSet=tagSet, decodeFun=decodeFun, + length=length, **options): + if isinstance(asn1Object, SubstrateUnderrunError): + yield asn1Object + + if substrate.tell() < original_position + length: + if LOG: + for trailing in readFromStream(substrate, context=options): + if isinstance(trailing, SubstrateUnderrunError): + yield trailing + + LOG('Unused trailing %d octets encountered: %s' % ( + len(trailing), debug.hexdump(trailing))) + + yield asn1Object + + return + + asn1Object = asn1Spec.clone() + asn1Object.clear() + + options = self._passAsn1Object(asn1Object, options) + + if asn1Spec.typeId in (univ.Sequence.typeId, univ.Set.typeId): + + namedTypes = asn1Spec.componentType + + isSetType = asn1Spec.typeId == univ.Set.typeId + isDeterministic = not isSetType and not namedTypes.hasOptionalOrDefault + + if LOG: + LOG('decoding %sdeterministic %s type %r chosen by type ID' % ( + not isDeterministic and 'non-' or '', isSetType and 'SET' or '', + asn1Spec)) + + seenIndices = set() + idx = 0 + while substrate.tell() - original_position < length: + if not namedTypes: + componentType = None + + elif isSetType: + componentType = namedTypes.tagMapUnique + + else: + try: + if isDeterministic: + componentType = namedTypes[idx].asn1Object + + elif namedTypes[idx].isOptional or namedTypes[idx].isDefaulted: + componentType = namedTypes.getTagMapNearPosition(idx) + + else: + componentType = namedTypes[idx].asn1Object + + except IndexError: + raise error.PyAsn1Error( + 'Excessive components decoded at %r' % (asn1Spec,) + ) + + for component in decodeFun(substrate, componentType, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + if not isDeterministic and namedTypes: + if isSetType: + idx = namedTypes.getPositionByType(component.effectiveTagSet) + + elif namedTypes[idx].isOptional or namedTypes[idx].isDefaulted: + idx = namedTypes.getPositionNearType(component.effectiveTagSet, idx) + + asn1Object.setComponentByPosition( + idx, component, + verifyConstraints=False, + matchTags=False, matchConstraints=False + ) + + seenIndices.add(idx) + idx += 1 + + if LOG: + LOG('seen component indices %s' % seenIndices) + + if namedTypes: + if not namedTypes.requiredComponents.issubset(seenIndices): + raise error.PyAsn1Error( + 'ASN.1 object %s has uninitialized ' + 'components' % asn1Object.__class__.__name__) + + if namedTypes.hasOpenTypes: + + openTypes = options.get('openTypes', {}) + + if LOG: + LOG('user-specified open types map:') + + for k, v in openTypes.items(): + LOG('%s -> %r' % (k, v)) + + if openTypes or options.get('decodeOpenTypes', False): + + for idx, namedType in enumerate(namedTypes.namedTypes): + if not namedType.openType: + continue + + if namedType.isOptional and not asn1Object.getComponentByPosition(idx).isValue: + continue + + governingValue = asn1Object.getComponentByName( + namedType.openType.name + ) + + try: + openType = openTypes[governingValue] + + except KeyError: + + if LOG: + LOG('default open types map of component ' + '"%s.%s" governed by component "%s.%s"' + ':' % (asn1Object.__class__.__name__, + namedType.name, + asn1Object.__class__.__name__, + namedType.openType.name)) + + for k, v in namedType.openType.items(): + LOG('%s -> %r' % (k, v)) + + try: + openType = namedType.openType[governingValue] + + except KeyError: + if LOG: + LOG('failed to resolve open type by governing ' + 'value %r' % (governingValue,)) + continue + + if LOG: + LOG('resolved open type %r by governing ' + 'value %r' % (openType, governingValue)) + + containerValue = asn1Object.getComponentByPosition(idx) + + if containerValue.typeId in ( + univ.SetOf.typeId, univ.SequenceOf.typeId): + + for pos, containerElement in enumerate( + containerValue): + + stream = asSeekableStream(containerValue[pos].asOctets()) + + for component in decodeFun(stream, asn1Spec=openType, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + containerValue[pos] = component + + else: + stream = asSeekableStream(asn1Object.getComponentByPosition(idx).asOctets()) + + for component in decodeFun(stream, asn1Spec=openType, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + asn1Object.setComponentByPosition(idx, component) + + else: + inconsistency = asn1Object.isInconsistent + if inconsistency: + raise error.PyAsn1Error( + f"ASN.1 object {asn1Object.__class__.__name__} is inconsistent") + + else: + componentType = asn1Spec.componentType + + if LOG: + LOG('decoding type %r chosen by given `asn1Spec`' % componentType) + + idx = 0 + + while substrate.tell() - original_position < length: + for component in decodeFun(substrate, componentType, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + asn1Object.setComponentByPosition( + idx, component, + verifyConstraints=False, + matchTags=False, matchConstraints=False + ) + + idx += 1 + + yield asn1Object + + def indefLenValueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if tagSet[0].tagFormat != tag.tagFormatConstructed: + raise error.PyAsn1Error('Constructed tag format expected') + + if substrateFun is not None: + if asn1Spec is not None: + asn1Object = asn1Spec.clone() + + elif self.protoComponent is not None: + asn1Object = self.protoComponent.clone(tagSet=tagSet) + + else: + asn1Object = self.protoRecordComponent, self.protoSequenceComponent + + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + if asn1Spec is None: + for asn1Object in self._decodeComponentsSchemaless( + substrate, tagSet=tagSet, decodeFun=decodeFun, + length=length, **dict(options, allowEoo=True)): + if isinstance(asn1Object, SubstrateUnderrunError): + yield asn1Object + + yield asn1Object + + return + + asn1Object = asn1Spec.clone() + asn1Object.clear() + + options = self._passAsn1Object(asn1Object, options) + + if asn1Spec.typeId in (univ.Sequence.typeId, univ.Set.typeId): + + namedTypes = asn1Object.componentType + + isSetType = asn1Object.typeId == univ.Set.typeId + isDeterministic = not isSetType and not namedTypes.hasOptionalOrDefault + + if LOG: + LOG('decoding %sdeterministic %s type %r chosen by type ID' % ( + not isDeterministic and 'non-' or '', isSetType and 'SET' or '', + asn1Spec)) + + seenIndices = set() + + idx = 0 + + while True: # loop over components + if len(namedTypes) <= idx: + asn1Spec = None + + elif isSetType: + asn1Spec = namedTypes.tagMapUnique + + else: + try: + if isDeterministic: + asn1Spec = namedTypes[idx].asn1Object + + elif namedTypes[idx].isOptional or namedTypes[idx].isDefaulted: + asn1Spec = namedTypes.getTagMapNearPosition(idx) + + else: + asn1Spec = namedTypes[idx].asn1Object + + except IndexError: + raise error.PyAsn1Error( + 'Excessive components decoded at %r' % (asn1Object,) + ) + + for component in decodeFun(substrate, asn1Spec, allowEoo=True, **options): + + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + if component is eoo.endOfOctets: + break + + if not isDeterministic and namedTypes: + if isSetType: + idx = namedTypes.getPositionByType(component.effectiveTagSet) + + elif namedTypes[idx].isOptional or namedTypes[idx].isDefaulted: + idx = namedTypes.getPositionNearType(component.effectiveTagSet, idx) + + asn1Object.setComponentByPosition( + idx, component, + verifyConstraints=False, + matchTags=False, matchConstraints=False + ) + + seenIndices.add(idx) + idx += 1 + + if LOG: + LOG('seen component indices %s' % seenIndices) + + if namedTypes: + if not namedTypes.requiredComponents.issubset(seenIndices): + raise error.PyAsn1Error( + 'ASN.1 object %s has uninitialized ' + 'components' % asn1Object.__class__.__name__) + + if namedTypes.hasOpenTypes: + + openTypes = options.get('openTypes', {}) + + if LOG: + LOG('user-specified open types map:') + + for k, v in openTypes.items(): + LOG('%s -> %r' % (k, v)) + + if openTypes or options.get('decodeOpenTypes', False): + + for idx, namedType in enumerate(namedTypes.namedTypes): + if not namedType.openType: + continue + + if namedType.isOptional and not asn1Object.getComponentByPosition(idx).isValue: + continue + + governingValue = asn1Object.getComponentByName( + namedType.openType.name + ) + + try: + openType = openTypes[governingValue] + + except KeyError: + + if LOG: + LOG('default open types map of component ' + '"%s.%s" governed by component "%s.%s"' + ':' % (asn1Object.__class__.__name__, + namedType.name, + asn1Object.__class__.__name__, + namedType.openType.name)) + + for k, v in namedType.openType.items(): + LOG('%s -> %r' % (k, v)) + + try: + openType = namedType.openType[governingValue] + + except KeyError: + if LOG: + LOG('failed to resolve open type by governing ' + 'value %r' % (governingValue,)) + continue + + if LOG: + LOG('resolved open type %r by governing ' + 'value %r' % (openType, governingValue)) + + containerValue = asn1Object.getComponentByPosition(idx) + + if containerValue.typeId in ( + univ.SetOf.typeId, univ.SequenceOf.typeId): + + for pos, containerElement in enumerate( + containerValue): + + stream = asSeekableStream(containerValue[pos].asOctets()) + + for component in decodeFun(stream, asn1Spec=openType, + **dict(options, allowEoo=True)): + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + containerValue[pos] = component + + else: + stream = asSeekableStream(asn1Object.getComponentByPosition(idx).asOctets()) + for component in decodeFun(stream, asn1Spec=openType, + **dict(options, allowEoo=True)): + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + asn1Object.setComponentByPosition(idx, component) + + else: + inconsistency = asn1Object.isInconsistent + if inconsistency: + raise error.PyAsn1Error( + f"ASN.1 object {asn1Object.__class__.__name__} is inconsistent") + + else: + componentType = asn1Spec.componentType + + if LOG: + LOG('decoding type %r chosen by given `asn1Spec`' % componentType) + + idx = 0 + + while True: + + for component in decodeFun( + substrate, componentType, allowEoo=True, **options): + + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + if component is eoo.endOfOctets: + break + + asn1Object.setComponentByPosition( + idx, component, + verifyConstraints=False, + matchTags=False, matchConstraints=False + ) + + idx += 1 + + yield asn1Object + + +class SequenceOrSequenceOfPayloadDecoder(ConstructedPayloadDecoderBase): + protoRecordComponent = univ.Sequence() + protoSequenceComponent = univ.SequenceOf() + + +class SequencePayloadDecoder(SequenceOrSequenceOfPayloadDecoder): + protoComponent = univ.Sequence() + + +class SequenceOfPayloadDecoder(SequenceOrSequenceOfPayloadDecoder): + protoComponent = univ.SequenceOf() + + +class SetOrSetOfPayloadDecoder(ConstructedPayloadDecoderBase): + protoRecordComponent = univ.Set() + protoSequenceComponent = univ.SetOf() + + +class SetPayloadDecoder(SetOrSetOfPayloadDecoder): + protoComponent = univ.Set() + + +class SetOfPayloadDecoder(SetOrSetOfPayloadDecoder): + protoComponent = univ.SetOf() + + +class ChoicePayloadDecoder(ConstructedPayloadDecoderBase): + protoComponent = univ.Choice() + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if asn1Spec is None: + asn1Object = self.protoComponent.clone(tagSet=tagSet) + + else: + asn1Object = asn1Spec.clone() + + if substrateFun: + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + options = self._passAsn1Object(asn1Object, options) + + if asn1Object.tagSet == tagSet: + if LOG: + LOG('decoding %s as explicitly tagged CHOICE' % (tagSet,)) + + for component in decodeFun( + substrate, asn1Object.componentTagMap, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + else: + if LOG: + LOG('decoding %s as untagged CHOICE' % (tagSet,)) + + for component in decodeFun( + substrate, asn1Object.componentTagMap, tagSet, length, + state, **options): + if isinstance(component, SubstrateUnderrunError): + yield component + + effectiveTagSet = component.effectiveTagSet + + if LOG: + LOG('decoded component %s, effective tag set %s' % (component, effectiveTagSet)) + + asn1Object.setComponentByType( + effectiveTagSet, component, + verifyConstraints=False, + matchTags=False, matchConstraints=False, + innerFlag=False + ) + + yield asn1Object + + def indefLenValueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if asn1Spec is None: + asn1Object = self.protoComponent.clone(tagSet=tagSet) + + else: + asn1Object = asn1Spec.clone() + + if substrateFun: + for chunk in substrateFun(asn1Object, substrate, length, options): + yield chunk + + return + + options = self._passAsn1Object(asn1Object, options) + + isTagged = asn1Object.tagSet == tagSet + + if LOG: + LOG('decoding %s as %stagged CHOICE' % ( + tagSet, isTagged and 'explicitly ' or 'un')) + + while True: + + if isTagged: + iterator = decodeFun( + substrate, asn1Object.componentType.tagMapUnique, + **dict(options, allowEoo=True)) + + else: + iterator = decodeFun( + substrate, asn1Object.componentType.tagMapUnique, + tagSet, length, state, **dict(options, allowEoo=True)) + + for component in iterator: + + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + effectiveTagSet = component.effectiveTagSet + + if LOG: + LOG('decoded component %s, effective tag set ' + '%s' % (component, effectiveTagSet)) + + asn1Object.setComponentByType( + effectiveTagSet, component, + verifyConstraints=False, + matchTags=False, matchConstraints=False, + innerFlag=False + ) + + if not isTagged: + break + + if not isTagged or component is eoo.endOfOctets: + break + + yield asn1Object + + +class AnyPayloadDecoder(AbstractSimplePayloadDecoder): + protoComponent = univ.Any() + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if asn1Spec is None: + isUntagged = True + + elif asn1Spec.__class__ is tagmap.TagMap: + isUntagged = tagSet not in asn1Spec.tagMap + + else: + isUntagged = tagSet != asn1Spec.tagSet + + if isUntagged: + fullPosition = substrate.markedPosition + currentPosition = substrate.tell() + + substrate.seek(fullPosition, os.SEEK_SET) + length += currentPosition - fullPosition + + if LOG: + for chunk in peekIntoStream(substrate, length): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + LOG('decoding as untagged ANY, substrate ' + '%s' % debug.hexdump(chunk)) + + if substrateFun: + for chunk in substrateFun( + self._createComponent(asn1Spec, tagSet, noValue, **options), + substrate, length, options): + yield chunk + + return + + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + yield self._createComponent(asn1Spec, tagSet, chunk, **options) + + def indefLenValueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + if asn1Spec is None: + isTagged = False + + elif asn1Spec.__class__ is tagmap.TagMap: + isTagged = tagSet in asn1Spec.tagMap + + else: + isTagged = tagSet == asn1Spec.tagSet + + if isTagged: + # tagged Any type -- consume header substrate + chunk = b'' + + if LOG: + LOG('decoding as tagged ANY') + + else: + # TODO: Seems not to be tested + fullPosition = substrate.markedPosition + currentPosition = substrate.tell() + + substrate.seek(fullPosition, os.SEEK_SET) + for chunk in readFromStream(substrate, currentPosition - fullPosition, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + if LOG: + LOG('decoding as untagged ANY, header substrate %s' % debug.hexdump(chunk)) + + # Any components do not inherit initial tag + asn1Spec = self.protoComponent + + if substrateFun and substrateFun is not self.substrateCollector: + asn1Object = self._createComponent( + asn1Spec, tagSet, noValue, **options) + + for chunk in substrateFun( + asn1Object, chunk + substrate, length + len(chunk), options): + yield chunk + + return + + if LOG: + LOG('assembling constructed serialization') + + # All inner fragments are of the same type, treat them as octet string + substrateFun = self.substrateCollector + + fragments = [chunk] + + while True: # loop over fragments + + for component in decodeFun( + substrate, asn1Spec, substrateFun=substrateFun, + allowEoo=True, **options): + + if isinstance(component, SubstrateUnderrunError): + yield component + + if component is eoo.endOfOctets: + break + + if component is eoo.endOfOctets: + break + + fragments.append(component) + + chunk = b''.join(fragments) + + if substrateFun: + yield chunk # TODO: Weird + + else: + yield self._createComponent(asn1Spec, tagSet, chunk, **options) + + +# character string types +class UTF8StringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.UTF8String() + + +class NumericStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.NumericString() + + +class PrintableStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.PrintableString() + + +class TeletexStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.TeletexString() + + +class VideotexStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.VideotexString() + + +class IA5StringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.IA5String() + + +class GraphicStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.GraphicString() + + +class VisibleStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.VisibleString() + + +class GeneralStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.GeneralString() + + +class UniversalStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.UniversalString() + + +class BMPStringPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = char.BMPString() + + +# "useful" types +class ObjectDescriptorPayloadDecoder(OctetStringPayloadDecoder): + protoComponent = useful.ObjectDescriptor() + + +class GeneralizedTimePayloadDecoder(OctetStringPayloadDecoder): + protoComponent = useful.GeneralizedTime() + + +class UTCTimePayloadDecoder(OctetStringPayloadDecoder): + protoComponent = useful.UTCTime() + + +TAG_MAP = { + univ.Integer.tagSet: IntegerPayloadDecoder(), + univ.Boolean.tagSet: BooleanPayloadDecoder(), + univ.BitString.tagSet: BitStringPayloadDecoder(), + univ.OctetString.tagSet: OctetStringPayloadDecoder(), + univ.Null.tagSet: NullPayloadDecoder(), + univ.ObjectIdentifier.tagSet: ObjectIdentifierPayloadDecoder(), + univ.RelativeOID.tagSet: RelativeOIDPayloadDecoder(), + univ.Enumerated.tagSet: IntegerPayloadDecoder(), + univ.Real.tagSet: RealPayloadDecoder(), + univ.Sequence.tagSet: SequenceOrSequenceOfPayloadDecoder(), # conflicts with SequenceOf + univ.Set.tagSet: SetOrSetOfPayloadDecoder(), # conflicts with SetOf + univ.Choice.tagSet: ChoicePayloadDecoder(), # conflicts with Any + # character string types + char.UTF8String.tagSet: UTF8StringPayloadDecoder(), + char.NumericString.tagSet: NumericStringPayloadDecoder(), + char.PrintableString.tagSet: PrintableStringPayloadDecoder(), + char.TeletexString.tagSet: TeletexStringPayloadDecoder(), + char.VideotexString.tagSet: VideotexStringPayloadDecoder(), + char.IA5String.tagSet: IA5StringPayloadDecoder(), + char.GraphicString.tagSet: GraphicStringPayloadDecoder(), + char.VisibleString.tagSet: VisibleStringPayloadDecoder(), + char.GeneralString.tagSet: GeneralStringPayloadDecoder(), + char.UniversalString.tagSet: UniversalStringPayloadDecoder(), + char.BMPString.tagSet: BMPStringPayloadDecoder(), + # useful types + useful.ObjectDescriptor.tagSet: ObjectDescriptorPayloadDecoder(), + useful.GeneralizedTime.tagSet: GeneralizedTimePayloadDecoder(), + useful.UTCTime.tagSet: UTCTimePayloadDecoder() +} + +# Type-to-codec map for ambiguous ASN.1 types +TYPE_MAP = { + univ.Set.typeId: SetPayloadDecoder(), + univ.SetOf.typeId: SetOfPayloadDecoder(), + univ.Sequence.typeId: SequencePayloadDecoder(), + univ.SequenceOf.typeId: SequenceOfPayloadDecoder(), + univ.Choice.typeId: ChoicePayloadDecoder(), + univ.Any.typeId: AnyPayloadDecoder() +} + +# Put in non-ambiguous types for faster codec lookup +for typeDecoder in TAG_MAP.values(): + if typeDecoder.protoComponent is not None: + typeId = typeDecoder.protoComponent.__class__.typeId + if typeId is not None and typeId not in TYPE_MAP: + TYPE_MAP[typeId] = typeDecoder + + +(stDecodeTag, + stDecodeLength, + stGetValueDecoder, + stGetValueDecoderByAsn1Spec, + stGetValueDecoderByTag, + stTryAsExplicitTag, + stDecodeValue, + stDumpRawValue, + stErrorCondition, + stStop) = [x for x in range(10)] + + +EOO_SENTINEL = bytes((0, 0)) + + +class SingleItemDecoder(object): + defaultErrorState = stErrorCondition + #defaultErrorState = stDumpRawValue + defaultRawDecoder = AnyPayloadDecoder() + + supportIndefLength = True + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + def __init__(self, tagMap=_MISSING, typeMap=_MISSING, **ignored): + self._tagMap = tagMap if tagMap is not _MISSING else self.TAG_MAP + self._typeMap = typeMap if typeMap is not _MISSING else self.TYPE_MAP + + # Tag & TagSet objects caches + self._tagCache = {} + self._tagSetCache = {} + + def __call__(self, substrate, asn1Spec=None, + tagSet=None, length=None, state=stDecodeTag, + decodeFun=None, substrateFun=None, + **options): + + _nestingLevel = options.get('_nestingLevel', 0) + + if _nestingLevel > MAX_NESTING_DEPTH: + raise error.PyAsn1Error( + 'ASN.1 structure nesting depth exceeds limit (%d)' % MAX_NESTING_DEPTH + ) + + options['_nestingLevel'] = _nestingLevel + 1 + + allowEoo = options.pop('allowEoo', False) + + if LOG: + LOG('decoder called at scope %s with state %d, working with up ' + 'to %s octets of substrate: ' + '%s' % (debug.scope, state, length, substrate)) + + # Look for end-of-octets sentinel + if allowEoo and self.supportIndefLength: + + for eoo_candidate in readFromStream(substrate, 2, options): + if isinstance(eoo_candidate, SubstrateUnderrunError): + yield eoo_candidate + + if eoo_candidate == EOO_SENTINEL: + if LOG: + LOG('end-of-octets sentinel found') + yield eoo.endOfOctets + return + + else: + substrate.seek(-2, os.SEEK_CUR) + + tagMap = self._tagMap + typeMap = self._typeMap + tagCache = self._tagCache + tagSetCache = self._tagSetCache + + value = noValue + + substrate.markedPosition = substrate.tell() + + while state is not stStop: + + if state is stDecodeTag: + # Decode tag + isShortTag = True + + for firstByte in readFromStream(substrate, 1, options): + if isinstance(firstByte, SubstrateUnderrunError): + yield firstByte + + firstOctet = ord(firstByte) + + try: + lastTag = tagCache[firstOctet] + + except KeyError: + integerTag = firstOctet + tagClass = integerTag & 0xC0 + tagFormat = integerTag & 0x20 + tagId = integerTag & 0x1F + + if tagId == 0x1F: + isShortTag = False + tagOctetCount = 0 + tagId = 0 + + while True: + for integerByte in readFromStream(substrate, 1, options): + if isinstance(integerByte, SubstrateUnderrunError): + yield integerByte + + if not integerByte: + raise error.SubstrateUnderrunError( + 'Short octet stream on long tag decoding' + ) + + integerTag = ord(integerByte) + tagOctetCount += 1 + if tagOctetCount > MAX_TAG_OCTETS: + raise error.PyAsn1Error( + 'Tag ID octet count exceeds limit (%d)' % ( + MAX_TAG_OCTETS,) + ) + tagId <<= 7 + tagId |= (integerTag & 0x7F) + + if not integerTag & 0x80: + break + + lastTag = tag.Tag( + tagClass=tagClass, tagFormat=tagFormat, tagId=tagId + ) + + if isShortTag: + # cache short tags + tagCache[firstOctet] = lastTag + + if tagSet is None: + if isShortTag: + try: + tagSet = tagSetCache[firstOctet] + + except KeyError: + # base tag not recovered + tagSet = tag.TagSet((), lastTag) + tagSetCache[firstOctet] = tagSet + else: + tagSet = tag.TagSet((), lastTag) + + else: + tagSet = lastTag + tagSet + + state = stDecodeLength + + if LOG: + LOG('tag decoded into %s, decoding length' % tagSet) + + if state is stDecodeLength: + # Decode length + for firstOctet in readFromStream(substrate, 1, options): + if isinstance(firstOctet, SubstrateUnderrunError): + yield firstOctet + + firstOctet = ord(firstOctet) + + if firstOctet < 128: + length = firstOctet + + elif firstOctet > 128: + size = firstOctet & 0x7F + + if size > MAX_LENGTH_OCTETS: + raise error.PyAsn1Error( + 'BER length field size %d exceeds limit (%d)' % ( + size, MAX_LENGTH_OCTETS) + ) + + # encoded in size bytes + for encodedLength in readFromStream(substrate, size, options): + if isinstance(encodedLength, SubstrateUnderrunError): + yield encodedLength + encodedLength = list(encodedLength) + if len(encodedLength) != size: + raise error.SubstrateUnderrunError( + '%s<%s at %s' % (size, len(encodedLength), tagSet) + ) + + length = 0 + for lengthOctet in encodedLength: + length <<= 8 + length |= lengthOctet + size += 1 + + else: # 128 means indefinite + length = -1 + + if length == -1 and not self.supportIndefLength: + raise error.PyAsn1Error('Indefinite length encoding not supported by this codec') + + state = stGetValueDecoder + + if LOG: + LOG('value length decoded into %d' % length) + + if state is stGetValueDecoder: + if asn1Spec is None: + state = stGetValueDecoderByTag + + else: + state = stGetValueDecoderByAsn1Spec + # + # There're two ways of creating subtypes in ASN.1 what influences + # decoder operation. These methods are: + # 1) Either base types used in or no IMPLICIT tagging has been + # applied on subtyping. + # 2) Subtype syntax drops base type information (by means of + # IMPLICIT tagging. + # The first case allows for complete tag recovery from substrate + # while the second one requires original ASN.1 type spec for + # decoding. + # + # In either case a set of tags (tagSet) is coming from substrate + # in an incremental, tag-by-tag fashion (this is the case of + # EXPLICIT tag which is most basic). Outermost tag comes first + # from the wire. + # + if state is stGetValueDecoderByTag: + try: + concreteDecoder = tagMap[tagSet] + + except KeyError: + concreteDecoder = None + + if concreteDecoder: + state = stDecodeValue + + else: + try: + concreteDecoder = tagMap[tagSet[:1]] + + except KeyError: + concreteDecoder = None + + if concreteDecoder: + state = stDecodeValue + else: + state = stTryAsExplicitTag + + if LOG: + LOG('codec %s chosen by a built-in type, decoding %s' % (concreteDecoder and concreteDecoder.__class__.__name__ or "", state is stDecodeValue and 'value' or 'as explicit tag')) + debug.scope.push(concreteDecoder is None and '?' or concreteDecoder.protoComponent.__class__.__name__) + + if state is stGetValueDecoderByAsn1Spec: + + if asn1Spec.__class__ is tagmap.TagMap: + try: + chosenSpec = asn1Spec[tagSet] + + except KeyError: + chosenSpec = None + + if LOG: + LOG('candidate ASN.1 spec is a map of:') + + for firstOctet, v in asn1Spec.presentTypes.items(): + LOG(' %s -> %s' % (firstOctet, v.__class__.__name__)) + + if asn1Spec.skipTypes: + LOG('but neither of: ') + for firstOctet, v in asn1Spec.skipTypes.items(): + LOG(' %s -> %s' % (firstOctet, v.__class__.__name__)) + LOG('new candidate ASN.1 spec is %s, chosen by %s' % (chosenSpec is None and '' or chosenSpec.prettyPrintType(), tagSet)) + + elif tagSet == asn1Spec.tagSet or tagSet in asn1Spec.tagMap: + chosenSpec = asn1Spec + if LOG: + LOG('candidate ASN.1 spec is %s' % asn1Spec.__class__.__name__) + + else: + chosenSpec = None + + if chosenSpec is not None: + try: + # ambiguous type or just faster codec lookup + concreteDecoder = typeMap[chosenSpec.typeId] + + if LOG: + LOG('value decoder chosen for an ambiguous type by type ID %s' % (chosenSpec.typeId,)) + + except KeyError: + # use base type for codec lookup to recover untagged types + baseTagSet = tag.TagSet(chosenSpec.tagSet.baseTag, chosenSpec.tagSet.baseTag) + try: + # base type or tagged subtype + concreteDecoder = tagMap[baseTagSet] + + if LOG: + LOG('value decoder chosen by base %s' % (baseTagSet,)) + + except KeyError: + concreteDecoder = None + + if concreteDecoder: + asn1Spec = chosenSpec + state = stDecodeValue + + else: + state = stTryAsExplicitTag + + else: + concreteDecoder = None + state = stTryAsExplicitTag + + if LOG: + LOG('codec %s chosen by ASN.1 spec, decoding %s' % (state is stDecodeValue and concreteDecoder.__class__.__name__ or "", state is stDecodeValue and 'value' or 'as explicit tag')) + debug.scope.push(chosenSpec is None and '?' or chosenSpec.__class__.__name__) + + if state is stDecodeValue: + if not options.get('recursiveFlag', True) and not substrateFun: # deprecate this + def substrateFun(asn1Object, _substrate, _length, _options): + """Legacy hack to keep the recursiveFlag=False option supported. + + The decode(..., substrateFun=userCallback) option was introduced in 0.1.4 as a generalization + of the old recursiveFlag=False option. Users should pass their callback instead of using + recursiveFlag. + """ + yield asn1Object + + original_position = substrate.tell() + + if length == -1: # indef length + for value in concreteDecoder.indefLenValueDecoder( + substrate, asn1Spec, + tagSet, length, stGetValueDecoder, + self, substrateFun, **options): + if isinstance(value, SubstrateUnderrunError): + yield value + + else: + for value in concreteDecoder.valueDecoder( + substrate, asn1Spec, + tagSet, length, stGetValueDecoder, + self, substrateFun, **options): + if isinstance(value, SubstrateUnderrunError): + yield value + + bytesRead = substrate.tell() - original_position + if not substrateFun and bytesRead != length: + raise PyAsn1Error( + "Read %s bytes instead of expected %s." % (bytesRead, length)) + elif substrateFun and bytesRead > length: + # custom substrateFun may be used for partial decoding, reading less is expected there + raise PyAsn1Error( + "Read %s bytes are more than expected %s." % (bytesRead, length)) + + if LOG: + LOG('codec %s yields type %s, value:\n%s\n...' % ( + concreteDecoder.__class__.__name__, value.__class__.__name__, + isinstance(value, base.Asn1Item) and value.prettyPrint() or value)) + + state = stStop + break + + if state is stTryAsExplicitTag: + if (tagSet and + tagSet[0].tagFormat == tag.tagFormatConstructed and + tagSet[0].tagClass != tag.tagClassUniversal): + # Assume explicit tagging + concreteDecoder = rawPayloadDecoder + state = stDecodeValue + + else: + concreteDecoder = None + state = self.defaultErrorState + + if LOG: + LOG('codec %s chosen, decoding %s' % (concreteDecoder and concreteDecoder.__class__.__name__ or "", state is stDecodeValue and 'value' or 'as failure')) + + if state is stDumpRawValue: + concreteDecoder = self.defaultRawDecoder + + if LOG: + LOG('codec %s chosen, decoding value' % concreteDecoder.__class__.__name__) + + state = stDecodeValue + + if state is stErrorCondition: + raise error.PyAsn1Error( + '%s not in asn1Spec: %r' % (tagSet, asn1Spec) + ) + + if LOG: + debug.scope.pop() + LOG('decoder left scope %s, call completed' % debug.scope) + + yield value + + +class StreamingDecoder(object): + """Create an iterator that turns BER/CER/DER byte stream into ASN.1 objects. + + On each iteration, consume whatever BER/CER/DER serialization is + available in the `substrate` stream-like object and turns it into + one or more, possibly nested, ASN.1 objects. + + Parameters + ---------- + substrate: :py:class:`file`, :py:class:`io.BytesIO` + BER/CER/DER serialization in form of a byte stream + + Keyword Args + ------------ + asn1Spec: :py:class:`~pyasn1.type.base.PyAsn1Item` + A pyasn1 type object to act as a template guiding the decoder. + Depending on the ASN.1 structure being decoded, `asn1Spec` may + or may not be required. One of the reasons why `asn1Spec` may + me required is that ASN.1 structure is encoded in the *IMPLICIT* + tagging mode. + + Yields + ------ + : :py:class:`~pyasn1.type.base.PyAsn1Item`, :py:class:`~pyasn1.error.SubstrateUnderrunError` + Decoded ASN.1 object (possibly, nested) or + :py:class:`~pyasn1.error.SubstrateUnderrunError` object indicating + insufficient BER/CER/DER serialization on input to fully recover ASN.1 + objects from it. + + In the latter case the caller is advised to ensure some more data in + the input stream, then call the iterator again. The decoder will resume + the decoding process using the newly arrived data. + + The `context` property of :py:class:`~pyasn1.error.SubstrateUnderrunError` + object might hold a reference to the partially populated ASN.1 object + being reconstructed. + + Raises + ------ + ~pyasn1.error.PyAsn1Error, ~pyasn1.error.EndOfStreamError + `PyAsn1Error` on deserialization error, `EndOfStreamError` on + premature stream closure. + + Examples + -------- + Decode BER serialisation without ASN.1 schema + + .. code-block:: pycon + + >>> stream = io.BytesIO( + ... b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03') + >>> + >>> for asn1Object in StreamingDecoder(stream): + ... print(asn1Object) + >>> + SequenceOf: + 1 2 3 + + Decode BER serialisation with ASN.1 schema + + .. code-block:: pycon + + >>> stream = io.BytesIO( + ... b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03') + >>> + >>> schema = SequenceOf(componentType=Integer()) + >>> + >>> decoder = StreamingDecoder(stream, asn1Spec=schema) + >>> for asn1Object in decoder: + ... print(asn1Object) + >>> + SequenceOf: + 1 2 3 + """ + + SINGLE_ITEM_DECODER = SingleItemDecoder + + def __init__(self, substrate, asn1Spec=None, **options): + self._singleItemDecoder = self.SINGLE_ITEM_DECODER(**options) + self._substrate = asSeekableStream(substrate) + self._asn1Spec = asn1Spec + self._options = options + + def __iter__(self): + while True: + for asn1Object in self._singleItemDecoder( + self._substrate, self._asn1Spec, **self._options): + yield asn1Object + + for chunk in isEndOfStream(self._substrate): + if isinstance(chunk, SubstrateUnderrunError): + yield + + break + + if chunk: + break + + +class Decoder(object): + """Create a BER decoder object. + + Parse BER/CER/DER octet-stream into one, possibly nested, ASN.1 object. + """ + STREAMING_DECODER = StreamingDecoder + + @classmethod + def __call__(cls, substrate, asn1Spec=None, **options): + """Turns BER/CER/DER octet stream into an ASN.1 object. + + Takes BER/CER/DER octet-stream in form of :py:class:`bytes` + and decode it into an ASN.1 object + (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) which + may be a scalar or an arbitrary nested structure. + + Parameters + ---------- + substrate: :py:class:`bytes` + BER/CER/DER octet-stream to parse + + Keyword Args + ------------ + asn1Spec: :py:class:`~pyasn1.type.base.PyAsn1Item` + A pyasn1 type object (:py:class:`~pyasn1.type.base.PyAsn1Item` + derivative) to act as a template guiding the decoder. + Depending on the ASN.1 structure being decoded, `asn1Spec` may or + may not be required. Most common reason for it to require is that + ASN.1 structure is encoded in *IMPLICIT* tagging mode. + + substrateFun: :py:class:`Union[ + Callable[[pyasn1.type.base.PyAsn1Item, bytes, int], + Tuple[pyasn1.type.base.PyAsn1Item, bytes]], + Callable[[pyasn1.type.base.PyAsn1Item, io.BytesIO, int, dict], + Generator[Union[pyasn1.type.base.PyAsn1Item, + pyasn1.error.SubstrateUnderrunError], + None, None]] + ]` + User callback meant to generalize special use cases like non-recursive or + partial decoding. A 3-arg non-streaming variant is supported for backwards + compatiblilty in addition to the newer 4-arg streaming variant. + The callback will receive the uninitialized object recovered from substrate + as 1st argument, the uninterpreted payload as 2nd argument, and the length + of the uninterpreted payload as 3rd argument. The streaming variant will + additionally receive the decode(..., **options) kwargs as 4th argument. + The non-streaming variant shall return an object that will be propagated + as decode() return value as 1st item, and the remainig payload for further + decode passes as 2nd item. + The streaming variant shall yield an object that will be propagated as + decode() return value, and leave the remaining payload in the stream. + + Returns + ------- + : :py:class:`tuple` + A tuple of :py:class:`~pyasn1.type.base.PyAsn1Item` object + recovered from BER/CER/DER substrate and the unprocessed trailing + portion of the `substrate` (may be empty) + + Raises + ------ + : :py:class:`~pyasn1.error.PyAsn1Error` + :py:class:`~pyasn1.error.SubstrateUnderrunError` on insufficient + input or :py:class:`~pyasn1.error.PyAsn1Error` on decoding error. + + Examples + -------- + Decode BER/CER/DER serialisation without ASN.1 schema + + .. code-block:: pycon + + >>> s, unprocessed = decode(b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03') + >>> str(s) + SequenceOf: + 1 2 3 + + Decode BER/CER/DER serialisation with ASN.1 schema + + .. code-block:: pycon + + >>> seq = SequenceOf(componentType=Integer()) + >>> s, unprocessed = decode( + b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03', asn1Spec=seq) + >>> str(s) + SequenceOf: + 1 2 3 + + """ + substrate = asSeekableStream(substrate) + + if "substrateFun" in options: + origSubstrateFun = options["substrateFun"] + + def substrateFunWrapper(asn1Object, substrate, length, options=None): + """Support both 0.4 and 0.5 style APIs. + + substrateFun API has changed in 0.5 for use with streaming decoders. To stay backwards compatible, + we first try if we received a streaming user callback. If that fails,we assume we've received a + non-streaming v0.4 user callback and convert it for streaming on the fly + """ + try: + substrate_gen = origSubstrateFun(asn1Object, substrate, length, options) + except TypeError as _value: + if _value.__traceback__.tb_next: + # Traceback depth > 1 means TypeError from inside user provided function + raise + # invariant maintained at Decoder.__call__ entry + assert isinstance(substrate, io.BytesIO) # nosec assert_used + substrate_gen = Decoder._callSubstrateFunV4asV5(origSubstrateFun, asn1Object, substrate, length) + for value in substrate_gen: + yield value + + options["substrateFun"] = substrateFunWrapper + + streamingDecoder = cls.STREAMING_DECODER( + substrate, asn1Spec, **options) + + for asn1Object in streamingDecoder: + if isinstance(asn1Object, SubstrateUnderrunError): + raise error.SubstrateUnderrunError('Short substrate on input') + + try: + tail = next(readFromStream(substrate)) + + except error.EndOfStreamError: + tail = b'' + + return asn1Object, tail + + @staticmethod + def _callSubstrateFunV4asV5(substrateFunV4, asn1Object, substrate, length): + substrate_bytes = substrate.read() + if length == -1: + length = len(substrate_bytes) + value, nextSubstrate = substrateFunV4(asn1Object, substrate_bytes, length) + nbytes = substrate.write(nextSubstrate) + substrate.truncate() + substrate.seek(-nbytes, os.SEEK_CUR) + yield value + +#: Turns BER octet stream into an ASN.1 object. +#: +#: Takes BER octet-stream and decode it into an ASN.1 object +#: (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) which +#: may be a scalar or an arbitrary nested structure. +#: +#: Parameters +#: ---------- +#: substrate: :py:class:`bytes` +#: BER octet-stream +#: +#: Keyword Args +#: ------------ +#: asn1Spec: any pyasn1 type object e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: A pyasn1 type object to act as a template guiding the decoder. Depending on the ASN.1 structure +#: being decoded, *asn1Spec* may or may not be required. Most common reason for +#: it to require is that ASN.1 structure is encoded in *IMPLICIT* tagging mode. +#: +#: Returns +#: ------- +#: : :py:class:`tuple` +#: A tuple of pyasn1 object recovered from BER substrate (:py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: and the unprocessed trailing portion of the *substrate* (may be empty) +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error, ~pyasn1.error.SubstrateUnderrunError +#: On decoding errors +#: +#: Notes +#: ----- +#: This function is deprecated. Please use :py:class:`Decoder` or +#: :py:class:`StreamingDecoder` class instance. +#: +#: Examples +#: -------- +#: Decode BER serialisation without ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> s, _ = decode(b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03') +#: >>> str(s) +#: SequenceOf: +#: 1 2 3 +#: +#: Decode BER serialisation with ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> s, _ = decode(b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03', asn1Spec=seq) +#: >>> str(s) +#: SequenceOf: +#: 1 2 3 +#: +decode = Decoder() + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/ber/encoder.py b/module/scripts/lib/pyasn1/codec/ber/encoder.py new file mode 100644 index 0000000..f06b19e --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/ber/encoder.py @@ -0,0 +1,954 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import sys +import warnings + +from pyasn1 import debug +from pyasn1 import error +from pyasn1.codec.ber import eoo +from pyasn1.compat import _MISSING +from pyasn1.compat.integer import to_bytes +from pyasn1.type import char +from pyasn1.type import tag +from pyasn1.type import univ +from pyasn1.type import useful + +__all__ = ['Encoder', 'encode'] + +LOG = debug.registerLoggee(__name__, flags=debug.DEBUG_ENCODER) + + +class AbstractItemEncoder(object): + supportIndefLenMode = True + + # An outcome of otherwise legit call `encodeFun(eoo.endOfOctets)` + eooIntegerSubstrate = (0, 0) + eooOctetsSubstrate = bytes(eooIntegerSubstrate) + + # noinspection PyMethodMayBeStatic + def encodeTag(self, singleTag, isConstructed): + tagClass, tagFormat, tagId = singleTag + encodedTag = tagClass | tagFormat + if isConstructed: + encodedTag |= tag.tagFormatConstructed + + if tagId < 31: + return encodedTag | tagId, + + else: + substrate = tagId & 0x7f, + + tagId >>= 7 + + while tagId: + substrate = (0x80 | (tagId & 0x7f),) + substrate + tagId >>= 7 + + return (encodedTag | 0x1F,) + substrate + + def encodeLength(self, length, defMode): + if not defMode and self.supportIndefLenMode: + return (0x80,) + + if length < 0x80: + return length, + + else: + substrate = () + while length: + substrate = (length & 0xff,) + substrate + length >>= 8 + + substrateLen = len(substrate) + + if substrateLen > 126: + raise error.PyAsn1Error('Length octets overflow (%d)' % substrateLen) + + return (0x80 | substrateLen,) + substrate + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + raise error.PyAsn1Error('Not implemented') + + def encode(self, value, asn1Spec=None, encodeFun=None, **options): + + if asn1Spec is None: + tagSet = value.tagSet + else: + tagSet = asn1Spec.tagSet + + # untagged item? + if not tagSet: + substrate, isConstructed, isOctets = self.encodeValue( + value, asn1Spec, encodeFun, **options + ) + return substrate + + defMode = options.get('defMode', True) + + substrate = b'' + + for idx, singleTag in enumerate(tagSet.superTags): + + defModeOverride = defMode + + # base tag? + if not idx: + try: + substrate, isConstructed, isOctets = self.encodeValue( + value, asn1Spec, encodeFun, **options + ) + + except error.PyAsn1Error as exc: + raise error.PyAsn1Error( + 'Error encoding %r: %s' % (value, exc)) + + if LOG: + LOG('encoded %svalue %s into %s' % ( + isConstructed and 'constructed ' or '', value, substrate + )) + + if not substrate and isConstructed and options.get('ifNotEmpty', False): + return substrate + + if not isConstructed: + defModeOverride = True + + if LOG: + LOG('overridden encoding mode into definitive for primitive type') + + header = self.encodeTag(singleTag, isConstructed) + + if LOG: + LOG('encoded %stag %s into %s' % ( + isConstructed and 'constructed ' or '', + singleTag, debug.hexdump(bytes(header)))) + + header += self.encodeLength(len(substrate), defModeOverride) + + if LOG: + LOG('encoded %s octets (tag + payload) into %s' % ( + len(substrate), debug.hexdump(bytes(header)))) + + if isOctets: + substrate = bytes(header) + substrate + + if not defModeOverride: + substrate += self.eooOctetsSubstrate + + else: + substrate = header + substrate + + if not defModeOverride: + substrate += self.eooIntegerSubstrate + + if not isOctets: + substrate = bytes(substrate) + + return substrate + + +class EndOfOctetsEncoder(AbstractItemEncoder): + def encodeValue(self, value, asn1Spec, encodeFun, **options): + return b'', False, True + + +class BooleanEncoder(AbstractItemEncoder): + supportIndefLenMode = False + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + return value and (1,) or (0,), False, False + + +class IntegerEncoder(AbstractItemEncoder): + supportIndefLenMode = False + supportCompactZero = False + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if value == 0: + if LOG: + LOG('encoding %spayload for zero INTEGER' % ( + self.supportCompactZero and 'no ' or '' + )) + + # de-facto way to encode zero + if self.supportCompactZero: + return (), False, False + else: + return (0,), False, False + + return to_bytes(int(value), signed=True), False, True + + +class BitStringEncoder(AbstractItemEncoder): + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if asn1Spec is not None: + # TODO: try to avoid ASN.1 schema instantiation + value = asn1Spec.clone(value) + + valueLength = len(value) + if valueLength % 8: + alignedValue = value << (8 - valueLength % 8) + else: + alignedValue = value + + maxChunkSize = options.get('maxChunkSize', 0) + if not maxChunkSize or len(alignedValue) <= maxChunkSize * 8: + substrate = alignedValue.asOctets() + return bytes((len(substrate) * 8 - valueLength,)) + substrate, False, True + + if LOG: + LOG('encoding into up to %s-octet chunks' % maxChunkSize) + + baseTag = value.tagSet.baseTag + + # strip off explicit tags + if baseTag: + tagSet = tag.TagSet(baseTag, baseTag) + + else: + tagSet = tag.TagSet() + + alignedValue = alignedValue.clone(tagSet=tagSet) + + stop = 0 + substrate = b'' + while stop < valueLength: + start = stop + stop = min(start + maxChunkSize * 8, valueLength) + substrate += encodeFun(alignedValue[start:stop], asn1Spec, **options) + + return substrate, True, True + + +class OctetStringEncoder(AbstractItemEncoder): + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + + if asn1Spec is None: + substrate = value.asOctets() + + elif not isinstance(value, bytes): + substrate = asn1Spec.clone(value).asOctets() + + else: + substrate = value + + maxChunkSize = options.get('maxChunkSize', 0) + + if not maxChunkSize or len(substrate) <= maxChunkSize: + return substrate, False, True + + if LOG: + LOG('encoding into up to %s-octet chunks' % maxChunkSize) + + # strip off explicit tags for inner chunks + + if asn1Spec is None: + baseTag = value.tagSet.baseTag + + # strip off explicit tags + if baseTag: + tagSet = tag.TagSet(baseTag, baseTag) + + else: + tagSet = tag.TagSet() + + asn1Spec = value.clone(tagSet=tagSet) + + elif not isinstance(value, bytes): + baseTag = asn1Spec.tagSet.baseTag + + # strip off explicit tags + if baseTag: + tagSet = tag.TagSet(baseTag, baseTag) + + else: + tagSet = tag.TagSet() + + asn1Spec = asn1Spec.clone(tagSet=tagSet) + + pos = 0 + substrate = b'' + + while True: + chunk = value[pos:pos + maxChunkSize] + if not chunk: + break + + substrate += encodeFun(chunk, asn1Spec, **options) + pos += maxChunkSize + + return substrate, True, True + + +class NullEncoder(AbstractItemEncoder): + supportIndefLenMode = False + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + return b'', False, True + + +class ObjectIdentifierEncoder(AbstractItemEncoder): + supportIndefLenMode = False + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if asn1Spec is not None: + value = asn1Spec.clone(value) + + oid = value.asTuple() + + # Build the first pair + try: + first = oid[0] + second = oid[1] + + except IndexError: + raise error.PyAsn1Error('Short OID %s' % (value,)) + + if 0 <= second <= 39: + if first == 1: + oid = (second + 40,) + oid[2:] + elif first == 0: + oid = (second,) + oid[2:] + elif first == 2: + oid = (second + 80,) + oid[2:] + else: + raise error.PyAsn1Error('Impossible first/second arcs at %s' % (value,)) + + elif first == 2: + oid = (second + 80,) + oid[2:] + + else: + raise error.PyAsn1Error('Impossible first/second arcs at %s' % (value,)) + + octets = [] + + # Cycle through subIds + for subOid in oid: + if 0 <= subOid <= 127: + # Optimize for the common case + octets.append(subOid) + + elif subOid > 127: + # Pack large Sub-Object IDs + res = [subOid & 0x7f] + subOid >>= 7 + + while subOid: + res.append(0x80 | (subOid & 0x7f)) + subOid >>= 7 + + # Add packed Sub-Object ID to resulted Object ID + octets.extend(reversed(res)) + + else: + raise error.PyAsn1Error('Negative OID arc %s at %s' % (subOid, value)) + + return tuple(octets), False, False + + +class RelativeOIDEncoder(AbstractItemEncoder): + supportIndefLenMode = False + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if asn1Spec is not None: + value = asn1Spec.clone(value) + + octets = [] + + # Cycle through subIds + for subOid in value.asTuple(): + if 0 <= subOid <= 127: + # Optimize for the common case + octets.append(subOid) + + elif subOid > 127: + # Pack large Sub-Object IDs + res = [subOid & 0x7f] + subOid >>= 7 + + while subOid: + res.append(0x80 | (subOid & 0x7f)) + subOid >>= 7 + + # Add packed Sub-Object ID to resulted RELATIVE-OID + octets.extend(reversed(res)) + + else: + raise error.PyAsn1Error('Negative RELATIVE-OID arc %s at %s' % (subOid, value)) + + return tuple(octets), False, False + + +class RealEncoder(AbstractItemEncoder): + supportIndefLenMode = False + binEncBase = 2 # set to None to choose encoding base automatically + + @staticmethod + def _dropFloatingPoint(m, encbase, e): + ms, es = 1, 1 + if m < 0: + ms = -1 # mantissa sign + + if e < 0: + es = -1 # exponent sign + + m *= ms + + if encbase == 8: + m *= 2 ** (abs(e) % 3 * es) + e = abs(e) // 3 * es + + elif encbase == 16: + m *= 2 ** (abs(e) % 4 * es) + e = abs(e) // 4 * es + + while True: + if int(m) != m: + m *= encbase + e -= 1 + continue + break + + return ms, int(m), encbase, e + + def _chooseEncBase(self, value): + m, b, e = value + encBase = [2, 8, 16] + if value.binEncBase in encBase: + return self._dropFloatingPoint(m, value.binEncBase, e) + + elif self.binEncBase in encBase: + return self._dropFloatingPoint(m, self.binEncBase, e) + + # auto choosing base 2/8/16 + mantissa = [m, m, m] + exponent = [e, e, e] + sign = 1 + encbase = 2 + e = float('inf') + + for i in range(3): + (sign, + mantissa[i], + encBase[i], + exponent[i]) = self._dropFloatingPoint(mantissa[i], encBase[i], exponent[i]) + + if abs(exponent[i]) < abs(e) or (abs(exponent[i]) == abs(e) and mantissa[i] < m): + e = exponent[i] + m = int(mantissa[i]) + encbase = encBase[i] + + if LOG: + LOG('automatically chosen REAL encoding base %s, sign %s, mantissa %s, ' + 'exponent %s' % (encbase, sign, m, e)) + + return sign, m, encbase, e + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if asn1Spec is not None: + value = asn1Spec.clone(value) + + if value.isPlusInf: + return (0x40,), False, False + + if value.isMinusInf: + return (0x41,), False, False + + m, b, e = value + + if not m: + return b'', False, True + + if b == 10: + if LOG: + LOG('encoding REAL into character form') + + return b'\x03%dE%s%d' % (m, e == 0 and b'+' or b'', e), False, True + + elif b == 2: + fo = 0x80 # binary encoding + ms, m, encbase, e = self._chooseEncBase(value) + + if ms < 0: # mantissa sign + fo |= 0x40 # sign bit + + # exponent & mantissa normalization + if encbase == 2: + while m & 0x1 == 0: + m >>= 1 + e += 1 + + elif encbase == 8: + while m & 0x7 == 0: + m >>= 3 + e += 1 + fo |= 0x10 + + else: # encbase = 16 + while m & 0xf == 0: + m >>= 4 + e += 1 + fo |= 0x20 + + sf = 0 # scale factor + + while m & 0x1 == 0: + m >>= 1 + sf += 1 + + if sf > 3: + raise error.PyAsn1Error('Scale factor overflow') # bug if raised + + fo |= sf << 2 + eo = b'' + if e == 0 or e == -1: + eo = bytes((e & 0xff,)) + + else: + while e not in (0, -1): + eo = bytes((e & 0xff,)) + eo + e >>= 8 + + if e == 0 and eo and eo[0] & 0x80: + eo = bytes((0,)) + eo + + if e == -1 and eo and not (eo[0] & 0x80): + eo = bytes((0xff,)) + eo + + n = len(eo) + if n > 0xff: + raise error.PyAsn1Error('Real exponent overflow') + + if n == 1: + pass + + elif n == 2: + fo |= 1 + + elif n == 3: + fo |= 2 + + else: + fo |= 3 + eo = bytes((n & 0xff,)) + eo + + po = b'' + + while m: + po = bytes((m & 0xff,)) + po + m >>= 8 + + substrate = bytes((fo,)) + eo + po + + return substrate, False, True + + else: + raise error.PyAsn1Error('Prohibited Real base %s' % b) + + +class SequenceEncoder(AbstractItemEncoder): + omitEmptyOptionals = False + + # TODO: handling three flavors of input is too much -- split over codecs + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + + substrate = b'' + + omitEmptyOptionals = options.get( + 'omitEmptyOptionals', self.omitEmptyOptionals) + + if LOG: + LOG('%sencoding empty OPTIONAL components' % ( + omitEmptyOptionals and 'not ' or '')) + + if asn1Spec is None: + # instance of ASN.1 schema + inconsistency = value.isInconsistent + if inconsistency: + raise error.PyAsn1Error( + f"ASN.1 object {value.__class__.__name__} is inconsistent") + + namedTypes = value.componentType + + for idx, component in enumerate(value.values()): + if namedTypes: + namedType = namedTypes[idx] + + if namedType.isOptional and not component.isValue: + if LOG: + LOG('not encoding OPTIONAL component %r' % (namedType,)) + continue + + if namedType.isDefaulted and component == namedType.asn1Object: + if LOG: + LOG('not encoding DEFAULT component %r' % (namedType,)) + continue + + if omitEmptyOptionals: + options.update(ifNotEmpty=namedType.isOptional) + + # wrap open type blob if needed + if namedTypes and namedType.openType: + + wrapType = namedType.asn1Object + + if wrapType.typeId in ( + univ.SetOf.typeId, univ.SequenceOf.typeId): + + substrate += encodeFun( + component, asn1Spec, + **dict(options, wrapType=wrapType.componentType)) + + else: + chunk = encodeFun(component, asn1Spec, **options) + + if wrapType.isSameTypeWith(component): + substrate += chunk + + else: + substrate += encodeFun(chunk, wrapType, **options) + + if LOG: + LOG('wrapped with wrap type %r' % (wrapType,)) + + else: + substrate += encodeFun(component, asn1Spec, **options) + + else: + # bare Python value + ASN.1 schema + for idx, namedType in enumerate(asn1Spec.componentType.namedTypes): + + try: + component = value[namedType.name] + + except KeyError: + raise error.PyAsn1Error('Component name "%s" not found in %r' % ( + namedType.name, value)) + + if namedType.isOptional and namedType.name not in value: + if LOG: + LOG('not encoding OPTIONAL component %r' % (namedType,)) + continue + + if namedType.isDefaulted and component == namedType.asn1Object: + if LOG: + LOG('not encoding DEFAULT component %r' % (namedType,)) + continue + + if omitEmptyOptionals: + options.update(ifNotEmpty=namedType.isOptional) + + componentSpec = namedType.asn1Object + + # wrap open type blob if needed + if namedType.openType: + + if componentSpec.typeId in ( + univ.SetOf.typeId, univ.SequenceOf.typeId): + + substrate += encodeFun( + component, componentSpec, + **dict(options, wrapType=componentSpec.componentType)) + + else: + chunk = encodeFun(component, componentSpec, **options) + + if componentSpec.isSameTypeWith(component): + substrate += chunk + + else: + substrate += encodeFun(chunk, componentSpec, **options) + + if LOG: + LOG('wrapped with wrap type %r' % (componentSpec,)) + + else: + substrate += encodeFun(component, componentSpec, **options) + + return substrate, True, True + + +class SequenceOfEncoder(AbstractItemEncoder): + def _encodeComponents(self, value, asn1Spec, encodeFun, **options): + + if asn1Spec is None: + inconsistency = value.isInconsistent + if inconsistency: + raise error.PyAsn1Error( + f"ASN.1 object {value.__class__.__name__} is inconsistent") + + else: + asn1Spec = asn1Spec.componentType + + chunks = [] + + wrapType = options.pop('wrapType', None) + + for idx, component in enumerate(value): + chunk = encodeFun(component, asn1Spec, **options) + + if (wrapType is not None and + not wrapType.isSameTypeWith(component)): + # wrap encoded value with wrapper container (e.g. ANY) + chunk = encodeFun(chunk, wrapType, **options) + + if LOG: + LOG('wrapped with wrap type %r' % (wrapType,)) + + chunks.append(chunk) + + return chunks + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + chunks = self._encodeComponents( + value, asn1Spec, encodeFun, **options) + + return b''.join(chunks), True, True + + +class ChoiceEncoder(AbstractItemEncoder): + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if asn1Spec is None: + component = value.getComponent() + else: + names = [namedType.name for namedType in asn1Spec.componentType.namedTypes + if namedType.name in value] + if len(names) != 1: + raise error.PyAsn1Error('%s components for Choice at %r' % (len(names) and 'Multiple ' or 'None ', value)) + + name = names[0] + + component = value[name] + asn1Spec = asn1Spec[name] + + return encodeFun(component, asn1Spec, **options), True, True + + +class AnyEncoder(OctetStringEncoder): + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if asn1Spec is None: + value = value.asOctets() + elif not isinstance(value, bytes): + value = asn1Spec.clone(value).asOctets() + + return value, not options.get('defMode', True), True + + +TAG_MAP = { + eoo.endOfOctets.tagSet: EndOfOctetsEncoder(), + univ.Boolean.tagSet: BooleanEncoder(), + univ.Integer.tagSet: IntegerEncoder(), + univ.BitString.tagSet: BitStringEncoder(), + univ.OctetString.tagSet: OctetStringEncoder(), + univ.Null.tagSet: NullEncoder(), + univ.ObjectIdentifier.tagSet: ObjectIdentifierEncoder(), + univ.RelativeOID.tagSet: RelativeOIDEncoder(), + univ.Enumerated.tagSet: IntegerEncoder(), + univ.Real.tagSet: RealEncoder(), + # Sequence & Set have same tags as SequenceOf & SetOf + univ.SequenceOf.tagSet: SequenceOfEncoder(), + univ.SetOf.tagSet: SequenceOfEncoder(), + univ.Choice.tagSet: ChoiceEncoder(), + # character string types + char.UTF8String.tagSet: OctetStringEncoder(), + char.NumericString.tagSet: OctetStringEncoder(), + char.PrintableString.tagSet: OctetStringEncoder(), + char.TeletexString.tagSet: OctetStringEncoder(), + char.VideotexString.tagSet: OctetStringEncoder(), + char.IA5String.tagSet: OctetStringEncoder(), + char.GraphicString.tagSet: OctetStringEncoder(), + char.VisibleString.tagSet: OctetStringEncoder(), + char.GeneralString.tagSet: OctetStringEncoder(), + char.UniversalString.tagSet: OctetStringEncoder(), + char.BMPString.tagSet: OctetStringEncoder(), + # useful types + useful.ObjectDescriptor.tagSet: OctetStringEncoder(), + useful.GeneralizedTime.tagSet: OctetStringEncoder(), + useful.UTCTime.tagSet: OctetStringEncoder() +} + +# Put in ambiguous & non-ambiguous types for faster codec lookup +TYPE_MAP = { + univ.Boolean.typeId: BooleanEncoder(), + univ.Integer.typeId: IntegerEncoder(), + univ.BitString.typeId: BitStringEncoder(), + univ.OctetString.typeId: OctetStringEncoder(), + univ.Null.typeId: NullEncoder(), + univ.ObjectIdentifier.typeId: ObjectIdentifierEncoder(), + univ.RelativeOID.typeId: RelativeOIDEncoder(), + univ.Enumerated.typeId: IntegerEncoder(), + univ.Real.typeId: RealEncoder(), + # Sequence & Set have same tags as SequenceOf & SetOf + univ.Set.typeId: SequenceEncoder(), + univ.SetOf.typeId: SequenceOfEncoder(), + univ.Sequence.typeId: SequenceEncoder(), + univ.SequenceOf.typeId: SequenceOfEncoder(), + univ.Choice.typeId: ChoiceEncoder(), + univ.Any.typeId: AnyEncoder(), + # character string types + char.UTF8String.typeId: OctetStringEncoder(), + char.NumericString.typeId: OctetStringEncoder(), + char.PrintableString.typeId: OctetStringEncoder(), + char.TeletexString.typeId: OctetStringEncoder(), + char.VideotexString.typeId: OctetStringEncoder(), + char.IA5String.typeId: OctetStringEncoder(), + char.GraphicString.typeId: OctetStringEncoder(), + char.VisibleString.typeId: OctetStringEncoder(), + char.GeneralString.typeId: OctetStringEncoder(), + char.UniversalString.typeId: OctetStringEncoder(), + char.BMPString.typeId: OctetStringEncoder(), + # useful types + useful.ObjectDescriptor.typeId: OctetStringEncoder(), + useful.GeneralizedTime.typeId: OctetStringEncoder(), + useful.UTCTime.typeId: OctetStringEncoder() +} + + +class SingleItemEncoder(object): + fixedDefLengthMode = None + fixedChunkSize = None + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + def __init__(self, tagMap=_MISSING, typeMap=_MISSING, **ignored): + self._tagMap = tagMap if tagMap is not _MISSING else self.TAG_MAP + self._typeMap = typeMap if typeMap is not _MISSING else self.TYPE_MAP + + def __call__(self, value, asn1Spec=None, **options): + try: + if asn1Spec is None: + typeId = value.typeId + else: + typeId = asn1Spec.typeId + + except AttributeError: + raise error.PyAsn1Error('Value %r is not ASN.1 type instance ' + 'and "asn1Spec" not given' % (value,)) + + if LOG: + LOG('encoder called in %sdef mode, chunk size %s for type %s, ' + 'value:\n%s' % (not options.get('defMode', True) and 'in' or '', + options.get('maxChunkSize', 0), + asn1Spec is None and value.prettyPrintType() or + asn1Spec.prettyPrintType(), value)) + + if self.fixedDefLengthMode is not None: + options.update(defMode=self.fixedDefLengthMode) + + if self.fixedChunkSize is not None: + options.update(maxChunkSize=self.fixedChunkSize) + + try: + concreteEncoder = self._typeMap[typeId] + + if LOG: + LOG('using value codec %s chosen by type ID ' + '%s' % (concreteEncoder.__class__.__name__, typeId)) + + except KeyError: + if asn1Spec is None: + tagSet = value.tagSet + else: + tagSet = asn1Spec.tagSet + + # use base type for codec lookup to recover untagged types + baseTagSet = tag.TagSet(tagSet.baseTag, tagSet.baseTag) + + try: + concreteEncoder = self._tagMap[baseTagSet] + + except KeyError: + raise error.PyAsn1Error('No encoder for %r (%s)' % (value, tagSet)) + + if LOG: + LOG('using value codec %s chosen by tagSet ' + '%s' % (concreteEncoder.__class__.__name__, tagSet)) + + substrate = concreteEncoder.encode(value, asn1Spec, self, **options) + + if LOG: + LOG('codec %s built %s octets of substrate: %s\nencoder ' + 'completed' % (concreteEncoder, len(substrate), + debug.hexdump(substrate))) + + return substrate + + +class Encoder(object): + SINGLE_ITEM_ENCODER = SingleItemEncoder + + def __init__(self, tagMap=_MISSING, typeMap=_MISSING, **options): + self._singleItemEncoder = self.SINGLE_ITEM_ENCODER( + tagMap=tagMap, typeMap=typeMap, **options + ) + + def __call__(self, pyObject, asn1Spec=None, **options): + return self._singleItemEncoder( + pyObject, asn1Spec=asn1Spec, **options) + + +#: Turns ASN.1 object into BER octet stream. +#: +#: Takes any ASN.1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: walks all its components recursively and produces a BER octet stream. +#: +#: Parameters +#: ---------- +#: value: either a Python or pyasn1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: A Python or pyasn1 object to encode. If Python object is given, `asnSpec` +#: parameter is required to guide the encoding process. +#: +#: Keyword Args +#: ------------ +#: asn1Spec: +#: Optional ASN.1 schema or value object e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: +#: defMode: :py:class:`bool` +#: If :obj:`False`, produces indefinite length encoding +#: +#: maxChunkSize: :py:class:`int` +#: Maximum chunk size in chunked encoding mode (0 denotes unlimited chunk size) +#: +#: Returns +#: ------- +#: : :py:class:`bytes` +#: Given ASN.1 object encoded into BER octetstream +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error +#: On encoding errors +#: +#: Examples +#: -------- +#: Encode Python value into BER with ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> encode([1, 2, 3], asn1Spec=seq) +#: b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03' +#: +#: Encode ASN.1 value object into BER +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> seq.extend([1, 2, 3]) +#: >>> encode(seq) +#: b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03' +#: +encode = Encoder() + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/ber/eoo.py b/module/scripts/lib/pyasn1/codec/ber/eoo.py new file mode 100644 index 0000000..8c91a3d --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/ber/eoo.py @@ -0,0 +1,28 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +from pyasn1.type import base +from pyasn1.type import tag + +__all__ = ['endOfOctets'] + + +class EndOfOctets(base.SimpleAsn1Type): + defaultValue = 0 + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x00) + ) + + _instance = None + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = object.__new__(cls, *args, **kwargs) + + return cls._instance + + +endOfOctets = EndOfOctets() diff --git a/module/scripts/lib/pyasn1/codec/cer/__init__.py b/module/scripts/lib/pyasn1/codec/cer/__init__.py new file mode 100644 index 0000000..8c3066b --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/cer/__init__.py @@ -0,0 +1 @@ +# This file is necessary to make this directory a package. diff --git a/module/scripts/lib/pyasn1/codec/cer/decoder.py b/module/scripts/lib/pyasn1/codec/cer/decoder.py new file mode 100644 index 0000000..4610446 --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/cer/decoder.py @@ -0,0 +1,149 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import warnings + +from pyasn1 import error +from pyasn1.codec.streaming import readFromStream +from pyasn1.codec.ber import decoder +from pyasn1.type import univ + +__all__ = ['decode', 'StreamingDecoder'] + +SubstrateUnderrunError = error.SubstrateUnderrunError + + +class BooleanPayloadDecoder(decoder.AbstractSimplePayloadDecoder): + protoComponent = univ.Boolean(0) + + def valueDecoder(self, substrate, asn1Spec, + tagSet=None, length=None, state=None, + decodeFun=None, substrateFun=None, + **options): + + if length != 1: + raise error.PyAsn1Error('Not single-octet Boolean payload') + + for chunk in readFromStream(substrate, length, options): + if isinstance(chunk, SubstrateUnderrunError): + yield chunk + + byte = chunk[0] + + # CER/DER specifies encoding of TRUE as 0xFF and FALSE as 0x0, while + # BER allows any non-zero value as TRUE; cf. sections 8.2.2. and 11.1 + # in https://www.itu.int/ITU-T/studygroups/com17/languages/X.690-0207.pdf + if byte == 0xff: + value = 1 + + elif byte == 0x00: + value = 0 + + else: + raise error.PyAsn1Error('Unexpected Boolean payload: %s' % byte) + + yield self._createComponent(asn1Spec, tagSet, value, **options) + + +# TODO: prohibit non-canonical encoding +BitStringPayloadDecoder = decoder.BitStringPayloadDecoder +OctetStringPayloadDecoder = decoder.OctetStringPayloadDecoder +RealPayloadDecoder = decoder.RealPayloadDecoder + +TAG_MAP = decoder.TAG_MAP.copy() +TAG_MAP.update( + {univ.Boolean.tagSet: BooleanPayloadDecoder(), + univ.BitString.tagSet: BitStringPayloadDecoder(), + univ.OctetString.tagSet: OctetStringPayloadDecoder(), + univ.Real.tagSet: RealPayloadDecoder()} +) + +TYPE_MAP = decoder.TYPE_MAP.copy() + +# Put in non-ambiguous types for faster codec lookup +for typeDecoder in TAG_MAP.values(): + if typeDecoder.protoComponent is not None: + typeId = typeDecoder.protoComponent.__class__.typeId + if typeId is not None and typeId not in TYPE_MAP: + TYPE_MAP[typeId] = typeDecoder + + +class SingleItemDecoder(decoder.SingleItemDecoder): + __doc__ = decoder.SingleItemDecoder.__doc__ + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + +class StreamingDecoder(decoder.StreamingDecoder): + __doc__ = decoder.StreamingDecoder.__doc__ + + SINGLE_ITEM_DECODER = SingleItemDecoder + + +class Decoder(decoder.Decoder): + __doc__ = decoder.Decoder.__doc__ + + STREAMING_DECODER = StreamingDecoder + + +#: Turns CER octet stream into an ASN.1 object. +#: +#: Takes CER octet-stream and decode it into an ASN.1 object +#: (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) which +#: may be a scalar or an arbitrary nested structure. +#: +#: Parameters +#: ---------- +#: substrate: :py:class:`bytes` +#: CER octet-stream +#: +#: Keyword Args +#: ------------ +#: asn1Spec: any pyasn1 type object e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: A pyasn1 type object to act as a template guiding the decoder. Depending on the ASN.1 structure +#: being decoded, *asn1Spec* may or may not be required. Most common reason for +#: it to require is that ASN.1 structure is encoded in *IMPLICIT* tagging mode. +#: +#: Returns +#: ------- +#: : :py:class:`tuple` +#: A tuple of pyasn1 object recovered from CER substrate (:py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: and the unprocessed trailing portion of the *substrate* (may be empty) +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error, ~pyasn1.error.SubstrateUnderrunError +#: On decoding errors +#: +#: Examples +#: -------- +#: Decode CER serialisation without ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> s, _ = decode(b'0\x80\x02\x01\x01\x02\x01\x02\x02\x01\x03\x00\x00') +#: >>> str(s) +#: SequenceOf: +#: 1 2 3 +#: +#: Decode CER serialisation with ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> s, _ = decode(b'0\x80\x02\x01\x01\x02\x01\x02\x02\x01\x03\x00\x00', asn1Spec=seq) +#: >>> str(s) +#: SequenceOf: +#: 1 2 3 +#: +decode = Decoder() + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/cer/encoder.py b/module/scripts/lib/pyasn1/codec/cer/encoder.py new file mode 100644 index 0000000..15a8064 --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/cer/encoder.py @@ -0,0 +1,331 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import warnings + +from pyasn1 import error +from pyasn1.codec.ber import encoder +from pyasn1.type import univ +from pyasn1.type import useful + +__all__ = ['Encoder', 'encode'] + + +class BooleanEncoder(encoder.IntegerEncoder): + def encodeValue(self, value, asn1Spec, encodeFun, **options): + if value == 0: + substrate = (0,) + else: + substrate = (255,) + return substrate, False, False + + +class RealEncoder(encoder.RealEncoder): + def _chooseEncBase(self, value): + m, b, e = value + return self._dropFloatingPoint(m, b, e) + + +# specialized GeneralStringEncoder here + +class TimeEncoderMixIn(object): + Z_CHAR = ord('Z') + PLUS_CHAR = ord('+') + MINUS_CHAR = ord('-') + COMMA_CHAR = ord(',') + DOT_CHAR = ord('.') + ZERO_CHAR = ord('0') + + MIN_LENGTH = 12 + MAX_LENGTH = 19 + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + # CER encoding constraints: + # - minutes are mandatory, seconds are optional + # - sub-seconds must NOT be zero / no meaningless zeros + # - no hanging fraction dot + # - time in UTC (Z) + # - only dot is allowed for fractions + + if asn1Spec is not None: + value = asn1Spec.clone(value) + + numbers = value.asNumbers() + + if self.PLUS_CHAR in numbers or self.MINUS_CHAR in numbers: + raise error.PyAsn1Error('Must be UTC time: %r' % value) + + if numbers[-1] != self.Z_CHAR: + raise error.PyAsn1Error('Missing "Z" time zone specifier: %r' % value) + + if self.COMMA_CHAR in numbers: + raise error.PyAsn1Error('Comma in fractions disallowed: %r' % value) + + if self.DOT_CHAR in numbers: + + isModified = False + + numbers = list(numbers) + + searchIndex = min(numbers.index(self.DOT_CHAR) + 4, len(numbers) - 1) + + while numbers[searchIndex] != self.DOT_CHAR: + if numbers[searchIndex] == self.ZERO_CHAR: + del numbers[searchIndex] + isModified = True + + searchIndex -= 1 + + searchIndex += 1 + + if searchIndex < len(numbers): + if numbers[searchIndex] == self.Z_CHAR: + # drop hanging comma + del numbers[searchIndex - 1] + isModified = True + + if isModified: + value = value.clone(numbers) + + if not self.MIN_LENGTH < len(numbers) < self.MAX_LENGTH: + raise error.PyAsn1Error('Length constraint violated: %r' % value) + + options.update(maxChunkSize=1000) + + return encoder.OctetStringEncoder.encodeValue( + self, value, asn1Spec, encodeFun, **options + ) + + +class GeneralizedTimeEncoder(TimeEncoderMixIn, encoder.OctetStringEncoder): + MIN_LENGTH = 12 + MAX_LENGTH = 20 + + +class UTCTimeEncoder(TimeEncoderMixIn, encoder.OctetStringEncoder): + MIN_LENGTH = 10 + MAX_LENGTH = 14 + + +class SetOfEncoder(encoder.SequenceOfEncoder): + def encodeValue(self, value, asn1Spec, encodeFun, **options): + chunks = self._encodeComponents( + value, asn1Spec, encodeFun, **options) + + # sort by serialised and padded components + if len(chunks) > 1: + zero = b'\x00' + maxLen = max(map(len, chunks)) + paddedChunks = [ + (x.ljust(maxLen, zero), x) for x in chunks + ] + paddedChunks.sort(key=lambda x: x[0]) + + chunks = [x[1] for x in paddedChunks] + + return b''.join(chunks), True, True + + +class SequenceOfEncoder(encoder.SequenceOfEncoder): + def encodeValue(self, value, asn1Spec, encodeFun, **options): + + if options.get('ifNotEmpty', False) and not len(value): + return b'', True, True + + chunks = self._encodeComponents( + value, asn1Spec, encodeFun, **options) + + return b''.join(chunks), True, True + + +class SetEncoder(encoder.SequenceEncoder): + @staticmethod + def _componentSortKey(componentAndType): + """Sort SET components by tag + + Sort regardless of the Choice value (static sort) + """ + component, asn1Spec = componentAndType + + if asn1Spec is None: + asn1Spec = component + + if asn1Spec.typeId == univ.Choice.typeId and not asn1Spec.tagSet: + if asn1Spec.tagSet: + return asn1Spec.tagSet + else: + return asn1Spec.componentType.minTagSet + else: + return asn1Spec.tagSet + + def encodeValue(self, value, asn1Spec, encodeFun, **options): + + substrate = b'' + + comps = [] + compsMap = {} + + if asn1Spec is None: + # instance of ASN.1 schema + inconsistency = value.isInconsistent + if inconsistency: + raise error.PyAsn1Error( + f"ASN.1 object {value.__class__.__name__} is inconsistent") + + namedTypes = value.componentType + + for idx, component in enumerate(value.values()): + if namedTypes: + namedType = namedTypes[idx] + + if namedType.isOptional and not component.isValue: + continue + + if namedType.isDefaulted and component == namedType.asn1Object: + continue + + compsMap[id(component)] = namedType + + else: + compsMap[id(component)] = None + + comps.append((component, asn1Spec)) + + else: + # bare Python value + ASN.1 schema + for idx, namedType in enumerate(asn1Spec.componentType.namedTypes): + + try: + component = value[namedType.name] + + except KeyError: + raise error.PyAsn1Error('Component name "%s" not found in %r' % (namedType.name, value)) + + if namedType.isOptional and namedType.name not in value: + continue + + if namedType.isDefaulted and component == namedType.asn1Object: + continue + + compsMap[id(component)] = namedType + comps.append((component, asn1Spec[idx])) + + for comp, compType in sorted(comps, key=self._componentSortKey): + namedType = compsMap[id(comp)] + + if namedType: + options.update(ifNotEmpty=namedType.isOptional) + + chunk = encodeFun(comp, compType, **options) + + # wrap open type blob if needed + if namedType and namedType.openType: + wrapType = namedType.asn1Object + if wrapType.tagSet and not wrapType.isSameTypeWith(comp): + chunk = encodeFun(chunk, wrapType, **options) + + substrate += chunk + + return substrate, True, True + + +class SequenceEncoder(encoder.SequenceEncoder): + omitEmptyOptionals = True + + +TAG_MAP = encoder.TAG_MAP.copy() + +TAG_MAP.update({ + univ.Boolean.tagSet: BooleanEncoder(), + univ.Real.tagSet: RealEncoder(), + useful.GeneralizedTime.tagSet: GeneralizedTimeEncoder(), + useful.UTCTime.tagSet: UTCTimeEncoder(), + # Sequence & Set have same tags as SequenceOf & SetOf + univ.SetOf.tagSet: SetOfEncoder(), + univ.Sequence.typeId: SequenceEncoder() +}) + +TYPE_MAP = encoder.TYPE_MAP.copy() + +TYPE_MAP.update({ + univ.Boolean.typeId: BooleanEncoder(), + univ.Real.typeId: RealEncoder(), + useful.GeneralizedTime.typeId: GeneralizedTimeEncoder(), + useful.UTCTime.typeId: UTCTimeEncoder(), + # Sequence & Set have same tags as SequenceOf & SetOf + univ.Set.typeId: SetEncoder(), + univ.SetOf.typeId: SetOfEncoder(), + univ.Sequence.typeId: SequenceEncoder(), + univ.SequenceOf.typeId: SequenceOfEncoder() +}) + + +class SingleItemEncoder(encoder.SingleItemEncoder): + fixedDefLengthMode = False + fixedChunkSize = 1000 + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + +class Encoder(encoder.Encoder): + SINGLE_ITEM_ENCODER = SingleItemEncoder + + +#: Turns ASN.1 object into CER octet stream. +#: +#: Takes any ASN.1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: walks all its components recursively and produces a CER octet stream. +#: +#: Parameters +#: ---------- +#: value: either a Python or pyasn1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: A Python or pyasn1 object to encode. If Python object is given, `asnSpec` +#: parameter is required to guide the encoding process. +#: +#: Keyword Args +#: ------------ +#: asn1Spec: +#: Optional ASN.1 schema or value object e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: +#: Returns +#: ------- +#: : :py:class:`bytes` +#: Given ASN.1 object encoded into BER octet-stream +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error +#: On encoding errors +#: +#: Examples +#: -------- +#: Encode Python value into CER with ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> encode([1, 2, 3], asn1Spec=seq) +#: b'0\x80\x02\x01\x01\x02\x01\x02\x02\x01\x03\x00\x00' +#: +#: Encode ASN.1 value object into CER +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> seq.extend([1, 2, 3]) +#: >>> encode(seq) +#: b'0\x80\x02\x01\x01\x02\x01\x02\x02\x01\x03\x00\x00' +#: +encode = Encoder() + +# EncoderFactory queries class instance and builds a map of tags -> encoders + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/der/__init__.py b/module/scripts/lib/pyasn1/codec/der/__init__.py new file mode 100644 index 0000000..8c3066b --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/der/__init__.py @@ -0,0 +1 @@ +# This file is necessary to make this directory a package. diff --git a/module/scripts/lib/pyasn1/codec/der/decoder.py b/module/scripts/lib/pyasn1/codec/der/decoder.py new file mode 100644 index 0000000..2bef454 --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/der/decoder.py @@ -0,0 +1,120 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import warnings + +from pyasn1.codec.cer import decoder +from pyasn1.type import univ + +__all__ = ['decode', 'StreamingDecoder'] + + +class BitStringPayloadDecoder(decoder.BitStringPayloadDecoder): + supportConstructedForm = False + + +class OctetStringPayloadDecoder(decoder.OctetStringPayloadDecoder): + supportConstructedForm = False + + +# TODO: prohibit non-canonical encoding +RealPayloadDecoder = decoder.RealPayloadDecoder + +TAG_MAP = decoder.TAG_MAP.copy() +TAG_MAP.update( + {univ.BitString.tagSet: BitStringPayloadDecoder(), + univ.OctetString.tagSet: OctetStringPayloadDecoder(), + univ.Real.tagSet: RealPayloadDecoder()} +) + +TYPE_MAP = decoder.TYPE_MAP.copy() + +# Put in non-ambiguous types for faster codec lookup +for typeDecoder in TAG_MAP.values(): + if typeDecoder.protoComponent is not None: + typeId = typeDecoder.protoComponent.__class__.typeId + if typeId is not None and typeId not in TYPE_MAP: + TYPE_MAP[typeId] = typeDecoder + + +class SingleItemDecoder(decoder.SingleItemDecoder): + __doc__ = decoder.SingleItemDecoder.__doc__ + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + supportIndefLength = False + + +class StreamingDecoder(decoder.StreamingDecoder): + __doc__ = decoder.StreamingDecoder.__doc__ + + SINGLE_ITEM_DECODER = SingleItemDecoder + + +class Decoder(decoder.Decoder): + __doc__ = decoder.Decoder.__doc__ + + STREAMING_DECODER = StreamingDecoder + + +#: Turns DER octet stream into an ASN.1 object. +#: +#: Takes DER octet-stream and decode it into an ASN.1 object +#: (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) which +#: may be a scalar or an arbitrary nested structure. +#: +#: Parameters +#: ---------- +#: substrate: :py:class:`bytes` +#: DER octet-stream +#: +#: Keyword Args +#: ------------ +#: asn1Spec: any pyasn1 type object e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: A pyasn1 type object to act as a template guiding the decoder. Depending on the ASN.1 structure +#: being decoded, *asn1Spec* may or may not be required. Most common reason for +#: it to require is that ASN.1 structure is encoded in *IMPLICIT* tagging mode. +#: +#: Returns +#: ------- +#: : :py:class:`tuple` +#: A tuple of pyasn1 object recovered from DER substrate (:py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: and the unprocessed trailing portion of the *substrate* (may be empty) +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error, ~pyasn1.error.SubstrateUnderrunError +#: On decoding errors +#: +#: Examples +#: -------- +#: Decode DER serialisation without ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> s, _ = decode(b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03') +#: >>> str(s) +#: SequenceOf: +#: 1 2 3 +#: +#: Decode DER serialisation with ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> s, _ = decode(b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03', asn1Spec=seq) +#: >>> str(s) +#: SequenceOf: +#: 1 2 3 +#: +decode = Decoder() + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/der/encoder.py b/module/scripts/lib/pyasn1/codec/der/encoder.py new file mode 100644 index 0000000..8e00138 --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/der/encoder.py @@ -0,0 +1,126 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import warnings + +from pyasn1 import error +from pyasn1.codec.cer import encoder +from pyasn1.type import univ + +__all__ = ['Encoder', 'encode'] + + +class SetEncoder(encoder.SetEncoder): + @staticmethod + def _componentSortKey(componentAndType): + """Sort SET components by tag + + Sort depending on the actual Choice value (dynamic sort) + """ + component, asn1Spec = componentAndType + + if asn1Spec is None: + compType = component + else: + compType = asn1Spec + + if compType.typeId == univ.Choice.typeId and not compType.tagSet: + if asn1Spec is None: + return component.getComponent().tagSet + else: + # TODO: move out of sorting key function + names = [namedType.name for namedType in asn1Spec.componentType.namedTypes + if namedType.name in component] + if len(names) != 1: + raise error.PyAsn1Error( + '%s components for Choice at %r' % (len(names) and 'Multiple ' or 'None ', component)) + + # TODO: support nested CHOICE ordering + return asn1Spec[names[0]].tagSet + + else: + return compType.tagSet + + +TAG_MAP = encoder.TAG_MAP.copy() + +TAG_MAP.update({ + # Set & SetOf have same tags + univ.Set.tagSet: SetEncoder() +}) + +TYPE_MAP = encoder.TYPE_MAP.copy() + +TYPE_MAP.update({ + # Set & SetOf have same tags + univ.Set.typeId: SetEncoder() +}) + + +class SingleItemEncoder(encoder.SingleItemEncoder): + fixedDefLengthMode = True + fixedChunkSize = 0 + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + +class Encoder(encoder.Encoder): + SINGLE_ITEM_ENCODER = SingleItemEncoder + + +#: Turns ASN.1 object into DER octet stream. +#: +#: Takes any ASN.1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: walks all its components recursively and produces a DER octet stream. +#: +#: Parameters +#: ---------- +#: value: either a Python or pyasn1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: A Python or pyasn1 object to encode. If Python object is given, `asnSpec` +#: parameter is required to guide the encoding process. +#: +#: Keyword Args +#: ------------ +#: asn1Spec: +#: Optional ASN.1 schema or value object e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: +#: Returns +#: ------- +#: : :py:class:`bytes` +#: Given ASN.1 object encoded into BER octet-stream +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error +#: On encoding errors +#: +#: Examples +#: -------- +#: Encode Python value into DER with ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> encode([1, 2, 3], asn1Spec=seq) +#: b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03' +#: +#: Encode ASN.1 value object into DER +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> seq.extend([1, 2, 3]) +#: >>> encode(seq) +#: b'0\t\x02\x01\x01\x02\x01\x02\x02\x01\x03' +#: +encode = Encoder() + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/native/__init__.py b/module/scripts/lib/pyasn1/codec/native/__init__.py new file mode 100644 index 0000000..8c3066b --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/native/__init__.py @@ -0,0 +1 @@ +# This file is necessary to make this directory a package. diff --git a/module/scripts/lib/pyasn1/codec/native/decoder.py b/module/scripts/lib/pyasn1/codec/native/decoder.py new file mode 100644 index 0000000..881ce19 --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/native/decoder.py @@ -0,0 +1,244 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import warnings + +from pyasn1 import debug +from pyasn1 import error +from pyasn1.compat import _MISSING +from pyasn1.type import base +from pyasn1.type import char +from pyasn1.type import tag +from pyasn1.type import univ +from pyasn1.type import useful + +__all__ = ['decode'] + +LOG = debug.registerLoggee(__name__, flags=debug.DEBUG_DECODER) + + +class AbstractScalarPayloadDecoder(object): + def __call__(self, pyObject, asn1Spec, decodeFun=None, **options): + return asn1Spec.clone(pyObject) + + +class BitStringPayloadDecoder(AbstractScalarPayloadDecoder): + def __call__(self, pyObject, asn1Spec, decodeFun=None, **options): + return asn1Spec.clone(univ.BitString.fromBinaryString(pyObject)) + + +class SequenceOrSetPayloadDecoder(object): + def __call__(self, pyObject, asn1Spec, decodeFun=None, **options): + asn1Value = asn1Spec.clone() + + componentsTypes = asn1Spec.componentType + + for field in asn1Value: + if field in pyObject: + asn1Value[field] = decodeFun(pyObject[field], componentsTypes[field].asn1Object, **options) + + return asn1Value + + +class SequenceOfOrSetOfPayloadDecoder(object): + def __call__(self, pyObject, asn1Spec, decodeFun=None, **options): + asn1Value = asn1Spec.clone() + + for pyValue in pyObject: + asn1Value.append(decodeFun(pyValue, asn1Spec.componentType), **options) + + return asn1Value + + +class ChoicePayloadDecoder(object): + def __call__(self, pyObject, asn1Spec, decodeFun=None, **options): + asn1Value = asn1Spec.clone() + + componentsTypes = asn1Spec.componentType + + for field in pyObject: + if field in componentsTypes: + asn1Value[field] = decodeFun(pyObject[field], componentsTypes[field].asn1Object, **options) + break + + return asn1Value + + +TAG_MAP = { + univ.Integer.tagSet: AbstractScalarPayloadDecoder(), + univ.Boolean.tagSet: AbstractScalarPayloadDecoder(), + univ.BitString.tagSet: BitStringPayloadDecoder(), + univ.OctetString.tagSet: AbstractScalarPayloadDecoder(), + univ.Null.tagSet: AbstractScalarPayloadDecoder(), + univ.ObjectIdentifier.tagSet: AbstractScalarPayloadDecoder(), + univ.RelativeOID.tagSet: AbstractScalarPayloadDecoder(), + univ.Enumerated.tagSet: AbstractScalarPayloadDecoder(), + univ.Real.tagSet: AbstractScalarPayloadDecoder(), + univ.Sequence.tagSet: SequenceOrSetPayloadDecoder(), # conflicts with SequenceOf + univ.Set.tagSet: SequenceOrSetPayloadDecoder(), # conflicts with SetOf + univ.Choice.tagSet: ChoicePayloadDecoder(), # conflicts with Any + # character string types + char.UTF8String.tagSet: AbstractScalarPayloadDecoder(), + char.NumericString.tagSet: AbstractScalarPayloadDecoder(), + char.PrintableString.tagSet: AbstractScalarPayloadDecoder(), + char.TeletexString.tagSet: AbstractScalarPayloadDecoder(), + char.VideotexString.tagSet: AbstractScalarPayloadDecoder(), + char.IA5String.tagSet: AbstractScalarPayloadDecoder(), + char.GraphicString.tagSet: AbstractScalarPayloadDecoder(), + char.VisibleString.tagSet: AbstractScalarPayloadDecoder(), + char.GeneralString.tagSet: AbstractScalarPayloadDecoder(), + char.UniversalString.tagSet: AbstractScalarPayloadDecoder(), + char.BMPString.tagSet: AbstractScalarPayloadDecoder(), + # useful types + useful.ObjectDescriptor.tagSet: AbstractScalarPayloadDecoder(), + useful.GeneralizedTime.tagSet: AbstractScalarPayloadDecoder(), + useful.UTCTime.tagSet: AbstractScalarPayloadDecoder() +} + +# Put in ambiguous & non-ambiguous types for faster codec lookup +TYPE_MAP = { + univ.Integer.typeId: AbstractScalarPayloadDecoder(), + univ.Boolean.typeId: AbstractScalarPayloadDecoder(), + univ.BitString.typeId: BitStringPayloadDecoder(), + univ.OctetString.typeId: AbstractScalarPayloadDecoder(), + univ.Null.typeId: AbstractScalarPayloadDecoder(), + univ.ObjectIdentifier.typeId: AbstractScalarPayloadDecoder(), + univ.RelativeOID.typeId: AbstractScalarPayloadDecoder(), + univ.Enumerated.typeId: AbstractScalarPayloadDecoder(), + univ.Real.typeId: AbstractScalarPayloadDecoder(), + # ambiguous base types + univ.Set.typeId: SequenceOrSetPayloadDecoder(), + univ.SetOf.typeId: SequenceOfOrSetOfPayloadDecoder(), + univ.Sequence.typeId: SequenceOrSetPayloadDecoder(), + univ.SequenceOf.typeId: SequenceOfOrSetOfPayloadDecoder(), + univ.Choice.typeId: ChoicePayloadDecoder(), + univ.Any.typeId: AbstractScalarPayloadDecoder(), + # character string types + char.UTF8String.typeId: AbstractScalarPayloadDecoder(), + char.NumericString.typeId: AbstractScalarPayloadDecoder(), + char.PrintableString.typeId: AbstractScalarPayloadDecoder(), + char.TeletexString.typeId: AbstractScalarPayloadDecoder(), + char.VideotexString.typeId: AbstractScalarPayloadDecoder(), + char.IA5String.typeId: AbstractScalarPayloadDecoder(), + char.GraphicString.typeId: AbstractScalarPayloadDecoder(), + char.VisibleString.typeId: AbstractScalarPayloadDecoder(), + char.GeneralString.typeId: AbstractScalarPayloadDecoder(), + char.UniversalString.typeId: AbstractScalarPayloadDecoder(), + char.BMPString.typeId: AbstractScalarPayloadDecoder(), + # useful types + useful.ObjectDescriptor.typeId: AbstractScalarPayloadDecoder(), + useful.GeneralizedTime.typeId: AbstractScalarPayloadDecoder(), + useful.UTCTime.typeId: AbstractScalarPayloadDecoder() +} + + +class SingleItemDecoder(object): + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + def __init__(self, tagMap=_MISSING, typeMap=_MISSING, **ignored): + self._tagMap = tagMap if tagMap is not _MISSING else self.TAG_MAP + self._typeMap = typeMap if typeMap is not _MISSING else self.TYPE_MAP + + def __call__(self, pyObject, asn1Spec, **options): + + if LOG: + debug.scope.push(type(pyObject).__name__) + LOG('decoder called at scope %s, working with ' + 'type %s' % (debug.scope, type(pyObject).__name__)) + + if asn1Spec is None or not isinstance(asn1Spec, base.Asn1Item): + raise error.PyAsn1Error( + 'asn1Spec is not valid (should be an instance of an ASN.1 ' + 'Item, not %s)' % asn1Spec.__class__.__name__) + + try: + valueDecoder = self._typeMap[asn1Spec.typeId] + + except KeyError: + # use base type for codec lookup to recover untagged types + baseTagSet = tag.TagSet(asn1Spec.tagSet.baseTag, asn1Spec.tagSet.baseTag) + + try: + valueDecoder = self._tagMap[baseTagSet] + + except KeyError: + raise error.PyAsn1Error('Unknown ASN.1 tag %s' % asn1Spec.tagSet) + + if LOG: + LOG('calling decoder %s on Python type %s ' + '<%s>' % (type(valueDecoder).__name__, + type(pyObject).__name__, repr(pyObject))) + + value = valueDecoder(pyObject, asn1Spec, self, **options) + + if LOG: + LOG('decoder %s produced ASN.1 type %s ' + '<%s>' % (type(valueDecoder).__name__, + type(value).__name__, repr(value))) + debug.scope.pop() + + return value + + +class Decoder(object): + SINGLE_ITEM_DECODER = SingleItemDecoder + + def __init__(self, **options): + self._singleItemDecoder = self.SINGLE_ITEM_DECODER(**options) + + def __call__(self, pyObject, asn1Spec=None, **kwargs): + return self._singleItemDecoder(pyObject, asn1Spec=asn1Spec, **kwargs) + + +#: Turns Python objects of built-in types into ASN.1 objects. +#: +#: Takes Python objects of built-in types and turns them into a tree of +#: ASN.1 objects (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) which +#: may be a scalar or an arbitrary nested structure. +#: +#: Parameters +#: ---------- +#: pyObject: :py:class:`object` +#: A scalar or nested Python objects +#: +#: Keyword Args +#: ------------ +#: asn1Spec: any pyasn1 type object e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: A pyasn1 type object to act as a template guiding the decoder. It is required +#: for successful interpretation of Python objects mapping into their ASN.1 +#: representations. +#: +#: Returns +#: ------- +#: : :py:class:`~pyasn1.type.base.PyAsn1Item` derivative +#: A scalar or constructed pyasn1 object +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error +#: On decoding errors +#: +#: Examples +#: -------- +#: Decode native Python object into ASN.1 objects with ASN.1 schema +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> s, _ = decode([1, 2, 3], asn1Spec=seq) +#: >>> str(s) +#: SequenceOf: +#: 1 2 3 +#: +decode = Decoder() + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/native/encoder.py b/module/scripts/lib/pyasn1/codec/native/encoder.py new file mode 100644 index 0000000..ed5446b --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/native/encoder.py @@ -0,0 +1,285 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +from collections import OrderedDict +import warnings + +from pyasn1 import debug +from pyasn1 import error +from pyasn1.compat import _MISSING +from pyasn1.type import base +from pyasn1.type import char +from pyasn1.type import tag +from pyasn1.type import univ +from pyasn1.type import useful + +__all__ = ['encode'] + +LOG = debug.registerLoggee(__name__, flags=debug.DEBUG_ENCODER) + + +class AbstractItemEncoder(object): + def encode(self, value, encodeFun, **options): + raise error.PyAsn1Error('Not implemented') + + +class BooleanEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return bool(value) + + +class IntegerEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return int(value) + + +class BitStringEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return str(value) + + +class OctetStringEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return value.asOctets() + + +class TextStringEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return str(value) + + +class NullEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return None + + +class ObjectIdentifierEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return str(value) + + +class RelativeOIDEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return str(value) + + +class RealEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return float(value) + + +class SetEncoder(AbstractItemEncoder): + protoDict = dict + + def encode(self, value, encodeFun, **options): + inconsistency = value.isInconsistent + if inconsistency: + raise error.PyAsn1Error( + f"ASN.1 object {value.__class__.__name__} is inconsistent") + + namedTypes = value.componentType + substrate = self.protoDict() + + for idx, (key, subValue) in enumerate(value.items()): + if namedTypes and namedTypes[idx].isOptional and not value[idx].isValue: + continue + substrate[key] = encodeFun(subValue, **options) + return substrate + + +class SequenceEncoder(SetEncoder): + protoDict = OrderedDict + + +class SequenceOfEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + inconsistency = value.isInconsistent + if inconsistency: + raise error.PyAsn1Error( + f"ASN.1 object {value.__class__.__name__} is inconsistent") + return [encodeFun(x, **options) for x in value] + + +class ChoiceEncoder(SequenceEncoder): + pass + + +class AnyEncoder(AbstractItemEncoder): + def encode(self, value, encodeFun, **options): + return value.asOctets() + + +TAG_MAP = { + univ.Boolean.tagSet: BooleanEncoder(), + univ.Integer.tagSet: IntegerEncoder(), + univ.BitString.tagSet: BitStringEncoder(), + univ.OctetString.tagSet: OctetStringEncoder(), + univ.Null.tagSet: NullEncoder(), + univ.ObjectIdentifier.tagSet: ObjectIdentifierEncoder(), + univ.RelativeOID.tagSet: RelativeOIDEncoder(), + univ.Enumerated.tagSet: IntegerEncoder(), + univ.Real.tagSet: RealEncoder(), + # Sequence & Set have same tags as SequenceOf & SetOf + univ.SequenceOf.tagSet: SequenceOfEncoder(), + univ.SetOf.tagSet: SequenceOfEncoder(), + univ.Choice.tagSet: ChoiceEncoder(), + # character string types + char.UTF8String.tagSet: TextStringEncoder(), + char.NumericString.tagSet: TextStringEncoder(), + char.PrintableString.tagSet: TextStringEncoder(), + char.TeletexString.tagSet: TextStringEncoder(), + char.VideotexString.tagSet: TextStringEncoder(), + char.IA5String.tagSet: TextStringEncoder(), + char.GraphicString.tagSet: TextStringEncoder(), + char.VisibleString.tagSet: TextStringEncoder(), + char.GeneralString.tagSet: TextStringEncoder(), + char.UniversalString.tagSet: TextStringEncoder(), + char.BMPString.tagSet: TextStringEncoder(), + # useful types + useful.ObjectDescriptor.tagSet: OctetStringEncoder(), + useful.GeneralizedTime.tagSet: OctetStringEncoder(), + useful.UTCTime.tagSet: OctetStringEncoder() +} + +# Put in ambiguous & non-ambiguous types for faster codec lookup +TYPE_MAP = { + univ.Boolean.typeId: BooleanEncoder(), + univ.Integer.typeId: IntegerEncoder(), + univ.BitString.typeId: BitStringEncoder(), + univ.OctetString.typeId: OctetStringEncoder(), + univ.Null.typeId: NullEncoder(), + univ.ObjectIdentifier.typeId: ObjectIdentifierEncoder(), + univ.RelativeOID.typeId: RelativeOIDEncoder(), + univ.Enumerated.typeId: IntegerEncoder(), + univ.Real.typeId: RealEncoder(), + # Sequence & Set have same tags as SequenceOf & SetOf + univ.Set.typeId: SetEncoder(), + univ.SetOf.typeId: SequenceOfEncoder(), + univ.Sequence.typeId: SequenceEncoder(), + univ.SequenceOf.typeId: SequenceOfEncoder(), + univ.Choice.typeId: ChoiceEncoder(), + univ.Any.typeId: AnyEncoder(), + # character string types + char.UTF8String.typeId: OctetStringEncoder(), + char.NumericString.typeId: OctetStringEncoder(), + char.PrintableString.typeId: OctetStringEncoder(), + char.TeletexString.typeId: OctetStringEncoder(), + char.VideotexString.typeId: OctetStringEncoder(), + char.IA5String.typeId: OctetStringEncoder(), + char.GraphicString.typeId: OctetStringEncoder(), + char.VisibleString.typeId: OctetStringEncoder(), + char.GeneralString.typeId: OctetStringEncoder(), + char.UniversalString.typeId: OctetStringEncoder(), + char.BMPString.typeId: OctetStringEncoder(), + # useful types + useful.ObjectDescriptor.typeId: OctetStringEncoder(), + useful.GeneralizedTime.typeId: OctetStringEncoder(), + useful.UTCTime.typeId: OctetStringEncoder() +} + + +class SingleItemEncoder(object): + + TAG_MAP = TAG_MAP + TYPE_MAP = TYPE_MAP + + def __init__(self, tagMap=_MISSING, typeMap=_MISSING, **ignored): + self._tagMap = tagMap if tagMap is not _MISSING else self.TAG_MAP + self._typeMap = typeMap if typeMap is not _MISSING else self.TYPE_MAP + + def __call__(self, value, **options): + if not isinstance(value, base.Asn1Item): + raise error.PyAsn1Error( + 'value is not valid (should be an instance of an ASN.1 Item)') + + if LOG: + debug.scope.push(type(value).__name__) + LOG('encoder called for type %s ' + '<%s>' % (type(value).__name__, value.prettyPrint())) + + tagSet = value.tagSet + + try: + concreteEncoder = self._typeMap[value.typeId] + + except KeyError: + # use base type for codec lookup to recover untagged types + baseTagSet = tag.TagSet( + value.tagSet.baseTag, value.tagSet.baseTag) + + try: + concreteEncoder = self._tagMap[baseTagSet] + + except KeyError: + raise error.PyAsn1Error('No encoder for %s' % (value,)) + + if LOG: + LOG('using value codec %s chosen by ' + '%s' % (concreteEncoder.__class__.__name__, tagSet)) + + pyObject = concreteEncoder.encode(value, self, **options) + + if LOG: + LOG('encoder %s produced: ' + '%s' % (type(concreteEncoder).__name__, repr(pyObject))) + debug.scope.pop() + + return pyObject + + +class Encoder(object): + SINGLE_ITEM_ENCODER = SingleItemEncoder + + def __init__(self, **options): + self._singleItemEncoder = self.SINGLE_ITEM_ENCODER(**options) + + def __call__(self, pyObject, asn1Spec=None, **options): + return self._singleItemEncoder( + pyObject, asn1Spec=asn1Spec, **options) + + +#: Turns ASN.1 object into a Python built-in type object(s). +#: +#: Takes any ASN.1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: walks all its components recursively and produces a Python built-in type or a tree +#: of those. +#: +#: One exception is that instead of :py:class:`dict`, the :py:class:`OrderedDict` +#: is used to preserve ordering of the components in ASN.1 SEQUENCE. +#: +#: Parameters +#: ---------- +# asn1Value: any pyasn1 object (e.g. :py:class:`~pyasn1.type.base.PyAsn1Item` derivative) +#: pyasn1 object to encode (or a tree of them) +#: +#: Returns +#: ------- +#: : :py:class:`object` +#: Python built-in type instance (or a tree of them) +#: +#: Raises +#: ------ +#: ~pyasn1.error.PyAsn1Error +#: On encoding errors +#: +#: Examples +#: -------- +#: Encode ASN.1 value object into native Python types +#: +#: .. code-block:: pycon +#: +#: >>> seq = SequenceOf(componentType=Integer()) +#: >>> seq.extend([1, 2, 3]) +#: >>> encode(seq) +#: [1, 2, 3] +#: +encode = SingleItemEncoder() + +def __getattr__(attr: str): + if newAttr := {"tagMap": "TAG_MAP", "typeMap": "TYPE_MAP"}.get(attr): + warnings.warn(f"{attr} is deprecated. Please use {newAttr} instead.", DeprecationWarning, stacklevel=2) + return globals()[newAttr] + raise AttributeError(attr) diff --git a/module/scripts/lib/pyasn1/codec/streaming.py b/module/scripts/lib/pyasn1/codec/streaming.py new file mode 100644 index 0000000..c35f248 --- /dev/null +++ b/module/scripts/lib/pyasn1/codec/streaming.py @@ -0,0 +1,234 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2019, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import io +import os + +from pyasn1 import error +from pyasn1.type import univ + +class CachingStreamWrapper(io.IOBase): + """Wrapper around non-seekable streams. + + Note that the implementation is tied to the decoder, + not checking for dangerous arguments for the sake + of performance. + + The read bytes are kept in an internal cache until + setting _markedPosition which may reset the cache. + """ + def __init__(self, raw): + self._raw = raw + self._cache = io.BytesIO() + self._markedPosition = 0 + + def peek(self, n): + result = self.read(n) + self._cache.seek(-len(result), os.SEEK_CUR) + return result + + def seekable(self): + return True + + def seek(self, n=-1, whence=os.SEEK_SET): + # Note that this not safe for seeking forward. + return self._cache.seek(n, whence) + + def read(self, n=-1): + read_from_cache = self._cache.read(n) + if n != -1: + n -= len(read_from_cache) + if not n: # 0 bytes left to read + return read_from_cache + + read_from_raw = self._raw.read(n) + + self._cache.write(read_from_raw) + + return read_from_cache + read_from_raw + + @property + def markedPosition(self): + """Position where the currently processed element starts. + + This is used for back-tracking in SingleItemDecoder.__call__ + and (indefLen)ValueDecoder and should not be used for other purposes. + The client is not supposed to ever seek before this position. + """ + return self._markedPosition + + @markedPosition.setter + def markedPosition(self, value): + # By setting the value, we ensure we won't seek back before it. + # `value` should be the same as the current position + # We don't check for this for performance reasons. + self._markedPosition = value + + # Whenever we set _marked_position, we know for sure + # that we will not return back, and thus it is + # safe to drop all cached data. + if self._cache.tell() > io.DEFAULT_BUFFER_SIZE: + self._cache = io.BytesIO(self._cache.read()) + self._markedPosition = 0 + + def tell(self): + return self._cache.tell() + + +def asSeekableStream(substrate): + """Convert object to seekable byte-stream. + + Parameters + ---------- + substrate: :py:class:`bytes` or :py:class:`io.IOBase` or :py:class:`univ.OctetString` + + Returns + ------- + : :py:class:`io.IOBase` + + Raises + ------ + : :py:class:`~pyasn1.error.PyAsn1Error` + If the supplied substrate cannot be converted to a seekable stream. + """ + if isinstance(substrate, io.BytesIO): + return substrate + + elif isinstance(substrate, bytes): + return io.BytesIO(substrate) + + elif isinstance(substrate, univ.OctetString): + return io.BytesIO(substrate.asOctets()) + + try: + if substrate.seekable(): # Will fail for most invalid types + return substrate + else: + return CachingStreamWrapper(substrate) + + except AttributeError: + raise error.UnsupportedSubstrateError( + "Cannot convert " + substrate.__class__.__name__ + + " to a seekable bit stream.") + + +def isEndOfStream(substrate): + """Check whether we have reached the end of a stream. + + Although it is more effective to read and catch exceptions, this + function + + Parameters + ---------- + substrate: :py:class:`IOBase` + Stream to check + + Returns + ------- + : :py:class:`bool` + """ + if isinstance(substrate, io.BytesIO): + cp = substrate.tell() + substrate.seek(0, os.SEEK_END) + result = substrate.tell() == cp + substrate.seek(cp, os.SEEK_SET) + yield result + + else: + received = substrate.read(1) + if received is None: + yield + + if received: + substrate.seek(-1, os.SEEK_CUR) + + yield not received + + +def peekIntoStream(substrate, size=-1): + """Peek into stream. + + Parameters + ---------- + substrate: :py:class:`IOBase` + Stream to read from. + + size: :py:class:`int` + How many bytes to peek (-1 = all available) + + Returns + ------- + : :py:class:`bytes` or :py:class:`str` + The return type depends on Python major version + """ + if hasattr(substrate, "peek"): + received = substrate.peek(size) + if received is None: + yield + + while len(received) < size: + yield + + yield received + + else: + current_position = substrate.tell() + try: + for chunk in readFromStream(substrate, size): + yield chunk + + finally: + substrate.seek(current_position) + + +def readFromStream(substrate, size=-1, context=None): + """Read from the stream. + + Parameters + ---------- + substrate: :py:class:`IOBase` + Stream to read from. + + Keyword parameters + ------------------ + size: :py:class:`int` + How many bytes to read (-1 = all available) + + context: :py:class:`dict` + Opaque caller context will be attached to exception objects created + by this function. + + Yields + ------ + : :py:class:`bytes` or :py:class:`str` or :py:class:`SubstrateUnderrunError` + Read data or :py:class:`~pyasn1.error.SubstrateUnderrunError` + object if no `size` bytes is readily available in the stream. The + data type depends on Python major version + + Raises + ------ + : :py:class:`~pyasn1.error.EndOfStreamError` + Input stream is exhausted + """ + while True: + # this will block unless stream is non-blocking + received = substrate.read(size) + if received is None: # non-blocking stream can do this + yield error.SubstrateUnderrunError(context=context) + + elif not received and size != 0: # end-of-stream + raise error.EndOfStreamError(context=context) + + elif len(received) < size: + substrate.seek(-len(received), os.SEEK_CUR) + + # behave like a non-blocking stream + yield error.SubstrateUnderrunError(context=context) + + else: + break + + yield received diff --git a/module/scripts/lib/pyasn1/compat/__init__.py b/module/scripts/lib/pyasn1/compat/__init__.py new file mode 100644 index 0000000..d3e676a --- /dev/null +++ b/module/scripts/lib/pyasn1/compat/__init__.py @@ -0,0 +1,4 @@ +# This file is necessary to make this directory a package. + +# sentinal for missing argument +_MISSING = object() diff --git a/module/scripts/lib/pyasn1/compat/integer.py b/module/scripts/lib/pyasn1/compat/integer.py new file mode 100644 index 0000000..306210e --- /dev/null +++ b/module/scripts/lib/pyasn1/compat/integer.py @@ -0,0 +1,13 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +def to_bytes(value, signed=False, length=0): + length = max(value.bit_length(), length) + + if signed and length % 8 == 0: + length += 1 + + return value.to_bytes(length // 8 + (length % 8 and 1 or 0), 'big', signed=signed) diff --git a/module/scripts/lib/pyasn1/debug.py b/module/scripts/lib/pyasn1/debug.py new file mode 100644 index 0000000..0719423 --- /dev/null +++ b/module/scripts/lib/pyasn1/debug.py @@ -0,0 +1,146 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import logging +import sys + +from pyasn1 import __version__ +from pyasn1 import error + +__all__ = ['Debug', 'setLogger', 'hexdump'] + +DEBUG_NONE = 0x0000 +DEBUG_ENCODER = 0x0001 +DEBUG_DECODER = 0x0002 +DEBUG_ALL = 0xffff + +FLAG_MAP = { + 'none': DEBUG_NONE, + 'encoder': DEBUG_ENCODER, + 'decoder': DEBUG_DECODER, + 'all': DEBUG_ALL +} + +LOGGEE_MAP = {} + + +class Printer(object): + # noinspection PyShadowingNames + def __init__(self, logger=None, handler=None, formatter=None): + if logger is None: + logger = logging.getLogger('pyasn1') + + logger.setLevel(logging.DEBUG) + + if handler is None: + handler = logging.StreamHandler() + + if formatter is None: + formatter = logging.Formatter('%(asctime)s %(name)s: %(message)s') + + handler.setFormatter(formatter) + handler.setLevel(logging.DEBUG) + logger.addHandler(handler) + + self.__logger = logger + + def __call__(self, msg): + self.__logger.debug(msg) + + def __str__(self): + return '' + + +class Debug(object): + defaultPrinter = Printer() + + def __init__(self, *flags, **options): + self._flags = DEBUG_NONE + + if 'loggerName' in options: + # route our logs to parent logger + self._printer = Printer( + logger=logging.getLogger(options['loggerName']), + handler=logging.NullHandler() + ) + + elif 'printer' in options: + self._printer = options.get('printer') + + else: + self._printer = self.defaultPrinter + + self._printer('running pyasn1 %s, debug flags %s' % (__version__, ', '.join(flags))) + + for flag in flags: + inverse = flag and flag[0] in ('!', '~') + if inverse: + flag = flag[1:] + try: + if inverse: + self._flags &= ~FLAG_MAP[flag] + else: + self._flags |= FLAG_MAP[flag] + except KeyError: + raise error.PyAsn1Error('bad debug flag %s' % flag) + + self._printer("debug category '%s' %s" % (flag, inverse and 'disabled' or 'enabled')) + + def __str__(self): + return 'logger %s, flags %x' % (self._printer, self._flags) + + def __call__(self, msg): + self._printer(msg) + + def __and__(self, flag): + return self._flags & flag + + def __rand__(self, flag): + return flag & self._flags + +_LOG = DEBUG_NONE + + +def setLogger(userLogger): + global _LOG + + if userLogger: + _LOG = userLogger + else: + _LOG = DEBUG_NONE + + # Update registered logging clients + for module, (name, flags) in LOGGEE_MAP.items(): + setattr(module, name, _LOG & flags and _LOG or DEBUG_NONE) + + +def registerLoggee(module, name='LOG', flags=DEBUG_NONE): + LOGGEE_MAP[sys.modules[module]] = name, flags + setLogger(_LOG) + return _LOG + + +def hexdump(octets): + return ' '.join( + ['%s%.2X' % (n % 16 == 0 and ('\n%.5d: ' % n) or '', x) + for n, x in zip(range(len(octets)), octets)] + ) + + +class Scope(object): + def __init__(self): + self._list = [] + + def __str__(self): return '.'.join(self._list) + + def push(self, token): + self._list.append(token) + + def pop(self): + return self._list.pop() + + +scope = Scope() diff --git a/module/scripts/lib/pyasn1/error.py b/module/scripts/lib/pyasn1/error.py new file mode 100644 index 0000000..75c9a3f --- /dev/null +++ b/module/scripts/lib/pyasn1/error.py @@ -0,0 +1,116 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# + + +class PyAsn1Error(Exception): + """Base pyasn1 exception + + `PyAsn1Error` is the base exception class (based on + :class:`Exception`) that represents all possible ASN.1 related + errors. + + Parameters + ---------- + args: + Opaque positional parameters + + Keyword Args + ------------ + kwargs: + Opaque keyword parameters + + """ + def __init__(self, *args, **kwargs): + self._args = args + self._kwargs = kwargs + + @property + def context(self): + """Return exception context + + When exception object is created, the caller can supply some opaque + context for the upper layers to better understand the cause of the + exception. + + Returns + ------- + : :py:class:`dict` + Dict holding context specific data + """ + return self._kwargs.get('context', {}) + + +class ValueConstraintError(PyAsn1Error): + """ASN.1 type constraints violation exception + + The `ValueConstraintError` exception indicates an ASN.1 value + constraint violation. + + It might happen on value object instantiation (for scalar types) or on + serialization (for constructed types). + """ + + +class SubstrateUnderrunError(PyAsn1Error): + """ASN.1 data structure deserialization error + + The `SubstrateUnderrunError` exception indicates insufficient serialised + data on input of a de-serialization codec. + """ + + +class EndOfStreamError(SubstrateUnderrunError): + """ASN.1 data structure deserialization error + + The `EndOfStreamError` exception indicates the condition of the input + stream has been closed. + """ + + +class UnsupportedSubstrateError(PyAsn1Error): + """Unsupported substrate type to parse as ASN.1 data.""" + + +class PyAsn1UnicodeError(PyAsn1Error, UnicodeError): + """Unicode text processing error + + The `PyAsn1UnicodeError` exception is a base class for errors relating to + unicode text de/serialization. + + Apart from inheriting from :class:`PyAsn1Error`, it also inherits from + :class:`UnicodeError` to help the caller catching unicode-related errors. + """ + def __init__(self, message, unicode_error=None): + if isinstance(unicode_error, UnicodeError): + UnicodeError.__init__(self, *unicode_error.args) + PyAsn1Error.__init__(self, message) + + +class PyAsn1UnicodeDecodeError(PyAsn1UnicodeError, UnicodeDecodeError): + """Unicode text decoding error + + The `PyAsn1UnicodeDecodeError` exception represents a failure to + deserialize unicode text. + + Apart from inheriting from :class:`PyAsn1UnicodeError`, it also inherits + from :class:`UnicodeDecodeError` to help the caller catching unicode-related + errors. + """ + + +class PyAsn1UnicodeEncodeError(PyAsn1UnicodeError, UnicodeEncodeError): + """Unicode text encoding error + + The `PyAsn1UnicodeEncodeError` exception represents a failure to + serialize unicode text. + + Apart from inheriting from :class:`PyAsn1UnicodeError`, it also inherits + from :class:`UnicodeEncodeError` to help the caller catching + unicode-related errors. + """ + + diff --git a/module/scripts/lib/pyasn1/type/__init__.py b/module/scripts/lib/pyasn1/type/__init__.py new file mode 100644 index 0000000..8c3066b --- /dev/null +++ b/module/scripts/lib/pyasn1/type/__init__.py @@ -0,0 +1 @@ +# This file is necessary to make this directory a package. diff --git a/module/scripts/lib/pyasn1/type/base.py b/module/scripts/lib/pyasn1/type/base.py new file mode 100644 index 0000000..aa86e52 --- /dev/null +++ b/module/scripts/lib/pyasn1/type/base.py @@ -0,0 +1,699 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import sys + +from pyasn1 import error +from pyasn1.type import constraint +from pyasn1.type import tag +from pyasn1.type import tagmap + +__all__ = ['Asn1Item', 'Asn1Type', 'SimpleAsn1Type', + 'ConstructedAsn1Type'] + + +class Asn1Item(object): + @classmethod + def getTypeId(cls, increment=1): + try: + Asn1Item._typeCounter += increment + except AttributeError: + Asn1Item._typeCounter = increment + return Asn1Item._typeCounter + + +class Asn1Type(Asn1Item): + """Base class for all classes representing ASN.1 types. + + In the user code, |ASN.1| class is normally used only for telling + ASN.1 objects from others. + + Note + ---- + For as long as ASN.1 is concerned, a way to compare ASN.1 types + is to use :meth:`isSameTypeWith` and :meth:`isSuperTypeOf` methods. + """ + #: Set or return a :py:class:`~pyasn1.type.tag.TagSet` object representing + #: ASN.1 tag(s) associated with |ASN.1| type. + tagSet = tag.TagSet() + + #: Default :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + #: object imposing constraints on initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Disambiguation ASN.1 types identification + typeId = None + + def __init__(self, **kwargs): + readOnly = { + 'tagSet': self.tagSet, + 'subtypeSpec': self.subtypeSpec + } + + readOnly.update(kwargs) + + self.__dict__.update(readOnly) + + self._readOnly = readOnly + + def __setattr__(self, name, value): + if name[0] != '_' and name in self._readOnly: + raise error.PyAsn1Error('read-only instance attribute "%s"' % name) + + self.__dict__[name] = value + + def __str__(self): + return self.prettyPrint() + + @property + def readOnly(self): + return self._readOnly + + @property + def effectiveTagSet(self): + """For |ASN.1| type is equivalent to *tagSet* + """ + return self.tagSet # used by untagged types + + @property + def tagMap(self): + """Return a :class:`~pyasn1.type.tagmap.TagMap` object mapping ASN.1 tags to ASN.1 objects within callee object. + """ + return tagmap.TagMap({self.tagSet: self}) + + def isSameTypeWith(self, other, matchTags=True, matchConstraints=True): + """Examine |ASN.1| type for equality with other ASN.1 type. + + ASN.1 tags (:py:mod:`~pyasn1.type.tag`) and constraints + (:py:mod:`~pyasn1.type.constraint`) are examined when carrying + out ASN.1 types comparison. + + Python class inheritance relationship is NOT considered. + + Parameters + ---------- + other: a pyasn1 type object + Class instance representing ASN.1 type. + + Returns + ------- + : :class:`bool` + :obj:`True` if *other* is |ASN.1| type, + :obj:`False` otherwise. + """ + return (self is other or + (not matchTags or self.tagSet == other.tagSet) and + (not matchConstraints or self.subtypeSpec == other.subtypeSpec)) + + def isSuperTypeOf(self, other, matchTags=True, matchConstraints=True): + """Examine |ASN.1| type for subtype relationship with other ASN.1 type. + + ASN.1 tags (:py:mod:`~pyasn1.type.tag`) and constraints + (:py:mod:`~pyasn1.type.constraint`) are examined when carrying + out ASN.1 types comparison. + + Python class inheritance relationship is NOT considered. + + Parameters + ---------- + other: a pyasn1 type object + Class instance representing ASN.1 type. + + Returns + ------- + : :class:`bool` + :obj:`True` if *other* is a subtype of |ASN.1| type, + :obj:`False` otherwise. + """ + return (not matchTags or + (self.tagSet.isSuperTagSetOf(other.tagSet)) and + (not matchConstraints or self.subtypeSpec.isSuperTypeOf(other.subtypeSpec))) + + @staticmethod + def isNoValue(*values): + for value in values: + if value is not noValue: + return False + return True + + def prettyPrint(self, scope=0): + raise NotImplementedError + + # backward compatibility + + def getTagSet(self): + return self.tagSet + + def getEffectiveTagSet(self): + return self.effectiveTagSet + + def getTagMap(self): + return self.tagMap + + def getSubtypeSpec(self): + return self.subtypeSpec + + # backward compatibility + def hasValue(self): + return self.isValue + +# Backward compatibility +Asn1ItemBase = Asn1Type + + +class NoValue(object): + """Create a singleton instance of NoValue class. + + The *NoValue* sentinel object represents an instance of ASN.1 schema + object as opposed to ASN.1 value object. + + Only ASN.1 schema-related operations can be performed on ASN.1 + schema objects. + + Warning + ------- + Any operation attempted on the *noValue* object will raise the + *PyAsn1Error* exception. + """ + skipMethods = { + '__slots__', + # attributes + '__getattribute__', + '__getattr__', + '__setattr__', + '__delattr__', + # class instance + '__class__', + '__init__', + '__del__', + '__new__', + '__repr__', + '__qualname__', + '__objclass__', + 'im_class', + '__sizeof__', + # pickle protocol + '__reduce__', + '__reduce_ex__', + '__getnewargs__', + '__getinitargs__', + '__getstate__', + '__setstate__', + } + + _instance = None + + def __new__(cls): + if cls._instance is None: + def getPlug(name): + def plug(self, *args, **kw): + raise error.PyAsn1Error('Attempted "%s" operation on ASN.1 schema object' % name) + return plug + + op_names = [name + for typ in (str, int, list, dict) + for name in dir(typ) + if (name not in cls.skipMethods and + name.startswith('__') and + name.endswith('__') and + callable(getattr(typ, name)))] + + for name in set(op_names): + setattr(cls, name, getPlug(name)) + + cls._instance = object.__new__(cls) + + return cls._instance + + def __getattr__(self, attr): + if attr in self.skipMethods: + raise AttributeError('Attribute %s not present' % attr) + + raise error.PyAsn1Error('Attempted "%s" operation on ASN.1 schema object' % attr) + + def __repr__(self): + return '<%s object>' % self.__class__.__name__ + + +noValue = NoValue() + + +class SimpleAsn1Type(Asn1Type): + """Base class for all simple classes representing ASN.1 types. + + ASN.1 distinguishes types by their ability to hold other objects. + Scalar types are known as *simple* in ASN.1. + + In the user code, |ASN.1| class is normally used only for telling + ASN.1 objects from others. + + Note + ---- + For as long as ASN.1 is concerned, a way to compare ASN.1 types + is to use :meth:`isSameTypeWith` and :meth:`isSuperTypeOf` methods. + """ + #: Default payload value + defaultValue = noValue + + def __init__(self, value=noValue, **kwargs): + Asn1Type.__init__(self, **kwargs) + if value is noValue: + value = self.defaultValue + else: + value = self.prettyIn(value) + try: + self.subtypeSpec(value) + + except error.PyAsn1Error as exValue: + raise type(exValue)('%s at %s' % (exValue, self.__class__.__name__)) + + self._value = value + + def __repr__(self): + representation = '%s %s object' % ( + self.__class__.__name__, self.isValue and 'value' or 'schema') + + for attr, value in self.readOnly.items(): + if value: + representation += ', %s %s' % (attr, value) + + if self.isValue: + value = self.prettyPrint() + if len(value) > 32: + value = value[:16] + '...' + value[-16:] + representation += ', payload [%s]' % value + + return '<%s>' % representation + + def __eq__(self, other): + if self is other: + return True + return self._value == other + + def __ne__(self, other): + return self._value != other + + def __lt__(self, other): + return self._value < other + + def __le__(self, other): + return self._value <= other + + def __gt__(self, other): + return self._value > other + + def __ge__(self, other): + return self._value >= other + + def __bool__(self): + return bool(self._value) + + def __hash__(self): + return hash(self._value) + + @property + def isValue(self): + """Indicate that |ASN.1| object represents ASN.1 value. + + If *isValue* is :obj:`False` then this object represents just + ASN.1 schema. + + If *isValue* is :obj:`True` then, in addition to its ASN.1 schema + features, this object can also be used like a Python built-in object + (e.g. :class:`int`, :class:`str`, :class:`dict` etc.). + + Returns + ------- + : :class:`bool` + :obj:`False` if object represents just ASN.1 schema. + :obj:`True` if object represents ASN.1 schema and can be used as a normal value. + + Note + ---- + There is an important distinction between PyASN1 schema and value objects. + The PyASN1 schema objects can only participate in ASN.1 schema-related + operations (e.g. defining or testing the structure of the data). Most + obvious uses of ASN.1 schema is to guide serialisation codecs whilst + encoding/decoding serialised ASN.1 contents. + + The PyASN1 value objects can **additionally** participate in many operations + involving regular Python objects (e.g. arithmetic, comprehension etc). + """ + return self._value is not noValue + + def clone(self, value=noValue, **kwargs): + """Create a modified version of |ASN.1| schema or value object. + + The `clone()` method accepts the same set arguments as |ASN.1| + class takes on instantiation except that all arguments + of the `clone()` method are optional. + + Whatever arguments are supplied, they are used to create a copy + of `self` taking precedence over the ones used to instantiate `self`. + + Note + ---- + Due to the immutable nature of the |ASN.1| object, if no arguments + are supplied, no new |ASN.1| object will be created and `self` will + be returned instead. + """ + if value is noValue: + if not kwargs: + return self + + value = self._value + + initializers = self.readOnly.copy() + initializers.update(kwargs) + + return self.__class__(value, **initializers) + + def subtype(self, value=noValue, **kwargs): + """Create a specialization of |ASN.1| schema or value object. + + The subtype relationship between ASN.1 types has no correlation with + subtype relationship between Python types. ASN.1 type is mainly identified + by its tag(s) (:py:class:`~pyasn1.type.tag.TagSet`) and value range + constraints (:py:class:`~pyasn1.type.constraint.ConstraintsIntersection`). + These ASN.1 type properties are implemented as |ASN.1| attributes. + + The `subtype()` method accepts the same set arguments as |ASN.1| + class takes on instantiation except that all parameters + of the `subtype()` method are optional. + + With the exception of the arguments described below, the rest of + supplied arguments they are used to create a copy of `self` taking + precedence over the ones used to instantiate `self`. + + The following arguments to `subtype()` create a ASN.1 subtype out of + |ASN.1| type: + + Other Parameters + ---------------- + implicitTag: :py:class:`~pyasn1.type.tag.Tag` + Implicitly apply given ASN.1 tag object to `self`'s + :py:class:`~pyasn1.type.tag.TagSet`, then use the result as + new object's ASN.1 tag(s). + + explicitTag: :py:class:`~pyasn1.type.tag.Tag` + Explicitly apply given ASN.1 tag object to `self`'s + :py:class:`~pyasn1.type.tag.TagSet`, then use the result as + new object's ASN.1 tag(s). + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Add ASN.1 constraints object to one of the `self`'s, then + use the result as new object's ASN.1 constraints. + + Returns + ------- + : + new instance of |ASN.1| schema or value object + + Note + ---- + Due to the immutable nature of the |ASN.1| object, if no arguments + are supplied, no new |ASN.1| object will be created and `self` will + be returned instead. + """ + if value is noValue: + if not kwargs: + return self + + value = self._value + + initializers = self.readOnly.copy() + + implicitTag = kwargs.pop('implicitTag', None) + if implicitTag is not None: + initializers['tagSet'] = self.tagSet.tagImplicitly(implicitTag) + + explicitTag = kwargs.pop('explicitTag', None) + if explicitTag is not None: + initializers['tagSet'] = self.tagSet.tagExplicitly(explicitTag) + + for arg, option in kwargs.items(): + initializers[arg] += option + + return self.__class__(value, **initializers) + + def prettyIn(self, value): + return value + + def prettyOut(self, value): + return str(value) + + def prettyPrint(self, scope=0): + return self.prettyOut(self._value) + + def prettyPrintType(self, scope=0): + return '%s -> %s' % (self.tagSet, self.__class__.__name__) + +# Backward compatibility +AbstractSimpleAsn1Item = SimpleAsn1Type + +# +# Constructed types: +# * There are five of them: Sequence, SequenceOf/SetOf, Set and Choice +# * ASN1 types and values are represened by Python class instances +# * Value initialization is made for defaulted components only +# * Primary method of component addressing is by-position. Data model for base +# type is Python sequence. Additional type-specific addressing methods +# may be implemented for particular types. +# * SequenceOf and SetOf types do not implement any additional methods +# * Sequence, Set and Choice types also implement by-identifier addressing +# * Sequence, Set and Choice types also implement by-asn1-type (tag) addressing +# * Sequence and Set types may include optional and defaulted +# components +# * Constructed types hold a reference to component types used for value +# verification and ordering. +# * Component type is a scalar type for SequenceOf/SetOf types and a list +# of types for Sequence/Set/Choice. +# + + +class ConstructedAsn1Type(Asn1Type): + """Base class for all constructed classes representing ASN.1 types. + + ASN.1 distinguishes types by their ability to hold other objects. + Those "nesting" types are known as *constructed* in ASN.1. + + In the user code, |ASN.1| class is normally used only for telling + ASN.1 objects from others. + + Note + ---- + For as long as ASN.1 is concerned, a way to compare ASN.1 types + is to use :meth:`isSameTypeWith` and :meth:`isSuperTypeOf` methods. + """ + + #: If :obj:`True`, requires exact component type matching, + #: otherwise subtype relation is only enforced + strictConstraints = False + + componentType = None + + # backward compatibility, unused + sizeSpec = constraint.ConstraintsIntersection() + + def __init__(self, **kwargs): + readOnly = { + 'componentType': self.componentType, + # backward compatibility, unused + 'sizeSpec': self.sizeSpec + } + + # backward compatibility: preserve legacy sizeSpec support + kwargs = self._moveSizeSpec(**kwargs) + + readOnly.update(kwargs) + + Asn1Type.__init__(self, **readOnly) + + def _moveSizeSpec(self, **kwargs): + # backward compatibility, unused + sizeSpec = kwargs.pop('sizeSpec', self.sizeSpec) + if sizeSpec: + subtypeSpec = kwargs.pop('subtypeSpec', self.subtypeSpec) + if subtypeSpec: + subtypeSpec = sizeSpec + + else: + subtypeSpec += sizeSpec + + kwargs['subtypeSpec'] = subtypeSpec + + return kwargs + + def __repr__(self): + representation = '%s %s object' % ( + self.__class__.__name__, self.isValue and 'value' or 'schema' + ) + + for attr, value in self.readOnly.items(): + if value is not noValue: + representation += ', %s=%r' % (attr, value) + + if self.isValue and self.components: + representation += ', payload [%s]' % ', '.join( + [repr(x) for x in self.components]) + + return '<%s>' % representation + + def __eq__(self, other): + return self is other or self.components == other + + def __ne__(self, other): + return self.components != other + + def __lt__(self, other): + return self.components < other + + def __le__(self, other): + return self.components <= other + + def __gt__(self, other): + return self.components > other + + def __ge__(self, other): + return self.components >= other + + def __bool__(self): + return bool(self.components) + + @property + def components(self): + raise error.PyAsn1Error('Method not implemented') + + def _cloneComponentValues(self, myClone, cloneValueFlag): + pass + + def clone(self, **kwargs): + """Create a modified version of |ASN.1| schema object. + + The `clone()` method accepts the same set arguments as |ASN.1| + class takes on instantiation except that all arguments + of the `clone()` method are optional. + + Whatever arguments are supplied, they are used to create a copy + of `self` taking precedence over the ones used to instantiate `self`. + + Possible values of `self` are never copied over thus `clone()` can + only create a new schema object. + + Returns + ------- + : + new instance of |ASN.1| type/value + + Note + ---- + Due to the mutable nature of the |ASN.1| object, even if no arguments + are supplied, a new |ASN.1| object will be created and returned. + """ + cloneValueFlag = kwargs.pop('cloneValueFlag', False) + + initializers = self.readOnly.copy() + initializers.update(kwargs) + + clone = self.__class__(**initializers) + + if cloneValueFlag: + self._cloneComponentValues(clone, cloneValueFlag) + + return clone + + def subtype(self, **kwargs): + """Create a specialization of |ASN.1| schema object. + + The `subtype()` method accepts the same set arguments as |ASN.1| + class takes on instantiation except that all parameters + of the `subtype()` method are optional. + + With the exception of the arguments described below, the rest of + supplied arguments they are used to create a copy of `self` taking + precedence over the ones used to instantiate `self`. + + The following arguments to `subtype()` create a ASN.1 subtype out of + |ASN.1| type. + + Other Parameters + ---------------- + implicitTag: :py:class:`~pyasn1.type.tag.Tag` + Implicitly apply given ASN.1 tag object to `self`'s + :py:class:`~pyasn1.type.tag.TagSet`, then use the result as + new object's ASN.1 tag(s). + + explicitTag: :py:class:`~pyasn1.type.tag.Tag` + Explicitly apply given ASN.1 tag object to `self`'s + :py:class:`~pyasn1.type.tag.TagSet`, then use the result as + new object's ASN.1 tag(s). + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Add ASN.1 constraints object to one of the `self`'s, then + use the result as new object's ASN.1 constraints. + + + Returns + ------- + : + new instance of |ASN.1| type/value + + Note + ---- + Due to the mutable nature of the |ASN.1| object, even if no arguments + are supplied, a new |ASN.1| object will be created and returned. + """ + + initializers = self.readOnly.copy() + + cloneValueFlag = kwargs.pop('cloneValueFlag', False) + + implicitTag = kwargs.pop('implicitTag', None) + if implicitTag is not None: + initializers['tagSet'] = self.tagSet.tagImplicitly(implicitTag) + + explicitTag = kwargs.pop('explicitTag', None) + if explicitTag is not None: + initializers['tagSet'] = self.tagSet.tagExplicitly(explicitTag) + + for arg, option in kwargs.items(): + initializers[arg] += option + + clone = self.__class__(**initializers) + + if cloneValueFlag: + self._cloneComponentValues(clone, cloneValueFlag) + + return clone + + def getComponentByPosition(self, idx): + raise error.PyAsn1Error('Method not implemented') + + def setComponentByPosition(self, idx, value, verifyConstraints=True): + raise error.PyAsn1Error('Method not implemented') + + def setComponents(self, *args, **kwargs): + for idx, value in enumerate(args): + self[idx] = value + for k in kwargs: + self[k] = kwargs[k] + return self + + # backward compatibility + + def setDefaultComponents(self): + pass + + def getComponentType(self): + return self.componentType + + # backward compatibility, unused + def verifySizeSpec(self): + self.subtypeSpec(self) + + + # Backward compatibility +AbstractConstructedAsn1Item = ConstructedAsn1Type diff --git a/module/scripts/lib/pyasn1/type/char.py b/module/scripts/lib/pyasn1/type/char.py new file mode 100644 index 0000000..ec65f00 --- /dev/null +++ b/module/scripts/lib/pyasn1/type/char.py @@ -0,0 +1,288 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import sys + +from pyasn1 import error +from pyasn1.type import tag +from pyasn1.type import univ + +__all__ = ['NumericString', 'PrintableString', 'TeletexString', 'T61String', 'VideotexString', + 'IA5String', 'GraphicString', 'VisibleString', 'ISO646String', + 'GeneralString', 'UniversalString', 'BMPString', 'UTF8String'] + +NoValue = univ.NoValue +noValue = univ.noValue + + +class AbstractCharacterString(univ.OctetString): + """Creates |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, + its objects are immutable and duck-type :class:`bytes`. + When used in octet-stream context, |ASN.1| type assumes + "|encoding|" encoding. + + Keyword Args + ------------ + value: :class:`str`, :class:`bytes` or |ASN.1| object + :class:`str`, alternatively :class:`bytes` + representing octet-stream of serialised unicode string + (note `encoding` parameter) or |ASN.1| class instance. + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + encoding: :py:class:`str` + Unicode codec ID to encode/decode + :class:`str` the payload when |ASN.1| object is used + in octet-stream context. + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + """ + + def __str__(self): + return str(self._value) + + def __bytes__(self): + try: + return self._value.encode(self.encoding) + except UnicodeEncodeError as exc: + raise error.PyAsn1UnicodeEncodeError( + "Can't encode string '%s' with codec " + "%s" % (self._value, self.encoding), exc + ) + + def prettyIn(self, value): + try: + if isinstance(value, str): + return value + elif isinstance(value, bytes): + return value.decode(self.encoding) + elif isinstance(value, (tuple, list)): + return self.prettyIn(bytes(value)) + elif isinstance(value, univ.OctetString): + return value.asOctets().decode(self.encoding) + else: + return str(value) + + except (UnicodeDecodeError, LookupError) as exc: + raise error.PyAsn1UnicodeDecodeError( + "Can't decode string '%s' with codec " + "%s" % (value, self.encoding), exc + ) + + def asOctets(self, padding=True): + return bytes(self) + + def asNumbers(self, padding=True): + return tuple(bytes(self)) + + # + # See OctetString.prettyPrint() for the explanation + # + + def prettyOut(self, value): + return value + + def prettyPrint(self, scope=0): + # first see if subclass has its own .prettyOut() + value = self.prettyOut(self._value) + + if value is not self._value: + return value + + return AbstractCharacterString.__str__(self) + + def __reversed__(self): + return reversed(self._value) + + +class NumericString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 18) + ) + encoding = 'us-ascii' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class PrintableString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 19) + ) + encoding = 'us-ascii' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class TeletexString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 20) + ) + encoding = 'iso-8859-1' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class T61String(TeletexString): + __doc__ = TeletexString.__doc__ + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class VideotexString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 21) + ) + encoding = 'iso-8859-1' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class IA5String(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 22) + ) + encoding = 'us-ascii' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class GraphicString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 25) + ) + encoding = 'iso-8859-1' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class VisibleString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 26) + ) + encoding = 'us-ascii' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class ISO646String(VisibleString): + __doc__ = VisibleString.__doc__ + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + +class GeneralString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 27) + ) + encoding = 'iso-8859-1' + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class UniversalString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 28) + ) + encoding = "utf-32-be" + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class BMPString(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 30) + ) + encoding = "utf-16-be" + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() + + +class UTF8String(AbstractCharacterString): + __doc__ = AbstractCharacterString.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = AbstractCharacterString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 12) + ) + encoding = "utf-8" + + # Optimization for faster codec lookup + typeId = AbstractCharacterString.getTypeId() diff --git a/module/scripts/lib/pyasn1/type/constraint.py b/module/scripts/lib/pyasn1/type/constraint.py new file mode 100644 index 0000000..02368d0 --- /dev/null +++ b/module/scripts/lib/pyasn1/type/constraint.py @@ -0,0 +1,751 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +# Original concept and code by Mike C. Fletcher. +# +import sys + +from pyasn1.type import error + +__all__ = ['SingleValueConstraint', 'ContainedSubtypeConstraint', + 'ValueRangeConstraint', 'ValueSizeConstraint', + 'PermittedAlphabetConstraint', 'InnerTypeConstraint', + 'ConstraintsExclusion', 'ConstraintsIntersection', + 'ConstraintsUnion'] + + +class AbstractConstraint(object): + + def __init__(self, *values): + self._valueMap = set() + self._setValues(values) + self.__hash = hash((self.__class__.__name__, self._values)) + + def __call__(self, value, idx=None): + if not self._values: + return + + try: + self._testValue(value, idx) + + except error.ValueConstraintError as exc: + raise error.ValueConstraintError( + '%s failed at: %r' % (self, exc) + ) + + def __repr__(self): + representation = '%s object' % (self.__class__.__name__) + + if self._values: + representation += ', consts %s' % ', '.join( + [repr(x) for x in self._values]) + + return '<%s>' % representation + + def __eq__(self, other): + if self is other: + return True + return self._values == other + + def __ne__(self, other): + return self._values != other + + def __lt__(self, other): + return self._values < other + + def __le__(self, other): + return self._values <= other + + def __gt__(self, other): + return self._values > other + + def __ge__(self, other): + return self._values >= other + + def __bool__(self): + return bool(self._values) + + def __hash__(self): + return self.__hash + + def _setValues(self, values): + self._values = values + + def _testValue(self, value, idx): + raise error.ValueConstraintError(value) + + # Constraints derivation logic + def getValueMap(self): + return self._valueMap + + def isSuperTypeOf(self, otherConstraint): + # TODO: fix possible comparison of set vs scalars here + return (otherConstraint is self or + not self._values or + otherConstraint == self or + self in otherConstraint.getValueMap()) + + def isSubTypeOf(self, otherConstraint): + return (otherConstraint is self or + not self or + otherConstraint == self or + otherConstraint in self._valueMap) + + +class SingleValueConstraint(AbstractConstraint): + """Create a SingleValueConstraint object. + + The SingleValueConstraint satisfies any value that + is present in the set of permitted values. + + Objects of this type are iterable (emitting constraint values) and + can act as operands for some arithmetic operations e.g. addition + and subtraction. The latter can be used for combining multiple + SingleValueConstraint objects into one. + + The SingleValueConstraint object can be applied to + any ASN.1 type. + + Parameters + ---------- + *values: :class:`int` + Full set of values permitted by this constraint object. + + Examples + -------- + .. code-block:: python + + class DivisorOfSix(Integer): + ''' + ASN.1 specification: + + Divisor-Of-6 ::= INTEGER (1 | 2 | 3 | 6) + ''' + subtypeSpec = SingleValueConstraint(1, 2, 3, 6) + + # this will succeed + divisor_of_six = DivisorOfSix(1) + + # this will raise ValueConstraintError + divisor_of_six = DivisorOfSix(7) + """ + def _setValues(self, values): + self._values = values + self._set = set(values) + + def _testValue(self, value, idx): + if value not in self._set: + raise error.ValueConstraintError(value) + + # Constrains can be merged or reduced + + def __contains__(self, item): + return item in self._set + + def __iter__(self): + return iter(self._set) + + def __add__(self, constraint): + return self.__class__(*(self._set.union(constraint))) + + def __sub__(self, constraint): + return self.__class__(*(self._set.difference(constraint))) + + +class ContainedSubtypeConstraint(AbstractConstraint): + """Create a ContainedSubtypeConstraint object. + + The ContainedSubtypeConstraint satisfies any value that + is present in the set of permitted values and also + satisfies included constraints. + + The ContainedSubtypeConstraint object can be applied to + any ASN.1 type. + + Parameters + ---------- + *values: + Full set of values and constraint objects permitted + by this constraint object. + + Examples + -------- + .. code-block:: python + + class DivisorOfEighteen(Integer): + ''' + ASN.1 specification: + + Divisors-of-18 ::= INTEGER (INCLUDES Divisors-of-6 | 9 | 18) + ''' + subtypeSpec = ContainedSubtypeConstraint( + SingleValueConstraint(1, 2, 3, 6), 9, 18 + ) + + # this will succeed + divisor_of_eighteen = DivisorOfEighteen(9) + + # this will raise ValueConstraintError + divisor_of_eighteen = DivisorOfEighteen(10) + """ + def _testValue(self, value, idx): + for constraint in self._values: + if isinstance(constraint, AbstractConstraint): + constraint(value, idx) + elif value not in self._set: + raise error.ValueConstraintError(value) + + +class ValueRangeConstraint(AbstractConstraint): + """Create a ValueRangeConstraint object. + + The ValueRangeConstraint satisfies any value that + falls in the range of permitted values. + + The ValueRangeConstraint object can only be applied + to :class:`~pyasn1.type.univ.Integer` and + :class:`~pyasn1.type.univ.Real` types. + + Parameters + ---------- + start: :class:`int` + Minimum permitted value in the range (inclusive) + + end: :class:`int` + Maximum permitted value in the range (inclusive) + + Examples + -------- + .. code-block:: python + + class TeenAgeYears(Integer): + ''' + ASN.1 specification: + + TeenAgeYears ::= INTEGER (13 .. 19) + ''' + subtypeSpec = ValueRangeConstraint(13, 19) + + # this will succeed + teen_year = TeenAgeYears(18) + + # this will raise ValueConstraintError + teen_year = TeenAgeYears(20) + """ + def _testValue(self, value, idx): + if value < self.start or value > self.stop: + raise error.ValueConstraintError(value) + + def _setValues(self, values): + if len(values) != 2: + raise error.PyAsn1Error( + '%s: bad constraint values' % (self.__class__.__name__,) + ) + self.start, self.stop = values + if self.start > self.stop: + raise error.PyAsn1Error( + '%s: screwed constraint values (start > stop): %s > %s' % ( + self.__class__.__name__, + self.start, self.stop + ) + ) + AbstractConstraint._setValues(self, values) + + +class ValueSizeConstraint(ValueRangeConstraint): + """Create a ValueSizeConstraint object. + + The ValueSizeConstraint satisfies any value for + as long as its size falls within the range of + permitted sizes. + + The ValueSizeConstraint object can be applied + to :class:`~pyasn1.type.univ.BitString`, + :class:`~pyasn1.type.univ.OctetString` (including + all :ref:`character ASN.1 types `), + :class:`~pyasn1.type.univ.SequenceOf` + and :class:`~pyasn1.type.univ.SetOf` types. + + Parameters + ---------- + minimum: :class:`int` + Minimum permitted size of the value (inclusive) + + maximum: :class:`int` + Maximum permitted size of the value (inclusive) + + Examples + -------- + .. code-block:: python + + class BaseballTeamRoster(SetOf): + ''' + ASN.1 specification: + + BaseballTeamRoster ::= SET SIZE (1..25) OF PlayerNames + ''' + componentType = PlayerNames() + subtypeSpec = ValueSizeConstraint(1, 25) + + # this will succeed + team = BaseballTeamRoster() + team.extend(['Jan', 'Matej']) + encode(team) + + # this will raise ValueConstraintError + team = BaseballTeamRoster() + team.extend(['Jan'] * 26) + encode(team) + + Note + ---- + Whenever ValueSizeConstraint is applied to mutable types + (e.g. :class:`~pyasn1.type.univ.SequenceOf`, + :class:`~pyasn1.type.univ.SetOf`), constraint + validation only happens at the serialisation phase rather + than schema instantiation phase (as it is with immutable + types). + """ + def _testValue(self, value, idx): + valueSize = len(value) + if valueSize < self.start or valueSize > self.stop: + raise error.ValueConstraintError(value) + + +class PermittedAlphabetConstraint(SingleValueConstraint): + """Create a PermittedAlphabetConstraint object. + + The PermittedAlphabetConstraint satisfies any character + string for as long as all its characters are present in + the set of permitted characters. + + Objects of this type are iterable (emitting constraint values) and + can act as operands for some arithmetic operations e.g. addition + and subtraction. + + The PermittedAlphabetConstraint object can only be applied + to the :ref:`character ASN.1 types ` such as + :class:`~pyasn1.type.char.IA5String`. + + Parameters + ---------- + *alphabet: :class:`str` + Full set of characters permitted by this constraint object. + + Example + ------- + .. code-block:: python + + class BooleanValue(IA5String): + ''' + ASN.1 specification: + + BooleanValue ::= IA5String (FROM ('T' | 'F')) + ''' + subtypeSpec = PermittedAlphabetConstraint('T', 'F') + + # this will succeed + truth = BooleanValue('T') + truth = BooleanValue('TF') + + # this will raise ValueConstraintError + garbage = BooleanValue('TAF') + + ASN.1 `FROM ... EXCEPT ...` clause can be modelled by combining multiple + PermittedAlphabetConstraint objects into one: + + Example + ------- + .. code-block:: python + + class Lipogramme(IA5String): + ''' + ASN.1 specification: + + Lipogramme ::= + IA5String (FROM (ALL EXCEPT ("e"|"E"))) + ''' + subtypeSpec = ( + PermittedAlphabetConstraint(*string.printable) - + PermittedAlphabetConstraint('e', 'E') + ) + + # this will succeed + lipogramme = Lipogramme('A work of fiction?') + + # this will raise ValueConstraintError + lipogramme = Lipogramme('Eel') + + Note + ---- + Although `ConstraintsExclusion` object could seemingly be used for this + purpose, practically, for it to work, it needs to represent its operand + constraints as sets and intersect one with the other. That would require + the insight into the constraint values (and their types) that are otherwise + hidden inside the constraint object. + + Therefore it's more practical to model `EXCEPT` clause at + `PermittedAlphabetConstraint` level instead. + """ + def _setValues(self, values): + self._values = values + self._set = set(values) + + def _testValue(self, value, idx): + if not self._set.issuperset(value): + raise error.ValueConstraintError(value) + + +class ComponentPresentConstraint(AbstractConstraint): + """Create a ComponentPresentConstraint object. + + The ComponentPresentConstraint is only satisfied when the value + is not `None`. + + The ComponentPresentConstraint object is typically used with + `WithComponentsConstraint`. + + Examples + -------- + .. code-block:: python + + present = ComponentPresentConstraint() + + # this will succeed + present('whatever') + + # this will raise ValueConstraintError + present(None) + """ + def _setValues(self, values): + self._values = ('',) + + if values: + raise error.PyAsn1Error('No arguments expected') + + def _testValue(self, value, idx): + if value is None: + raise error.ValueConstraintError( + 'Component is not present:') + + +class ComponentAbsentConstraint(AbstractConstraint): + """Create a ComponentAbsentConstraint object. + + The ComponentAbsentConstraint is only satisfied when the value + is `None`. + + The ComponentAbsentConstraint object is typically used with + `WithComponentsConstraint`. + + Examples + -------- + .. code-block:: python + + absent = ComponentAbsentConstraint() + + # this will succeed + absent(None) + + # this will raise ValueConstraintError + absent('whatever') + """ + def _setValues(self, values): + self._values = ('',) + + if values: + raise error.PyAsn1Error('No arguments expected') + + def _testValue(self, value, idx): + if value is not None: + raise error.ValueConstraintError( + 'Component is not absent: %r' % value) + + +class WithComponentsConstraint(AbstractConstraint): + """Create a WithComponentsConstraint object. + + The `WithComponentsConstraint` satisfies any mapping object that has + constrained fields present or absent, what is indicated by + `ComponentPresentConstraint` and `ComponentAbsentConstraint` + objects respectively. + + The `WithComponentsConstraint` object is typically applied + to :class:`~pyasn1.type.univ.Set` or + :class:`~pyasn1.type.univ.Sequence` types. + + Parameters + ---------- + *fields: :class:`tuple` + Zero or more tuples of (`field`, `constraint`) indicating constrained + fields. + + Notes + ----- + On top of the primary use of `WithComponentsConstraint` (ensuring presence + or absence of particular components of a :class:`~pyasn1.type.univ.Set` or + :class:`~pyasn1.type.univ.Sequence`), it is also possible to pass any other + constraint objects or their combinations. In case of scalar fields, these + constraints will be verified in addition to the constraints belonging to + scalar components themselves. However, formally, these additional + constraints do not change the type of these ASN.1 objects. + + Examples + -------- + + .. code-block:: python + + class Item(Sequence): # Set is similar + ''' + ASN.1 specification: + + Item ::= SEQUENCE { + id INTEGER OPTIONAL, + name OCTET STRING OPTIONAL + } WITH COMPONENTS id PRESENT, name ABSENT | id ABSENT, name PRESENT + ''' + componentType = NamedTypes( + OptionalNamedType('id', Integer()), + OptionalNamedType('name', OctetString()) + ) + withComponents = ConstraintsUnion( + WithComponentsConstraint( + ('id', ComponentPresentConstraint()), + ('name', ComponentAbsentConstraint()) + ), + WithComponentsConstraint( + ('id', ComponentAbsentConstraint()), + ('name', ComponentPresentConstraint()) + ) + ) + + item = Item() + + # This will succeed + item['id'] = 1 + + # This will succeed + item.reset() + item['name'] = 'John' + + # This will fail (on encoding) + item.reset() + descr['id'] = 1 + descr['name'] = 'John' + """ + def _testValue(self, value, idx): + for field, constraint in self._values: + constraint(value.get(field)) + + def _setValues(self, values): + AbstractConstraint._setValues(self, values) + + +# This is a bit kludgy, meaning two op modes within a single constraint +class InnerTypeConstraint(AbstractConstraint): + """Value must satisfy the type and presence constraints""" + + def _testValue(self, value, idx): + if self.__singleTypeConstraint: + self.__singleTypeConstraint(value) + elif self.__multipleTypeConstraint: + if idx not in self.__multipleTypeConstraint: + raise error.ValueConstraintError(value) + constraint, status = self.__multipleTypeConstraint[idx] + if status == 'ABSENT': # XXX presence is not checked! + raise error.ValueConstraintError(value) + constraint(value) + + def _setValues(self, values): + self.__multipleTypeConstraint = {} + self.__singleTypeConstraint = None + for v in values: + if isinstance(v, tuple): + self.__multipleTypeConstraint[v[0]] = v[1], v[2] + else: + self.__singleTypeConstraint = v + AbstractConstraint._setValues(self, values) + + +# Logic operations on constraints + +class ConstraintsExclusion(AbstractConstraint): + """Create a ConstraintsExclusion logic operator object. + + The ConstraintsExclusion logic operator succeeds when the + value does *not* satisfy the operand constraint. + + The ConstraintsExclusion object can be applied to + any constraint and logic operator object. + + Parameters + ---------- + *constraints: + Constraint or logic operator objects. + + Examples + -------- + .. code-block:: python + + class LuckyNumber(Integer): + subtypeSpec = ConstraintsExclusion( + SingleValueConstraint(13) + ) + + # this will succeed + luckyNumber = LuckyNumber(12) + + # this will raise ValueConstraintError + luckyNumber = LuckyNumber(13) + + Note + ---- + The `FROM ... EXCEPT ...` ASN.1 clause should be modeled by combining + constraint objects into one. See `PermittedAlphabetConstraint` for more + information. + """ + def _testValue(self, value, idx): + for constraint in self._values: + try: + constraint(value, idx) + + except error.ValueConstraintError: + continue + + raise error.ValueConstraintError(value) + + def _setValues(self, values): + AbstractConstraint._setValues(self, values) + + +class AbstractConstraintSet(AbstractConstraint): + + def __getitem__(self, idx): + return self._values[idx] + + def __iter__(self): + return iter(self._values) + + def __add__(self, value): + return self.__class__(*(self._values + (value,))) + + def __radd__(self, value): + return self.__class__(*((value,) + self._values)) + + def __len__(self): + return len(self._values) + + # Constraints inclusion in sets + + def _setValues(self, values): + self._values = values + for constraint in values: + if constraint: + self._valueMap.add(constraint) + self._valueMap.update(constraint.getValueMap()) + + +class ConstraintsIntersection(AbstractConstraintSet): + """Create a ConstraintsIntersection logic operator object. + + The ConstraintsIntersection logic operator only succeeds + if *all* its operands succeed. + + The ConstraintsIntersection object can be applied to + any constraint and logic operator objects. + + The ConstraintsIntersection object duck-types the immutable + container object like Python :py:class:`tuple`. + + Parameters + ---------- + *constraints: + Constraint or logic operator objects. + + Examples + -------- + .. code-block:: python + + class CapitalAndSmall(IA5String): + ''' + ASN.1 specification: + + CapitalAndSmall ::= + IA5String (FROM ("A".."Z"|"a".."z")) + ''' + subtypeSpec = ConstraintsIntersection( + PermittedAlphabetConstraint('A', 'Z'), + PermittedAlphabetConstraint('a', 'z') + ) + + # this will succeed + capital_and_small = CapitalAndSmall('Hello') + + # this will raise ValueConstraintError + capital_and_small = CapitalAndSmall('hello') + """ + def _testValue(self, value, idx): + for constraint in self._values: + constraint(value, idx) + + +class ConstraintsUnion(AbstractConstraintSet): + """Create a ConstraintsUnion logic operator object. + + The ConstraintsUnion logic operator succeeds if + *at least* a single operand succeeds. + + The ConstraintsUnion object can be applied to + any constraint and logic operator objects. + + The ConstraintsUnion object duck-types the immutable + container object like Python :py:class:`tuple`. + + Parameters + ---------- + *constraints: + Constraint or logic operator objects. + + Examples + -------- + .. code-block:: python + + class CapitalOrSmall(IA5String): + ''' + ASN.1 specification: + + CapitalOrSmall ::= + IA5String (FROM ("A".."Z") | FROM ("a".."z")) + ''' + subtypeSpec = ConstraintsUnion( + PermittedAlphabetConstraint('A', 'Z'), + PermittedAlphabetConstraint('a', 'z') + ) + + # this will succeed + capital_or_small = CapitalAndSmall('Hello') + + # this will raise ValueConstraintError + capital_or_small = CapitalOrSmall('hello!') + """ + def _testValue(self, value, idx): + for constraint in self._values: + try: + constraint(value, idx) + except error.ValueConstraintError: + pass + else: + return + + raise error.ValueConstraintError( + 'all of %s failed for "%s"' % (self._values, value) + ) + +# TODO: +# refactor InnerTypeConstraint +# add tests for type check +# implement other constraint types +# make constraint validation easy to skip diff --git a/module/scripts/lib/pyasn1/type/error.py b/module/scripts/lib/pyasn1/type/error.py new file mode 100644 index 0000000..0ff082a --- /dev/null +++ b/module/scripts/lib/pyasn1/type/error.py @@ -0,0 +1,11 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +from pyasn1.error import PyAsn1Error + + +class ValueConstraintError(PyAsn1Error): + pass diff --git a/module/scripts/lib/pyasn1/type/namedtype.py b/module/scripts/lib/pyasn1/type/namedtype.py new file mode 100644 index 0000000..5f6c4ca --- /dev/null +++ b/module/scripts/lib/pyasn1/type/namedtype.py @@ -0,0 +1,550 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import sys + +from pyasn1 import error +from pyasn1.type import tag +from pyasn1.type import tagmap + +__all__ = ['NamedType', 'OptionalNamedType', 'DefaultedNamedType', + 'NamedTypes'] + +class NamedType(object): + """Create named field object for a constructed ASN.1 type. + + The |NamedType| object represents a single name and ASN.1 type of a constructed ASN.1 type. + + |NamedType| objects are immutable and duck-type Python :class:`tuple` objects + holding *name* and *asn1Object* components. + + Parameters + ---------- + name: :py:class:`str` + Field name + + asn1Object: + ASN.1 type object + """ + isOptional = False + isDefaulted = False + + def __init__(self, name, asn1Object, openType=None): + self.__name = name + self.__type = asn1Object + self.__nameAndType = name, asn1Object + self.__openType = openType + + def __repr__(self): + representation = '%s=%r' % (self.name, self.asn1Object) + + if self.openType: + representation += ', open type %r' % self.openType + + return '<%s object, type %s>' % ( + self.__class__.__name__, representation) + + def __eq__(self, other): + return self.__nameAndType == other + + def __ne__(self, other): + return self.__nameAndType != other + + def __lt__(self, other): + return self.__nameAndType < other + + def __le__(self, other): + return self.__nameAndType <= other + + def __gt__(self, other): + return self.__nameAndType > other + + def __ge__(self, other): + return self.__nameAndType >= other + + def __hash__(self): + return hash(self.__nameAndType) + + def __getitem__(self, idx): + return self.__nameAndType[idx] + + def __iter__(self): + return iter(self.__nameAndType) + + @property + def name(self): + return self.__name + + @property + def asn1Object(self): + return self.__type + + @property + def openType(self): + return self.__openType + + # Backward compatibility + + def getName(self): + return self.name + + def getType(self): + return self.asn1Object + + +class OptionalNamedType(NamedType): + __doc__ = NamedType.__doc__ + + isOptional = True + + +class DefaultedNamedType(NamedType): + __doc__ = NamedType.__doc__ + + isDefaulted = True + + +class NamedTypes(object): + """Create a collection of named fields for a constructed ASN.1 type. + + The NamedTypes object represents a collection of named fields of a constructed ASN.1 type. + + *NamedTypes* objects are immutable and duck-type Python :class:`dict` objects + holding *name* as keys and ASN.1 type object as values. + + Parameters + ---------- + *namedTypes: :class:`~pyasn1.type.namedtype.NamedType` + + Examples + -------- + + .. code-block:: python + + class Description(Sequence): + ''' + ASN.1 specification: + + Description ::= SEQUENCE { + surname IA5String, + first-name IA5String OPTIONAL, + age INTEGER DEFAULT 40 + } + ''' + componentType = NamedTypes( + NamedType('surname', IA5String()), + OptionalNamedType('first-name', IA5String()), + DefaultedNamedType('age', Integer(40)) + ) + + descr = Description() + descr['surname'] = 'Smith' + descr['first-name'] = 'John' + """ + def __init__(self, *namedTypes, **kwargs): + self.__namedTypes = namedTypes + self.__namedTypesLen = len(self.__namedTypes) + self.__minTagSet = self.__computeMinTagSet() + self.__nameToPosMap = self.__computeNameToPosMap() + self.__tagToPosMap = self.__computeTagToPosMap() + self.__ambiguousTypes = 'terminal' not in kwargs and self.__computeAmbiguousTypes() or {} + self.__uniqueTagMap = self.__computeTagMaps(unique=True) + self.__nonUniqueTagMap = self.__computeTagMaps(unique=False) + self.__hasOptionalOrDefault = any([True for namedType in self.__namedTypes + if namedType.isDefaulted or namedType.isOptional]) + self.__hasOpenTypes = any([True for namedType in self.__namedTypes + if namedType.openType]) + + self.__requiredComponents = frozenset( + [idx for idx, nt in enumerate(self.__namedTypes) if not nt.isOptional and not nt.isDefaulted] + ) + self.__keys = frozenset([namedType.name for namedType in self.__namedTypes]) + self.__values = tuple([namedType.asn1Object for namedType in self.__namedTypes]) + self.__items = tuple([(namedType.name, namedType.asn1Object) for namedType in self.__namedTypes]) + + def __repr__(self): + representation = ', '.join(['%r' % x for x in self.__namedTypes]) + return '<%s object, types %s>' % ( + self.__class__.__name__, representation) + + def __eq__(self, other): + return self.__namedTypes == other + + def __ne__(self, other): + return self.__namedTypes != other + + def __lt__(self, other): + return self.__namedTypes < other + + def __le__(self, other): + return self.__namedTypes <= other + + def __gt__(self, other): + return self.__namedTypes > other + + def __ge__(self, other): + return self.__namedTypes >= other + + def __hash__(self): + return hash(self.__namedTypes) + + def __getitem__(self, idx): + try: + return self.__namedTypes[idx] + + except TypeError: + return self.__namedTypes[self.__nameToPosMap[idx]] + + def __contains__(self, key): + return key in self.__nameToPosMap + + def __iter__(self): + return (x[0] for x in self.__namedTypes) + + def __bool__(self): + return self.__namedTypesLen > 0 + + def __len__(self): + return self.__namedTypesLen + + # Python dict protocol + + def values(self): + return self.__values + + def keys(self): + return self.__keys + + def items(self): + return self.__items + + def clone(self): + return self.__class__(*self.__namedTypes) + + class PostponedError(object): + def __init__(self, errorMsg): + self.__errorMsg = errorMsg + + def __getitem__(self, item): + raise error.PyAsn1Error(self.__errorMsg) + + def __computeTagToPosMap(self): + tagToPosMap = {} + for idx, namedType in enumerate(self.__namedTypes): + tagMap = namedType.asn1Object.tagMap + if isinstance(tagMap, NamedTypes.PostponedError): + return tagMap + if not tagMap: + continue + for _tagSet in tagMap.presentTypes: + if _tagSet in tagToPosMap: + return NamedTypes.PostponedError('Duplicate component tag %s at %s' % (_tagSet, namedType)) + tagToPosMap[_tagSet] = idx + + return tagToPosMap + + def __computeNameToPosMap(self): + nameToPosMap = {} + for idx, namedType in enumerate(self.__namedTypes): + if namedType.name in nameToPosMap: + return NamedTypes.PostponedError('Duplicate component name %s at %s' % (namedType.name, namedType)) + nameToPosMap[namedType.name] = idx + + return nameToPosMap + + def __computeAmbiguousTypes(self): + ambiguousTypes = {} + partialAmbiguousTypes = () + for idx, namedType in reversed(tuple(enumerate(self.__namedTypes))): + if namedType.isOptional or namedType.isDefaulted: + partialAmbiguousTypes = (namedType,) + partialAmbiguousTypes + else: + partialAmbiguousTypes = (namedType,) + if len(partialAmbiguousTypes) == len(self.__namedTypes): + ambiguousTypes[idx] = self + else: + ambiguousTypes[idx] = NamedTypes(*partialAmbiguousTypes, **dict(terminal=True)) + return ambiguousTypes + + def getTypeByPosition(self, idx): + """Return ASN.1 type object by its position in fields set. + + Parameters + ---------- + idx: :py:class:`int` + Field index + + Returns + ------- + : + ASN.1 type + + Raises + ------ + ~pyasn1.error.PyAsn1Error + If given position is out of fields range + """ + try: + return self.__namedTypes[idx].asn1Object + + except IndexError: + raise error.PyAsn1Error('Type position out of range') + + def getPositionByType(self, tagSet): + """Return field position by its ASN.1 type. + + Parameters + ---------- + tagSet: :class:`~pysnmp.type.tag.TagSet` + ASN.1 tag set distinguishing one ASN.1 type from others. + + Returns + ------- + : :py:class:`int` + ASN.1 type position in fields set + + Raises + ------ + ~pyasn1.error.PyAsn1Error + If *tagSet* is not present or ASN.1 types are not unique within callee *NamedTypes* + """ + try: + return self.__tagToPosMap[tagSet] + + except KeyError: + raise error.PyAsn1Error('Type %s not found' % (tagSet,)) + + def getNameByPosition(self, idx): + """Return field name by its position in fields set. + + Parameters + ---------- + idx: :py:class:`idx` + Field index + + Returns + ------- + : :py:class:`str` + Field name + + Raises + ------ + ~pyasn1.error.PyAsn1Error + If given field name is not present in callee *NamedTypes* + """ + try: + return self.__namedTypes[idx].name + + except IndexError: + raise error.PyAsn1Error('Type position out of range') + + def getPositionByName(self, name): + """Return field position by filed name. + + Parameters + ---------- + name: :py:class:`str` + Field name + + Returns + ------- + : :py:class:`int` + Field position in fields set + + Raises + ------ + ~pyasn1.error.PyAsn1Error + If *name* is not present or not unique within callee *NamedTypes* + """ + try: + return self.__nameToPosMap[name] + + except KeyError: + raise error.PyAsn1Error('Name %s not found' % (name,)) + + def getTagMapNearPosition(self, idx): + """Return ASN.1 types that are allowed at or past given field position. + + Some ASN.1 serialisation allow for skipping optional and defaulted fields. + Some constructed ASN.1 types allow reordering of the fields. When recovering + such objects it may be important to know which types can possibly be + present at any given position in the field sets. + + Parameters + ---------- + idx: :py:class:`int` + Field index + + Returns + ------- + : :class:`~pyasn1.type.tagmap.TagMap` + Map if ASN.1 types allowed at given field position + + Raises + ------ + ~pyasn1.error.PyAsn1Error + If given position is out of fields range + """ + try: + return self.__ambiguousTypes[idx].tagMap + + except KeyError: + raise error.PyAsn1Error('Type position out of range') + + def getPositionNearType(self, tagSet, idx): + """Return the closest field position where given ASN.1 type is allowed. + + Some ASN.1 serialisation allow for skipping optional and defaulted fields. + Some constructed ASN.1 types allow reordering of the fields. When recovering + such objects it may be important to know at which field position, in field set, + given *tagSet* is allowed at or past *idx* position. + + Parameters + ---------- + tagSet: :class:`~pyasn1.type.tag.TagSet` + ASN.1 type which field position to look up + + idx: :py:class:`int` + Field position at or past which to perform ASN.1 type look up + + Returns + ------- + : :py:class:`int` + Field position in fields set + + Raises + ------ + ~pyasn1.error.PyAsn1Error + If *tagSet* is not present or not unique within callee *NamedTypes* + or *idx* is out of fields range + """ + try: + return idx + self.__ambiguousTypes[idx].getPositionByType(tagSet) + + except KeyError: + raise error.PyAsn1Error('Type position out of range') + + def __computeMinTagSet(self): + minTagSet = None + for namedType in self.__namedTypes: + asn1Object = namedType.asn1Object + + try: + tagSet = asn1Object.minTagSet + + except AttributeError: + tagSet = asn1Object.tagSet + + if minTagSet is None or tagSet < minTagSet: + minTagSet = tagSet + + return minTagSet or tag.TagSet() + + @property + def minTagSet(self): + """Return the minimal TagSet among ASN.1 type in callee *NamedTypes*. + + Some ASN.1 types/serialisation protocols require ASN.1 types to be + arranged based on their numerical tag value. The *minTagSet* property + returns that. + + Returns + ------- + : :class:`~pyasn1.type.tagset.TagSet` + Minimal TagSet among ASN.1 types in callee *NamedTypes* + """ + return self.__minTagSet + + def __computeTagMaps(self, unique): + presentTypes = {} + skipTypes = {} + defaultType = None + for namedType in self.__namedTypes: + tagMap = namedType.asn1Object.tagMap + if isinstance(tagMap, NamedTypes.PostponedError): + return tagMap + for tagSet in tagMap: + if unique and tagSet in presentTypes: + return NamedTypes.PostponedError('Non-unique tagSet %s of %s at %s' % (tagSet, namedType, self)) + presentTypes[tagSet] = namedType.asn1Object + skipTypes.update(tagMap.skipTypes) + + if defaultType is None: + defaultType = tagMap.defaultType + elif tagMap.defaultType is not None: + return NamedTypes.PostponedError('Duplicate default ASN.1 type at %s' % (self,)) + + return tagmap.TagMap(presentTypes, skipTypes, defaultType) + + @property + def tagMap(self): + """Return a *TagMap* object from tags and types recursively. + + Return a :class:`~pyasn1.type.tagmap.TagMap` object by + combining tags from *TagMap* objects of children types and + associating them with their immediate child type. + + Example + ------- + .. code-block:: python + + OuterType ::= CHOICE { + innerType INTEGER + } + + Calling *.tagMap* on *OuterType* will yield a map like this: + + .. code-block:: python + + Integer.tagSet -> Choice + """ + return self.__nonUniqueTagMap + + @property + def tagMapUnique(self): + """Return a *TagMap* object from unique tags and types recursively. + + Return a :class:`~pyasn1.type.tagmap.TagMap` object by + combining tags from *TagMap* objects of children types and + associating them with their immediate child type. + + Example + ------- + .. code-block:: python + + OuterType ::= CHOICE { + innerType INTEGER + } + + Calling *.tagMapUnique* on *OuterType* will yield a map like this: + + .. code-block:: python + + Integer.tagSet -> Choice + + Note + ---- + + Duplicate *TagSet* objects found in the tree of children + types would cause error. + """ + return self.__uniqueTagMap + + @property + def hasOptionalOrDefault(self): + return self.__hasOptionalOrDefault + + @property + def hasOpenTypes(self): + return self.__hasOpenTypes + + @property + def namedTypes(self): + return tuple(self.__namedTypes) + + @property + def requiredComponents(self): + return self.__requiredComponents diff --git a/module/scripts/lib/pyasn1/type/namedval.py b/module/scripts/lib/pyasn1/type/namedval.py new file mode 100644 index 0000000..46a6496 --- /dev/null +++ b/module/scripts/lib/pyasn1/type/namedval.py @@ -0,0 +1,192 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +# ASN.1 named integers +# +from pyasn1 import error + +__all__ = ['NamedValues'] + + +class NamedValues(object): + """Create named values object. + + The |NamedValues| object represents a collection of string names + associated with numeric IDs. These objects are used for giving + names to otherwise numerical values. + + |NamedValues| objects are immutable and duck-type Python + :class:`dict` object mapping ID to name and vice-versa. + + Parameters + ---------- + *args: variable number of two-element :py:class:`tuple` + + name: :py:class:`str` + Value label + + value: :py:class:`int` + Numeric value + + Keyword Args + ------------ + name: :py:class:`str` + Value label + + value: :py:class:`int` + Numeric value + + Examples + -------- + + .. code-block:: pycon + + >>> nv = NamedValues('a', 'b', ('c', 0), d=1) + >>> nv + >>> {'c': 0, 'd': 1, 'a': 2, 'b': 3} + >>> nv[0] + 'c' + >>> nv['a'] + 2 + """ + def __init__(self, *args, **kwargs): + self.__names = {} + self.__numbers = {} + + anonymousNames = [] + + for namedValue in args: + if isinstance(namedValue, (tuple, list)): + try: + name, number = namedValue + + except ValueError: + raise error.PyAsn1Error('Not a proper attribute-value pair %r' % (namedValue,)) + + else: + anonymousNames.append(namedValue) + continue + + if name in self.__names: + raise error.PyAsn1Error('Duplicate name %s' % (name,)) + + if number in self.__numbers: + raise error.PyAsn1Error('Duplicate number %s=%s' % (name, number)) + + self.__names[name] = number + self.__numbers[number] = name + + for name, number in kwargs.items(): + if name in self.__names: + raise error.PyAsn1Error('Duplicate name %s' % (name,)) + + if number in self.__numbers: + raise error.PyAsn1Error('Duplicate number %s=%s' % (name, number)) + + self.__names[name] = number + self.__numbers[number] = name + + if anonymousNames: + + number = self.__numbers and max(self.__numbers) + 1 or 0 + + for name in anonymousNames: + + if name in self.__names: + raise error.PyAsn1Error('Duplicate name %s' % (name,)) + + self.__names[name] = number + self.__numbers[number] = name + + number += 1 + + def __repr__(self): + representation = ', '.join(['%s=%d' % x for x in self.items()]) + + if len(representation) > 64: + representation = representation[:32] + '...' + representation[-32:] + + return '<%s object, enums %s>' % ( + self.__class__.__name__, representation) + + def __eq__(self, other): + return dict(self) == other + + def __ne__(self, other): + return dict(self) != other + + def __lt__(self, other): + return dict(self) < other + + def __le__(self, other): + return dict(self) <= other + + def __gt__(self, other): + return dict(self) > other + + def __ge__(self, other): + return dict(self) >= other + + def __hash__(self): + return hash(self.items()) + + # Python dict protocol (read-only) + + def __getitem__(self, key): + try: + return self.__numbers[key] + + except KeyError: + return self.__names[key] + + def __len__(self): + return len(self.__names) + + def __contains__(self, key): + return key in self.__names or key in self.__numbers + + def __iter__(self): + return iter(self.__names) + + def values(self): + return iter(self.__numbers) + + def keys(self): + return iter(self.__names) + + def items(self): + for name in self.__names: + yield name, self.__names[name] + + # support merging + + def __add__(self, namedValues): + return self.__class__(*tuple(self.items()) + tuple(namedValues.items())) + + # XXX clone/subtype? + + def clone(self, *args, **kwargs): + new = self.__class__(*args, **kwargs) + return self + new + + # legacy protocol + + def getName(self, value): + if value in self.__numbers: + return self.__numbers[value] + + def getValue(self, name): + if name in self.__names: + return self.__names[name] + + def getValues(self, *names): + try: + return [self.__names[name] for name in names] + + except KeyError: + raise error.PyAsn1Error( + 'Unknown bit identifier(s): %s' % (set(names).difference(self.__names),) + ) diff --git a/module/scripts/lib/pyasn1/type/opentype.py b/module/scripts/lib/pyasn1/type/opentype.py new file mode 100644 index 0000000..5a15f89 --- /dev/null +++ b/module/scripts/lib/pyasn1/type/opentype.py @@ -0,0 +1,104 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# + +__all__ = ['OpenType'] + + +class OpenType(object): + """Create ASN.1 type map indexed by a value + + The *OpenType* object models an untyped field of a constructed ASN.1 + type. In ASN.1 syntax it is usually represented by the + `ANY DEFINED BY` for scalars or `SET OF ANY DEFINED BY`, + `SEQUENCE OF ANY DEFINED BY` for container types clauses. Typically + used together with :class:`~pyasn1.type.univ.Any` object. + + OpenType objects duck-type a read-only Python :class:`dict` objects, + however the passed `typeMap` is not copied, but stored by reference. + That means the user can manipulate `typeMap` at run time having this + reflected on *OpenType* object behavior. + + The |OpenType| class models an untyped field of a constructed ASN.1 + type. In ASN.1 syntax it is usually represented by the + `ANY DEFINED BY` for scalars or `SET OF ANY DEFINED BY`, + `SEQUENCE OF ANY DEFINED BY` for container types clauses. Typically + used with :class:`~pyasn1.type.univ.Any` type. + + Parameters + ---------- + name: :py:class:`str` + Field name + + typeMap: :py:class:`dict` + A map of value->ASN.1 type. It's stored by reference and can be + mutated later to register new mappings. + + Examples + -------- + + For untyped scalars: + + .. code-block:: python + + openType = OpenType( + 'id', {1: Integer(), + 2: OctetString()} + ) + Sequence( + componentType=NamedTypes( + NamedType('id', Integer()), + NamedType('blob', Any(), openType=openType) + ) + ) + + For untyped `SET OF` or `SEQUENCE OF` vectors: + + .. code-block:: python + + openType = OpenType( + 'id', {1: Integer(), + 2: OctetString()} + ) + Sequence( + componentType=NamedTypes( + NamedType('id', Integer()), + NamedType('blob', SetOf(componentType=Any()), + openType=openType) + ) + ) + """ + + def __init__(self, name, typeMap=None): + self.__name = name + if typeMap is None: + self.__typeMap = {} + else: + self.__typeMap = typeMap + + @property + def name(self): + return self.__name + + # Python dict protocol + + def values(self): + return self.__typeMap.values() + + def keys(self): + return self.__typeMap.keys() + + def items(self): + return self.__typeMap.items() + + def __contains__(self, key): + return key in self.__typeMap + + def __getitem__(self, key): + return self.__typeMap[key] + + def __iter__(self): + return iter(self.__typeMap) diff --git a/module/scripts/lib/pyasn1/type/tag.py b/module/scripts/lib/pyasn1/type/tag.py new file mode 100644 index 0000000..28cd3fd --- /dev/null +++ b/module/scripts/lib/pyasn1/type/tag.py @@ -0,0 +1,347 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +from pyasn1 import error + +__all__ = ['tagClassUniversal', 'tagClassApplication', 'tagClassContext', + 'tagClassPrivate', 'tagFormatSimple', 'tagFormatConstructed', + 'tagCategoryImplicit', 'tagCategoryExplicit', + 'tagCategoryUntagged', 'Tag', 'TagSet'] + +#: Identifier for ASN.1 class UNIVERSAL +tagClassUniversal = 0x00 + +#: Identifier for ASN.1 class APPLICATION +tagClassApplication = 0x40 + +#: Identifier for ASN.1 class context-specific +tagClassContext = 0x80 + +#: Identifier for ASN.1 class private +tagClassPrivate = 0xC0 + +#: Identifier for "simple" ASN.1 structure (e.g. scalar) +tagFormatSimple = 0x00 + +#: Identifier for "constructed" ASN.1 structure (e.g. may have inner components) +tagFormatConstructed = 0x20 + +tagCategoryImplicit = 0x01 +tagCategoryExplicit = 0x02 +tagCategoryUntagged = 0x04 + + +def _tagIdToStr(tagId): + # Decimal rendering of a huge tag ID can exceed the interpreter's + # integer-to-string conversion limit (sys.get_int_max_str_digits(), + # Python 3.11+) and raise ValueError; hexadecimal is not limited + try: + return str(tagId) + except ValueError: + return hex(tagId) + + +class Tag(object): + """Create ASN.1 tag + + Represents ASN.1 tag that can be attached to a ASN.1 type to make + types distinguishable from each other. + + *Tag* objects are immutable and duck-type Python :class:`tuple` objects + holding three integer components of a tag. + + Parameters + ---------- + tagClass: :py:class:`int` + Tag *class* value + + tagFormat: :py:class:`int` + Tag *format* value + + tagId: :py:class:`int` + Tag ID value + """ + def __init__(self, tagClass, tagFormat, tagId): + if tagId < 0: + raise error.PyAsn1Error( + 'Negative tag ID (%s) not allowed' % _tagIdToStr(tagId)) + self.__tagClass = tagClass + self.__tagFormat = tagFormat + self.__tagId = tagId + self.__tagClassId = tagClass, tagId + self.__hash = hash(self.__tagClassId) + + def __repr__(self): + representation = '[%s:%s:%s]' % ( + self.__tagClass, self.__tagFormat, _tagIdToStr(self.__tagId)) + return '<%s object, tag %s>' % ( + self.__class__.__name__, representation) + + def __eq__(self, other): + return self.__tagClassId == other + + def __ne__(self, other): + return self.__tagClassId != other + + def __lt__(self, other): + return self.__tagClassId < other + + def __le__(self, other): + return self.__tagClassId <= other + + def __gt__(self, other): + return self.__tagClassId > other + + def __ge__(self, other): + return self.__tagClassId >= other + + def __hash__(self): + return self.__hash + + def __getitem__(self, idx): + if idx == 0: + return self.__tagClass + elif idx == 1: + return self.__tagFormat + elif idx == 2: + return self.__tagId + else: + raise IndexError + + def __iter__(self): + yield self.__tagClass + yield self.__tagFormat + yield self.__tagId + + def __and__(self, otherTag): + return self.__class__(self.__tagClass & otherTag.tagClass, + self.__tagFormat & otherTag.tagFormat, + self.__tagId & otherTag.tagId) + + def __or__(self, otherTag): + return self.__class__(self.__tagClass | otherTag.tagClass, + self.__tagFormat | otherTag.tagFormat, + self.__tagId | otherTag.tagId) + + @property + def tagClass(self): + """ASN.1 tag class + + Returns + ------- + : :py:class:`int` + Tag class + """ + return self.__tagClass + + @property + def tagFormat(self): + """ASN.1 tag format + + Returns + ------- + : :py:class:`int` + Tag format + """ + return self.__tagFormat + + @property + def tagId(self): + """ASN.1 tag ID + + Returns + ------- + : :py:class:`int` + Tag ID + """ + return self.__tagId + + +class TagSet(object): + """Create a collection of ASN.1 tags + + Represents a combination of :class:`~pyasn1.type.tag.Tag` objects + that can be attached to a ASN.1 type to make types distinguishable + from each other. + + *TagSet* objects are immutable and duck-type Python :class:`tuple` objects + holding arbitrary number of :class:`~pyasn1.type.tag.Tag` objects. + + Parameters + ---------- + baseTag: :class:`~pyasn1.type.tag.Tag` + Base *Tag* object. This tag survives IMPLICIT tagging. + + *superTags: :class:`~pyasn1.type.tag.Tag` + Additional *Tag* objects taking part in subtyping. + + Examples + -------- + .. code-block:: python + + class OrderNumber(NumericString): + ''' + ASN.1 specification + + Order-number ::= + [APPLICATION 5] IMPLICIT NumericString + ''' + tagSet = NumericString.tagSet.tagImplicitly( + Tag(tagClassApplication, tagFormatSimple, 5) + ) + + orderNumber = OrderNumber('1234') + """ + def __init__(self, baseTag=(), *superTags): + self.__baseTag = baseTag + self.__superTags = superTags + self.__superTagsClassId = tuple( + [(superTag.tagClass, superTag.tagId) for superTag in superTags] + ) + self.__lenOfSuperTags = len(superTags) + self.__hash = hash(self.__superTagsClassId) + + def __repr__(self): + representation = '-'.join( + ['%s:%s:%s' % (x.tagClass, x.tagFormat, _tagIdToStr(x.tagId)) + for x in self.__superTags]) + if representation: + representation = 'tags ' + representation + else: + representation = 'untagged' + + return '<%s object, %s>' % (self.__class__.__name__, representation) + + def __add__(self, superTag): + return self.__class__(self.__baseTag, *self.__superTags + (superTag,)) + + def __radd__(self, superTag): + return self.__class__(self.__baseTag, *(superTag,) + self.__superTags) + + def __getitem__(self, i): + if i.__class__ is slice: + return self.__class__(self.__baseTag, *self.__superTags[i]) + else: + return self.__superTags[i] + + def __eq__(self, other): + return self.__superTagsClassId == other + + def __ne__(self, other): + return self.__superTagsClassId != other + + def __lt__(self, other): + return self.__superTagsClassId < other + + def __le__(self, other): + return self.__superTagsClassId <= other + + def __gt__(self, other): + return self.__superTagsClassId > other + + def __ge__(self, other): + return self.__superTagsClassId >= other + + def __hash__(self): + return self.__hash + + def __len__(self): + return self.__lenOfSuperTags + + @property + def baseTag(self): + """Return base ASN.1 tag + + Returns + ------- + : :class:`~pyasn1.type.tag.Tag` + Base tag of this *TagSet* + """ + return self.__baseTag + + @property + def superTags(self): + """Return ASN.1 tags + + Returns + ------- + : :py:class:`tuple` + Tuple of :class:`~pyasn1.type.tag.Tag` objects that this *TagSet* contains + """ + return self.__superTags + + def tagExplicitly(self, superTag): + """Return explicitly tagged *TagSet* + + Create a new *TagSet* representing callee *TagSet* explicitly tagged + with passed tag(s). With explicit tagging mode, new tags are appended + to existing tag(s). + + Parameters + ---------- + superTag: :class:`~pyasn1.type.tag.Tag` + *Tag* object to tag this *TagSet* + + Returns + ------- + : :class:`~pyasn1.type.tag.TagSet` + New *TagSet* object + """ + if superTag.tagClass == tagClassUniversal: + raise error.PyAsn1Error("Can't tag with UNIVERSAL class tag") + if superTag.tagFormat != tagFormatConstructed: + superTag = Tag(superTag.tagClass, tagFormatConstructed, superTag.tagId) + return self + superTag + + def tagImplicitly(self, superTag): + """Return implicitly tagged *TagSet* + + Create a new *TagSet* representing callee *TagSet* implicitly tagged + with passed tag(s). With implicit tagging mode, new tag(s) replace the + last existing tag. + + Parameters + ---------- + superTag: :class:`~pyasn1.type.tag.Tag` + *Tag* object to tag this *TagSet* + + Returns + ------- + : :class:`~pyasn1.type.tag.TagSet` + New *TagSet* object + """ + if self.__superTags: + superTag = Tag(superTag.tagClass, self.__superTags[-1].tagFormat, superTag.tagId) + return self[:-1] + superTag + + def isSuperTagSetOf(self, tagSet): + """Test type relationship against given *TagSet* + + The callee is considered to be a supertype of given *TagSet* + tag-wise if all tags in *TagSet* are present in the callee and + they are in the same order. + + Parameters + ---------- + tagSet: :class:`~pyasn1.type.tag.TagSet` + *TagSet* object to evaluate against the callee + + Returns + ------- + : :py:class:`bool` + :obj:`True` if callee is a supertype of *tagSet* + """ + if len(tagSet) < self.__lenOfSuperTags: + return False + return self.__superTags == tagSet[:self.__lenOfSuperTags] + + # Backward compatibility + + def getBaseTag(self): + return self.__baseTag + +def initTagSet(tag): + return TagSet(tag, tag) diff --git a/module/scripts/lib/pyasn1/type/tagmap.py b/module/scripts/lib/pyasn1/type/tagmap.py new file mode 100644 index 0000000..7f8a955 --- /dev/null +++ b/module/scripts/lib/pyasn1/type/tagmap.py @@ -0,0 +1,96 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +from pyasn1 import error + +__all__ = ['TagMap'] + + +class TagMap(object): + """Map *TagSet* objects to ASN.1 types + + Create an object mapping *TagSet* object to ASN.1 type. + + *TagMap* objects are immutable and duck-type read-only Python + :class:`dict` objects holding *TagSet* objects as keys and ASN.1 + type objects as values. + + Parameters + ---------- + presentTypes: :py:class:`dict` + Map of :class:`~pyasn1.type.tag.TagSet` to ASN.1 objects considered + as being unconditionally present in the *TagMap*. + + skipTypes: :py:class:`dict` + A collection of :class:`~pyasn1.type.tag.TagSet` objects considered + as absent in the *TagMap* even when *defaultType* is present. + + defaultType: ASN.1 type object + An ASN.1 type object callee *TagMap* returns for any *TagSet* key not present + in *presentTypes* (unless given key is present in *skipTypes*). + """ + def __init__(self, presentTypes=None, skipTypes=None, defaultType=None): + self.__presentTypes = presentTypes or {} + self.__skipTypes = skipTypes or {} + self.__defaultType = defaultType + + def __contains__(self, tagSet): + return (tagSet in self.__presentTypes or + self.__defaultType is not None and tagSet not in self.__skipTypes) + + def __getitem__(self, tagSet): + try: + return self.__presentTypes[tagSet] + except KeyError: + if self.__defaultType is None: + raise + elif tagSet in self.__skipTypes: + raise error.PyAsn1Error('Key in negative map') + else: + return self.__defaultType + + def __iter__(self): + return iter(self.__presentTypes) + + def __repr__(self): + representation = '%s object' % self.__class__.__name__ + + if self.__presentTypes: + representation += ', present %s' % repr(self.__presentTypes) + + if self.__skipTypes: + representation += ', skip %s' % repr(self.__skipTypes) + + if self.__defaultType is not None: + representation += ', default %s' % repr(self.__defaultType) + + return '<%s>' % representation + + @property + def presentTypes(self): + """Return *TagSet* to ASN.1 type map present in callee *TagMap*""" + return self.__presentTypes + + @property + def skipTypes(self): + """Return *TagSet* collection unconditionally absent in callee *TagMap*""" + return self.__skipTypes + + @property + def defaultType(self): + """Return default ASN.1 type being returned for any missing *TagSet*""" + return self.__defaultType + + # Backward compatibility + + def getPosMap(self): + return self.presentTypes + + def getNegMap(self): + return self.skipTypes + + def getDef(self): + return self.defaultType diff --git a/module/scripts/lib/pyasn1/type/univ.py b/module/scripts/lib/pyasn1/type/univ.py new file mode 100644 index 0000000..8b786dd --- /dev/null +++ b/module/scripts/lib/pyasn1/type/univ.py @@ -0,0 +1,3338 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import math +import sys + +from pyasn1 import error +from pyasn1.codec.ber import eoo +from pyasn1.compat import integer +from pyasn1.type import base +from pyasn1.type import constraint +from pyasn1.type import namedtype +from pyasn1.type import namedval +from pyasn1.type import tag +from pyasn1.type import tagmap + +NoValue = base.NoValue +noValue = NoValue() + +__all__ = ['Integer', 'Boolean', 'BitString', 'OctetString', 'Null', + 'ObjectIdentifier', 'Real', 'Enumerated', + 'SequenceOfAndSetOfBase', 'SequenceOf', 'SetOf', + 'SequenceAndSetBase', 'Sequence', 'Set', 'Choice', 'Any', + 'NoValue', 'noValue'] + +# "Simple" ASN.1 types (yet incomplete) + + +class Integer(base.SimpleAsn1Type): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type Python :class:`int` objects. + + Keyword Args + ------------ + value: :class:`int`, :class:`str` or |ASN.1| object + Python :class:`int` or :class:`str` literal or |ASN.1| class + instance. If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + namedValues: :py:class:`~pyasn1.type.namedval.NamedValues` + Object representing non-default symbolic aliases for numbers + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + + .. code-block:: python + + class ErrorCode(Integer): + ''' + ASN.1 specification: + + ErrorCode ::= + INTEGER { disk-full(1), no-disk(-1), + disk-not-formatted(2) } + + error ErrorCode ::= disk-full + ''' + namedValues = NamedValues( + ('disk-full', 1), ('no-disk', -1), + ('disk-not-formatted', 2) + ) + + error = ErrorCode('disk-full') + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x02) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + #: Default :py:class:`~pyasn1.type.namedval.NamedValues` object + #: representing symbolic aliases for numbers + namedValues = namedval.NamedValues() + + # Optimization for faster codec lookup + typeId = base.SimpleAsn1Type.getTypeId() + + def __init__(self, value=noValue, **kwargs): + if 'namedValues' not in kwargs: + kwargs['namedValues'] = self.namedValues + + base.SimpleAsn1Type.__init__(self, value, **kwargs) + + def __and__(self, value): + return self.clone(self._value & value) + + def __rand__(self, value): + return self.clone(value & self._value) + + def __or__(self, value): + return self.clone(self._value | value) + + def __ror__(self, value): + return self.clone(value | self._value) + + def __xor__(self, value): + return self.clone(self._value ^ value) + + def __rxor__(self, value): + return self.clone(value ^ self._value) + + def __lshift__(self, value): + return self.clone(self._value << value) + + def __rshift__(self, value): + return self.clone(self._value >> value) + + def __add__(self, value): + return self.clone(self._value + value) + + def __radd__(self, value): + return self.clone(value + self._value) + + def __sub__(self, value): + return self.clone(self._value - value) + + def __rsub__(self, value): + return self.clone(value - self._value) + + def __mul__(self, value): + return self.clone(self._value * value) + + def __rmul__(self, value): + return self.clone(value * self._value) + + def __mod__(self, value): + return self.clone(self._value % value) + + def __rmod__(self, value): + return self.clone(value % self._value) + + def __pow__(self, value, modulo=None): + return self.clone(pow(self._value, value, modulo)) + + def __rpow__(self, value): + return self.clone(pow(value, self._value)) + + def __floordiv__(self, value): + return self.clone(self._value // value) + + def __rfloordiv__(self, value): + return self.clone(value // self._value) + + def __truediv__(self, value): + return Real(self._value / value) + + def __rtruediv__(self, value): + return Real(value / self._value) + + def __divmod__(self, value): + return self.clone(divmod(self._value, value)) + + def __rdivmod__(self, value): + return self.clone(divmod(value, self._value)) + + __hash__ = base.SimpleAsn1Type.__hash__ + + def __int__(self): + return int(self._value) + + def __float__(self): + return float(self._value) + + def __abs__(self): + return self.clone(abs(self._value)) + + def __index__(self): + return int(self._value) + + def __pos__(self): + return self.clone(+self._value) + + def __neg__(self): + return self.clone(-self._value) + + def __invert__(self): + return self.clone(~self._value) + + def __round__(self, n=0): + r = round(self._value, n) + if n: + return self.clone(r) + else: + return r + + def __floor__(self): + return math.floor(self._value) + + def __ceil__(self): + return math.ceil(self._value) + + def __trunc__(self): + return self.clone(math.trunc(self._value)) + + def __lt__(self, value): + return self._value < value + + def __le__(self, value): + return self._value <= value + + def __eq__(self, value): + return self._value == value + + def __ne__(self, value): + return self._value != value + + def __gt__(self, value): + return self._value > value + + def __ge__(self, value): + return self._value >= value + + def prettyIn(self, value): + try: + return int(value) + + except ValueError: + try: + return self.namedValues[value] + + except KeyError as exc: + raise error.PyAsn1Error( + 'Can\'t coerce %r into integer: %s' % (value, exc) + ) + + def prettyOut(self, value): + try: + return str(self.namedValues[value]) + + except KeyError: + return str(value) + + # backward compatibility + + def getNamedValues(self): + return self.namedValues + + +class Boolean(Integer): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type Python :class:`int` objects. + + Keyword Args + ------------ + value: :class:`int`, :class:`str` or |ASN.1| object + Python :class:`int` or :class:`str` literal or |ASN.1| class + instance. If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s).Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + namedValues: :py:class:`~pyasn1.type.namedval.NamedValues` + Object representing non-default symbolic aliases for numbers + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + .. code-block:: python + + class RoundResult(Boolean): + ''' + ASN.1 specification: + + RoundResult ::= BOOLEAN + + ok RoundResult ::= TRUE + ko RoundResult ::= FALSE + ''' + ok = RoundResult(True) + ko = RoundResult(False) + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x01), + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = Integer.subtypeSpec + constraint.SingleValueConstraint(0, 1) + + #: Default :py:class:`~pyasn1.type.namedval.NamedValues` object + #: representing symbolic aliases for numbers + namedValues = namedval.NamedValues(('False', 0), ('True', 1)) + + # Optimization for faster codec lookup + typeId = Integer.getTypeId() + + +class SizedInteger(int): + bitLength = leadingZeroBits = None + + def setBitLength(self, bitLength): + self.bitLength = bitLength + self.leadingZeroBits = max(bitLength - self.bit_length(), 0) + return self + + def __len__(self): + if self.bitLength is None: + self.setBitLength(self.bit_length()) + + return self.bitLength + + +class BitString(base.SimpleAsn1Type): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type both Python :class:`tuple` (as a tuple + of bits) and :class:`int` objects. + + Keyword Args + ------------ + value: :class:`int`, :class:`str` or |ASN.1| object + Python :class:`int` or :class:`str` literal representing binary + or hexadecimal number or sequence of integer bits or |ASN.1| object. + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + namedValues: :py:class:`~pyasn1.type.namedval.NamedValues` + Object representing non-default symbolic aliases for numbers + + binValue: :py:class:`str` + Binary string initializer to use instead of the *value*. + Example: '10110011'. + + hexValue: :py:class:`str` + Hexadecimal string initializer to use instead of the *value*. + Example: 'DEADBEEF'. + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + .. code-block:: python + + class Rights(BitString): + ''' + ASN.1 specification: + + Rights ::= BIT STRING { user-read(0), user-write(1), + group-read(2), group-write(3), + other-read(4), other-write(5) } + + group1 Rights ::= { group-read, group-write } + group2 Rights ::= '0011'B + group3 Rights ::= '3'H + ''' + namedValues = NamedValues( + ('user-read', 0), ('user-write', 1), + ('group-read', 2), ('group-write', 3), + ('other-read', 4), ('other-write', 5) + ) + + group1 = Rights(('group-read', 'group-write')) + group2 = Rights('0011') + group3 = Rights(0x3) + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x03) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + #: Default :py:class:`~pyasn1.type.namedval.NamedValues` object + #: representing symbolic aliases for numbers + namedValues = namedval.NamedValues() + + # Optimization for faster codec lookup + typeId = base.SimpleAsn1Type.getTypeId() + + defaultBinValue = defaultHexValue = noValue + + def __init__(self, value=noValue, **kwargs): + if value is noValue: + if kwargs: + try: + value = self.fromBinaryString(kwargs.pop('binValue'), internalFormat=True) + + except KeyError: + pass + + try: + value = self.fromHexString(kwargs.pop('hexValue'), internalFormat=True) + + except KeyError: + pass + + if value is noValue: + if self.defaultBinValue is not noValue: + value = self.fromBinaryString(self.defaultBinValue, internalFormat=True) + + elif self.defaultHexValue is not noValue: + value = self.fromHexString(self.defaultHexValue, internalFormat=True) + + if 'namedValues' not in kwargs: + kwargs['namedValues'] = self.namedValues + + base.SimpleAsn1Type.__init__(self, value, **kwargs) + + def __str__(self): + return self.asBinary() + + def __eq__(self, other): + other = self.prettyIn(other) + return self is other or self._value == other and len(self._value) == len(other) + + def __ne__(self, other): + other = self.prettyIn(other) + return self._value != other or len(self._value) != len(other) + + def __lt__(self, other): + other = self.prettyIn(other) + return len(self._value) < len(other) or len(self._value) == len(other) and self._value < other + + def __le__(self, other): + other = self.prettyIn(other) + return len(self._value) <= len(other) or len(self._value) == len(other) and self._value <= other + + def __gt__(self, other): + other = self.prettyIn(other) + return len(self._value) > len(other) or len(self._value) == len(other) and self._value > other + + def __ge__(self, other): + other = self.prettyIn(other) + return len(self._value) >= len(other) or len(self._value) == len(other) and self._value >= other + + # Immutable sequence object protocol + + def __len__(self): + return len(self._value) + + def __getitem__(self, i): + if i.__class__ is slice: + return self.clone([self[x] for x in range(*i.indices(len(self)))]) + else: + length = len(self._value) - 1 + if i > length or i < 0: + raise IndexError('bit index out of range') + return (self._value >> (length - i)) & 1 + + def __iter__(self): + length = len(self._value) + while length: + length -= 1 + yield (self._value >> length) & 1 + + def __reversed__(self): + return reversed(tuple(self)) + + # arithmetic operators + + def __add__(self, value): + value = self.prettyIn(value) + return self.clone(SizedInteger(self._value << len(value) | value).setBitLength(len(self._value) + len(value))) + + def __radd__(self, value): + value = self.prettyIn(value) + return self.clone(SizedInteger(value << len(self._value) | self._value).setBitLength(len(self._value) + len(value))) + + def __mul__(self, value): + bitString = self._value + while value > 1: + bitString <<= len(self._value) + bitString |= self._value + value -= 1 + return self.clone(bitString) + + def __rmul__(self, value): + return self * value + + def __lshift__(self, count): + return self.clone(SizedInteger(self._value << count).setBitLength(len(self._value) + count)) + + def __rshift__(self, count): + return self.clone(SizedInteger(self._value >> count).setBitLength(max(0, len(self._value) - count))) + + def __int__(self): + return int(self._value) + + def __float__(self): + return float(self._value) + + def asNumbers(self): + """Get |ASN.1| value as a sequence of 8-bit integers. + + If |ASN.1| object length is not a multiple of 8, result + will be left-padded with zeros. + """ + return tuple(self.asOctets()) + + def asOctets(self): + """Get |ASN.1| value as a sequence of octets. + + If |ASN.1| object length is not a multiple of 8, result + will be left-padded with zeros. + """ + return integer.to_bytes(self._value, length=len(self)) + + def asInteger(self): + """Get |ASN.1| value as a single integer value. + """ + return self._value + + def asBinary(self): + """Get |ASN.1| value as a text string of bits. + """ + binString = bin(self._value)[2:] + return '0' * (len(self._value) - len(binString)) + binString + + @classmethod + def fromHexString(cls, value, internalFormat=False, prepend=None): + """Create a |ASN.1| object initialized from the hex string. + + Parameters + ---------- + value: :class:`str` + Text string like 'DEADBEEF' + """ + try: + value = SizedInteger(value, 16).setBitLength(len(value) * 4) + + except ValueError as exc: + raise error.PyAsn1Error('%s.fromHexString() error: %s' % (cls.__name__, exc)) + + if prepend is not None: + value = SizedInteger( + (SizedInteger(prepend) << len(value)) | value + ).setBitLength(len(prepend) + len(value)) + + if not internalFormat: + value = cls(value) + + return value + + @classmethod + def fromBinaryString(cls, value, internalFormat=False, prepend=None): + """Create a |ASN.1| object initialized from a string of '0' and '1'. + + Parameters + ---------- + value: :class:`str` + Text string like '1010111' + """ + try: + value = SizedInteger(value or '0', 2).setBitLength(len(value)) + + except ValueError as exc: + raise error.PyAsn1Error('%s.fromBinaryString() error: %s' % (cls.__name__, exc)) + + if prepend is not None: + value = SizedInteger( + (SizedInteger(prepend) << len(value)) | value + ).setBitLength(len(prepend) + len(value)) + + if not internalFormat: + value = cls(value) + + return value + + @classmethod + def fromOctetString(cls, value, internalFormat=False, prepend=None, padding=0): + """Create a |ASN.1| object initialized from a string. + + Parameters + ---------- + value: :class:`bytes` + Text string like b'\\\\x01\\\\xff' + """ + value = SizedInteger(int.from_bytes(bytes(value), 'big') >> padding).setBitLength(len(value) * 8 - padding) + + if prepend is not None: + value = SizedInteger( + (SizedInteger(prepend) << len(value)) | value + ).setBitLength(len(prepend) + len(value)) + + if not internalFormat: + value = cls(value) + + return value + + def prettyIn(self, value): + if isinstance(value, SizedInteger): + return value + elif isinstance(value, str): + if not value: + return SizedInteger(0).setBitLength(0) + + elif value[0] == '\'': # "'1011'B" -- ASN.1 schema representation (deprecated) + if value[-2:] == '\'B': + return self.fromBinaryString(value[1:-2], internalFormat=True) + elif value[-2:] == '\'H': + return self.fromHexString(value[1:-2], internalFormat=True) + else: + raise error.PyAsn1Error( + 'Bad BIT STRING value notation %s' % (value,) + ) + + elif self.namedValues and not value.isdigit(): # named bits like 'Urgent, Active' + names = [x.strip() for x in value.split(',')] + + try: + + bitPositions = [self.namedValues[name] for name in names] + + except KeyError: + raise error.PyAsn1Error('unknown bit name(s) in %r' % (names,)) + + rightmostPosition = max(bitPositions) + + number = 0 + for bitPosition in bitPositions: + number |= 1 << (rightmostPosition - bitPosition) + + return SizedInteger(number).setBitLength(rightmostPosition + 1) + + elif value.startswith('0x'): + return self.fromHexString(value[2:], internalFormat=True) + + elif value.startswith('0b'): + return self.fromBinaryString(value[2:], internalFormat=True) + + else: # assume plain binary string like '1011' + return self.fromBinaryString(value, internalFormat=True) + + elif isinstance(value, (tuple, list)): + return self.fromBinaryString(''.join([b and '1' or '0' for b in value]), internalFormat=True) + + elif isinstance(value, BitString): + return SizedInteger(value).setBitLength(len(value)) + + elif isinstance(value, int): + return SizedInteger(value) + + else: + raise error.PyAsn1Error( + 'Bad BitString initializer type \'%s\'' % (value,) + ) + + +class OctetString(base.SimpleAsn1Type): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type :class:`bytes`. + When used in Unicode context, |ASN.1| type + assumes "|encoding|" serialisation. + + Keyword Args + ------------ + value: :class:`unicode`, :class:`str`, :class:`bytes` or |ASN.1| object + :class:`bytes`, alternatively :class:`str` + representing character string to be serialised into octets + (note `encoding` parameter) or |ASN.1| object. + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + encoding: :py:class:`str` + Unicode codec ID to encode/decode + :class:`str` the payload when |ASN.1| object is used + in text string context. + + binValue: :py:class:`str` + Binary string initializer to use instead of the *value*. + Example: '10110011'. + + hexValue: :py:class:`str` + Hexadecimal string initializer to use instead of the *value*. + Example: 'DEADBEEF'. + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + .. code-block:: python + + class Icon(OctetString): + ''' + ASN.1 specification: + + Icon ::= OCTET STRING + + icon1 Icon ::= '001100010011001000110011'B + icon2 Icon ::= '313233'H + ''' + icon1 = Icon.fromBinaryString('001100010011001000110011') + icon2 = Icon.fromHexString('313233') + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x04) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Optimization for faster codec lookup + typeId = base.SimpleAsn1Type.getTypeId() + + defaultBinValue = defaultHexValue = noValue + encoding = 'iso-8859-1' + + def __init__(self, value=noValue, **kwargs): + if kwargs: + if value is noValue: + try: + value = self.fromBinaryString(kwargs.pop('binValue')) + + except KeyError: + pass + + try: + value = self.fromHexString(kwargs.pop('hexValue')) + + except KeyError: + pass + + if value is noValue: + if self.defaultBinValue is not noValue: + value = self.fromBinaryString(self.defaultBinValue) + + elif self.defaultHexValue is not noValue: + value = self.fromHexString(self.defaultHexValue) + + if 'encoding' not in kwargs: + kwargs['encoding'] = self.encoding + + base.SimpleAsn1Type.__init__(self, value, **kwargs) + + def prettyIn(self, value): + if isinstance(value, bytes): + return value + + elif isinstance(value, str): + try: + return value.encode(self.encoding) + + except UnicodeEncodeError as exc: + raise error.PyAsn1UnicodeEncodeError( + "Can't encode string '%s' with '%s' " + "codec" % (value, self.encoding), exc + ) + elif isinstance(value, OctetString): # a shortcut, bytes() would work the same way + return value.asOctets() + + elif isinstance(value, base.SimpleAsn1Type): # this mostly targets Integer objects + return self.prettyIn(str(value)) + + elif isinstance(value, (tuple, list)): + return self.prettyIn(bytes(value)) + + else: + return bytes(value) + + def __str__(self): + try: + return self._value.decode(self.encoding) + + except UnicodeDecodeError as exc: + raise error.PyAsn1UnicodeDecodeError( + "Can't decode string '%s' with '%s' codec at " + "'%s'" % (self._value, self.encoding, + self.__class__.__name__), exc + ) + + def __bytes__(self): + return bytes(self._value) + + def asOctets(self): + return bytes(self._value) + + def asNumbers(self): + return tuple(self._value) + + # + # Normally, `.prettyPrint()` is called from `__str__()`. Historically, + # OctetString.prettyPrint() used to return hexified payload + # representation in cases when non-printable content is present. At the + # same time `str()` used to produce either octet-stream (Py2) or + # text (Py3) representations. + # + # Therefore `OctetString.__str__()` -> `.prettyPrint()` call chain is + # reversed to preserve the original behaviour. + # + # Eventually we should deprecate `.prettyPrint()` / `.prettyOut()` harness + # and end up with just `__str__()` producing hexified representation while + # both text and octet-stream representation should only be requested via + # the `.asOctets()` method. + # + # Note: ASN.1 OCTET STRING is never mean to contain text! + # + + def prettyOut(self, value): + return value + + def prettyPrint(self, scope=0): + # first see if subclass has its own .prettyOut() + value = self.prettyOut(self._value) + + if value is not self._value: + return value + + numbers = self.asNumbers() + + for x in numbers: + # hexify if needed + if x < 32 or x > 126: + return '0x' + ''.join(('%.2x' % x for x in numbers)) + else: + # this prevents infinite recursion + return OctetString.__str__(self) + + @staticmethod + def fromBinaryString(value): + """Create a |ASN.1| object initialized from a string of '0' and '1'. + + Parameters + ---------- + value: :class:`str` + Text string like '1010111' + """ + bitNo = 8 + byte = 0 + r = [] + for v in value: + if bitNo: + bitNo -= 1 + else: + bitNo = 7 + r.append(byte) + byte = 0 + if v in ('0', '1'): + v = int(v) + else: + raise error.PyAsn1Error( + 'Non-binary OCTET STRING initializer %s' % (v,) + ) + byte |= v << bitNo + + r.append(byte) + + return bytes(r) + + @staticmethod + def fromHexString(value): + """Create a |ASN.1| object initialized from the hex string. + + Parameters + ---------- + value: :class:`str` + Text string like 'DEADBEEF' + """ + r = [] + p = [] + for v in value: + if p: + r.append(int(p + v, 16)) + p = None + else: + p = v + if p: + r.append(int(p + '0', 16)) + + return bytes(r) + + # Immutable sequence object protocol + + def __len__(self): + return len(self._value) + + def __getitem__(self, i): + if i.__class__ is slice: + return self.clone(self._value[i]) + else: + return self._value[i] + + def __iter__(self): + return iter(self._value) + + def __contains__(self, value): + return value in self._value + + def __add__(self, value): + return self.clone(self._value + self.prettyIn(value)) + + def __radd__(self, value): + return self.clone(self.prettyIn(value) + self._value) + + def __mul__(self, value): + return self.clone(self._value * value) + + def __rmul__(self, value): + return self * value + + def __int__(self): + return int(self._value) + + def __float__(self): + return float(self._value) + + def __reversed__(self): + return reversed(self._value) + + +class Null(OctetString): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type Python :class:`str` objects + (always empty). + + Keyword Args + ------------ + value: :class:`str` or |ASN.1| object + Python empty :class:`str` literal or any object that evaluates to :obj:`False` + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + .. code-block:: python + + class Ack(Null): + ''' + ASN.1 specification: + + Ack ::= NULL + ''' + ack = Ack('') + """ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x05) + ) + subtypeSpec = OctetString.subtypeSpec + constraint.SingleValueConstraint(b'') + + # Optimization for faster codec lookup + typeId = OctetString.getTypeId() + + def prettyIn(self, value): + if value: + return value + + return b'' + + +class ObjectIdentifier(base.SimpleAsn1Type): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type Python :class:`tuple` objects + (tuple of non-negative integers). + + Keyword Args + ------------ + value: :class:`tuple`, :class:`str` or |ASN.1| object + Python sequence of :class:`int` or :class:`str` literal or |ASN.1| object. + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + .. code-block:: python + + class ID(ObjectIdentifier): + ''' + ASN.1 specification: + + ID ::= OBJECT IDENTIFIER + + id-edims ID ::= { joint-iso-itu-t mhs-motif(6) edims(7) } + id-bp ID ::= { id-edims 11 } + ''' + id_edims = ID('2.6.7') + id_bp = id_edims + (11,) + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x06) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Optimization for faster codec lookup + typeId = base.SimpleAsn1Type.getTypeId() + + def __add__(self, other): + return self.clone(self._value + other) + + def __radd__(self, other): + return self.clone(other + self._value) + + def asTuple(self): + return self._value + + # Sequence object protocol + + def __len__(self): + return len(self._value) + + def __getitem__(self, i): + if i.__class__ is slice: + return self.clone(self._value[i]) + else: + return self._value[i] + + def __iter__(self): + return iter(self._value) + + def __contains__(self, value): + return value in self._value + + def index(self, suboid): + return self._value.index(suboid) + + def isPrefixOf(self, other): + """Indicate if this |ASN.1| object is a prefix of other |ASN.1| object. + + Parameters + ---------- + other: |ASN.1| object + |ASN.1| object + + Returns + ------- + : :class:`bool` + :obj:`True` if this |ASN.1| object is a parent (e.g. prefix) of the other |ASN.1| object + or :obj:`False` otherwise. + """ + l = len(self) + if l <= len(other): + if self._value[:l] == other[:l]: + return True + return False + + def prettyIn(self, value): + if isinstance(value, ObjectIdentifier): + return tuple(value) + elif isinstance(value, str): + if '-' in value: + raise error.PyAsn1Error( + # sys.exc_info in case prettyIn was called while handling an exception + 'Malformed Object ID %s at %s: %s' % (value, self.__class__.__name__, sys.exc_info()[1]) + ) + try: + return tuple([int(subOid) for subOid in value.split('.') if subOid]) + except ValueError as exc: + raise error.PyAsn1Error( + 'Malformed Object ID %s at %s: %s' % (value, self.__class__.__name__, exc) + ) + + try: + tupleOfInts = tuple([int(subOid) for subOid in value if subOid >= 0]) + + except (ValueError, TypeError) as exc: + raise error.PyAsn1Error( + 'Malformed Object ID %s at %s: %s' % (value, self.__class__.__name__, exc) + ) + + if len(tupleOfInts) == len(value): + return tupleOfInts + + raise error.PyAsn1Error('Malformed Object ID %s at %s' % (value, self.__class__.__name__)) + + def prettyOut(self, value): + return '.'.join([str(x) for x in value]) + + +class RelativeOID(base.SimpleAsn1Type): + """Create |ASN.1| schema or value object. + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type Python :class:`tuple` objects + (tuple of non-negative integers). + Keyword Args + ------------ + value: :class:`tuple`, :class:`str` or |ASN.1| object + Python sequence of :class:`int` or :class:`str` literal or |ASN.1| object. + If `value` is not given, schema object will be created. + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + Examples + -------- + .. code-block:: python + class RelOID(RelativeOID): + ''' + ASN.1 specification: + id-pad-null RELATIVE-OID ::= { 0 } + id-pad-once RELATIVE-OID ::= { 5 6 } + id-pad-twice RELATIVE-OID ::= { 5 6 7 } + ''' + id_pad_null = RelOID('0') + id_pad_once = RelOID('5.6') + id_pad_twice = id_pad_once + (7,) + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x0d) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Optimization for faster codec lookup + typeId = base.SimpleAsn1Type.getTypeId() + + def __add__(self, other): + return self.clone(self._value + other) + + def __radd__(self, other): + return self.clone(other + self._value) + + def asTuple(self): + return self._value + + # Sequence object protocol + + def __len__(self): + return len(self._value) + + def __getitem__(self, i): + if i.__class__ is slice: + return self.clone(self._value[i]) + else: + return self._value[i] + + def __iter__(self): + return iter(self._value) + + def __contains__(self, value): + return value in self._value + + def index(self, suboid): + return self._value.index(suboid) + + def isPrefixOf(self, other): + """Indicate if this |ASN.1| object is a prefix of other |ASN.1| object. + Parameters + ---------- + other: |ASN.1| object + |ASN.1| object + Returns + ------- + : :class:`bool` + :obj:`True` if this |ASN.1| object is a parent (e.g. prefix) of the other |ASN.1| object + or :obj:`False` otherwise. + """ + l = len(self) + if l <= len(other): + if self._value[:l] == other[:l]: + return True + return False + + def prettyIn(self, value): + if isinstance(value, RelativeOID): + return tuple(value) + elif isinstance(value, str): + if '-' in value: + raise error.PyAsn1Error( + # sys.exc_info in case prettyIn was called while handling an exception + 'Malformed RELATIVE-OID %s at %s: %s' % (value, self.__class__.__name__, sys.exc_info()[1]) + ) + try: + return tuple([int(subOid) for subOid in value.split('.') if subOid]) + except ValueError as exc: + raise error.PyAsn1Error( + 'Malformed RELATIVE-OID %s at %s: %s' % (value, self.__class__.__name__, exc) + ) + + try: + tupleOfInts = tuple([int(subOid) for subOid in value if subOid >= 0]) + + except (ValueError, TypeError) as exc: + raise error.PyAsn1Error( + 'Malformed RELATIVE-OID %s at %s: %s' % (value, self.__class__.__name__, exc) + ) + + if len(tupleOfInts) == len(value): + return tupleOfInts + + raise error.PyAsn1Error('Malformed RELATIVE-OID %s at %s' % (value, self.__class__.__name__)) + + def prettyOut(self, value): + return '.'.join([str(x) for x in value]) + + +class Real(base.SimpleAsn1Type): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type Python :class:`float` objects. + Additionally, |ASN.1| objects behave like a :class:`tuple` in which case its + elements are mantissa, base and exponent. + + Keyword Args + ------------ + value: :class:`tuple`, :class:`float` or |ASN.1| object + Python sequence of :class:`int` (representing mantissa, base and + exponent) or :class:`float` instance or |ASN.1| object. + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + .. code-block:: python + + class Pi(Real): + ''' + ASN.1 specification: + + Pi ::= REAL + + pi Pi ::= { mantissa 314159, base 10, exponent -5 } + + ''' + pi = Pi((314159, 10, -5)) + """ + binEncBase = None # binEncBase = 16 is recommended for large numbers + + try: + _plusInf = float('inf') + _minusInf = float('-inf') + _inf = _plusInf, _minusInf + + except ValueError: + # Infinity support is platform and Python dependent + _plusInf = _minusInf = None + _inf = () + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x09) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Optimization for faster codec lookup + typeId = base.SimpleAsn1Type.getTypeId() + + @staticmethod + def __normalizeBase10(value): + m, b, e = value + while m and m % 10 == 0: + m //= 10 + e += 1 + return m, b, e + + def prettyIn(self, value): + if isinstance(value, tuple) and len(value) == 3: + if (not isinstance(value[0], (int, float)) or + not isinstance(value[1], int) or + not isinstance(value[2], int)): + raise error.PyAsn1Error('Lame Real value syntax: %s' % (value,)) + if (isinstance(value[0], float) and + self._inf and value[0] in self._inf): + return value[0] + if value[1] not in (2, 10): + raise error.PyAsn1Error( + 'Prohibited base for Real value: %s' % (value[1],) + ) + if value[1] == 10: + value = self.__normalizeBase10(value) + return value + elif isinstance(value, int): + return self.__normalizeBase10((value, 10, 0)) + elif isinstance(value, float) or isinstance(value, str): + if isinstance(value, str): + try: + value = float(value) + except ValueError: + raise error.PyAsn1Error( + 'Bad real value syntax: %s' % (value,) + ) + if self._inf and value in self._inf: + return value + else: + e = 0 + while int(value) != value: + value *= 10 + e -= 1 + return self.__normalizeBase10((int(value), 10, e)) + elif isinstance(value, Real): + return tuple(value) + raise error.PyAsn1Error( + 'Bad real value syntax: %s' % (value,) + ) + + def prettyPrint(self, scope=0): + try: + return self.prettyOut(float(self)) + + except OverflowError: + return '' + + @property + def isPlusInf(self): + """Indicate PLUS-INFINITY object value + + Returns + ------- + : :class:`bool` + :obj:`True` if calling object represents plus infinity + or :obj:`False` otherwise. + + """ + return self._value == self._plusInf + + @property + def isMinusInf(self): + """Indicate MINUS-INFINITY object value + + Returns + ------- + : :class:`bool` + :obj:`True` if calling object represents minus infinity + or :obj:`False` otherwise. + """ + return self._value == self._minusInf + + @property + def isInf(self): + return self._value in self._inf + + def __add__(self, value): + return self.clone(float(self) + value) + + def __radd__(self, value): + return self + value + + def __mul__(self, value): + return self.clone(float(self) * value) + + def __rmul__(self, value): + return self * value + + def __sub__(self, value): + return self.clone(float(self) - value) + + def __rsub__(self, value): + return self.clone(value - float(self)) + + def __mod__(self, value): + return self.clone(float(self) % value) + + def __rmod__(self, value): + return self.clone(value % float(self)) + + def __pow__(self, value, modulo=None): + return self.clone(pow(float(self), value, modulo)) + + def __rpow__(self, value): + return self.clone(pow(value, float(self))) + + def __truediv__(self, value): + return self.clone(float(self) / value) + + def __rtruediv__(self, value): + return self.clone(value / float(self)) + + def __divmod__(self, value): + return self.clone(float(self) // value) + + def __rdivmod__(self, value): + return self.clone(value // float(self)) + + def __int__(self): + return int(float(self)) + + def __float__(self): + if self._value in self._inf: + return self._value + + mantissa, base, exponent = self._value + + if not mantissa: + return 0.0 + + if base == 2: + return math.ldexp(float(mantissa), exponent) + + # base is 10 (prettyIn() rejects everything else); refuse to + # materialize astronomically large integers via pow() + if exponent > sys.float_info.max_10_exp: + raise OverflowError('Real value too large to convert to float') + + return float(mantissa * pow(base, exponent)) + + def __abs__(self): + return self.clone(abs(float(self))) + + def __pos__(self): + return self.clone(+float(self)) + + def __neg__(self): + return self.clone(-float(self)) + + def __round__(self, n=0): + r = round(float(self), n) + if n: + return self.clone(r) + else: + return r + + def __floor__(self): + return self.clone(math.floor(float(self))) + + def __ceil__(self): + return self.clone(math.ceil(float(self))) + + def __trunc__(self): + return self.clone(math.trunc(float(self))) + + def __lt__(self, value): + return float(self) < value + + def __le__(self, value): + return float(self) <= value + + def __eq__(self, value): + return float(self) == value + + def __ne__(self, value): + return float(self) != value + + def __gt__(self, value): + return float(self) > value + + def __ge__(self, value): + return float(self) >= value + + def __bool__(self): + return bool(float(self)) + + __hash__ = base.SimpleAsn1Type.__hash__ + + def __getitem__(self, idx): + if self._value in self._inf: + raise error.PyAsn1Error('Invalid infinite value operation') + else: + return self._value[idx] + + # compatibility stubs + + def isPlusInfinity(self): + return self.isPlusInf + + def isMinusInfinity(self): + return self.isMinusInf + + def isInfinity(self): + return self.isInf + + +class Enumerated(Integer): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, its + objects are immutable and duck-type Python :class:`int` objects. + + Keyword Args + ------------ + value: :class:`int`, :class:`str` or |ASN.1| object + Python :class:`int` or :class:`str` literal or |ASN.1| object. + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + namedValues: :py:class:`~pyasn1.type.namedval.NamedValues` + Object representing non-default symbolic aliases for numbers + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + + .. code-block:: python + + class RadioButton(Enumerated): + ''' + ASN.1 specification: + + RadioButton ::= ENUMERATED { button1(0), button2(1), + button3(2) } + + selected-by-default RadioButton ::= button1 + ''' + namedValues = NamedValues( + ('button1', 0), ('button2', 1), + ('button3', 2) + ) + + selected_by_default = RadioButton('button1') + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 0x0A) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Optimization for faster codec lookup + typeId = Integer.getTypeId() + + #: Default :py:class:`~pyasn1.type.namedval.NamedValues` object + #: representing symbolic aliases for numbers + namedValues = namedval.NamedValues() + + +# "Structured" ASN.1 types + +class SequenceOfAndSetOfBase(base.ConstructedAsn1Type): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.ConstructedAsn1Type`, + its objects are mutable and duck-type Python :class:`list` objects. + + Keyword Args + ------------ + componentType : :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + A pyasn1 object representing ASN.1 type allowed within |ASN.1| type + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type can only occur on explicit + `.isInconsistent` call. + + Examples + -------- + + .. code-block:: python + + class LotteryDraw(SequenceOf): # SetOf is similar + ''' + ASN.1 specification: + + LotteryDraw ::= SEQUENCE OF INTEGER + ''' + componentType = Integer() + + lotteryDraw = LotteryDraw() + lotteryDraw.extend([123, 456, 789]) + """ + def __init__(self, *args, **kwargs): + # support positional params for backward compatibility + if args: + for key, value in zip(('componentType', 'tagSet', + 'subtypeSpec'), args): + if key in kwargs: + raise error.PyAsn1Error('Conflicting positional and keyword params!') + kwargs['componentType'] = value + + self._componentValues = noValue + + base.ConstructedAsn1Type.__init__(self, **kwargs) + + # Python list protocol + + def __getitem__(self, idx): + try: + return self.getComponentByPosition(idx) + + except error.PyAsn1Error as exc: + raise IndexError(exc) + + def __setitem__(self, idx, value): + try: + self.setComponentByPosition(idx, value) + + except error.PyAsn1Error as exc: + raise IndexError(exc) + + def append(self, value): + if self._componentValues is noValue: + pos = 0 + + else: + pos = len(self._componentValues) + + self[pos] = value + + def count(self, value): + return list(self._componentValues.values()).count(value) + + def extend(self, values): + for value in values: + self.append(value) + + if self._componentValues is noValue: + self._componentValues = {} + + def index(self, value, start=0, stop=None): + if stop is None: + stop = len(self) + + indices, values = zip(*self._componentValues.items()) + + # TODO: remove when Py2.5 support is gone + values = list(values) + + try: + return indices[values.index(value, start, stop)] + + except error.PyAsn1Error as exc: + raise ValueError(exc) + + def reverse(self): + self._componentValues.reverse() + + def sort(self, key=None, reverse=False): + self._componentValues = dict( + enumerate(sorted(self._componentValues.values(), + key=key, reverse=reverse))) + + def __len__(self): + if self._componentValues is noValue or not self._componentValues: + return 0 + + return max(self._componentValues) + 1 + + def __iter__(self): + for idx in range(0, len(self)): + yield self.getComponentByPosition(idx) + + def _cloneComponentValues(self, myClone, cloneValueFlag): + for idx, componentValue in self._componentValues.items(): + if componentValue is not noValue: + if isinstance(componentValue, base.ConstructedAsn1Type): + myClone.setComponentByPosition( + idx, componentValue.clone(cloneValueFlag=cloneValueFlag) + ) + else: + myClone.setComponentByPosition(idx, componentValue.clone()) + + def getComponentByPosition(self, idx, default=noValue, instantiate=True): + """Return |ASN.1| type component value by position. + + Equivalent to Python sequence subscription operation (e.g. `[]`). + + Parameters + ---------- + idx : :class:`int` + Component index (zero-based). Must either refer to an existing + component or to N+1 component (if *componentType* is set). In the latter + case a new component type gets instantiated and appended to the |ASN.1| + sequence. + + Keyword Args + ------------ + default: :class:`object` + If set and requested component is a schema object, return the `default` + object instead of the requested component. + + instantiate: :class:`bool` + If :obj:`True` (default), inner component will be automatically instantiated. + If :obj:`False` either existing component or the :class:`NoValue` object will be + returned. + + Returns + ------- + : :py:class:`~pyasn1.type.base.PyAsn1Item` + Instantiate |ASN.1| component type or return existing component value + + Examples + -------- + + .. code-block:: python + + # can also be SetOf + class MySequenceOf(SequenceOf): + componentType = OctetString() + + s = MySequenceOf() + + # returns component #0 with `.isValue` property False + s.getComponentByPosition(0) + + # returns None + s.getComponentByPosition(0, default=None) + + s.clear() + + # returns noValue + s.getComponentByPosition(0, instantiate=False) + + # sets component #0 to OctetString() ASN.1 schema + # object and returns it + s.getComponentByPosition(0, instantiate=True) + + # sets component #0 to ASN.1 value object + s.setComponentByPosition(0, 'ABCD') + + # returns OctetString('ABCD') value object + s.getComponentByPosition(0, instantiate=False) + + s.clear() + + # returns noValue + s.getComponentByPosition(0, instantiate=False) + """ + if isinstance(idx, slice): + indices = tuple(range(len(self))) + return [self.getComponentByPosition(subidx, default, instantiate) + for subidx in indices[idx]] + + if idx < 0: + idx = len(self) + idx + if idx < 0: + raise error.PyAsn1Error( + 'SequenceOf/SetOf index is out of range') + + try: + componentValue = self._componentValues[idx] + + except (KeyError, error.PyAsn1Error): + if not instantiate: + return default + + self.setComponentByPosition(idx) + + componentValue = self._componentValues[idx] + + if default is noValue or componentValue.isValue: + return componentValue + else: + return default + + def setComponentByPosition(self, idx, value=noValue, + verifyConstraints=True, + matchTags=True, + matchConstraints=True): + """Assign |ASN.1| type component by position. + + Equivalent to Python sequence item assignment operation (e.g. `[]`) + or list.append() (when idx == len(self)). + + Parameters + ---------- + idx: :class:`int` + Component index (zero-based). Must either refer to existing + component or to N+1 component. In the latter case a new component + type gets instantiated (if *componentType* is set, or given ASN.1 + object is taken otherwise) and appended to the |ASN.1| sequence. + + Keyword Args + ------------ + value: :class:`object` or :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + A Python value to initialize |ASN.1| component with (if *componentType* is set) + or ASN.1 value object to assign to |ASN.1| component. + If `value` is not given, schema object will be set as a component. + + verifyConstraints: :class:`bool` + If :obj:`False`, skip constraints validation + + matchTags: :class:`bool` + If :obj:`False`, skip component tags matching + + matchConstraints: :class:`bool` + If :obj:`False`, skip component constraints matching + + Returns + ------- + self + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer + IndexError + When idx > len(self) + """ + if isinstance(idx, slice): + indices = tuple(range(len(self))) + startIdx = indices and indices[idx][0] or 0 + for subIdx, subValue in enumerate(value): + self.setComponentByPosition( + startIdx + subIdx, subValue, verifyConstraints, + matchTags, matchConstraints) + return self + + if idx < 0: + idx = len(self) + idx + if idx < 0: + raise error.PyAsn1Error( + 'SequenceOf/SetOf index is out of range') + + componentType = self.componentType + + if self._componentValues is noValue: + componentValues = {} + + else: + componentValues = self._componentValues + + currentValue = componentValues.get(idx, noValue) + + if value is noValue: + if componentType is not None: + value = componentType.clone() + + elif currentValue is noValue: + raise error.PyAsn1Error('Component type not defined') + + elif not isinstance(value, base.Asn1Item): + if (componentType is not None and + isinstance(componentType, base.SimpleAsn1Type)): + value = componentType.clone(value=value) + + elif (currentValue is not noValue and + isinstance(currentValue, base.SimpleAsn1Type)): + value = currentValue.clone(value=value) + + else: + raise error.PyAsn1Error( + 'Non-ASN.1 value %r and undefined component' + ' type at %r' % (value, self)) + + elif componentType is not None and (matchTags or matchConstraints): + subtypeChecker = ( + self.strictConstraints and + componentType.isSameTypeWith or + componentType.isSuperTypeOf) + + if not subtypeChecker(value, verifyConstraints and matchTags, + verifyConstraints and matchConstraints): + # TODO: we should wrap componentType with UnnamedType to carry + # additional properties associated with componentType + if componentType.typeId != Any.typeId: + raise error.PyAsn1Error( + 'Component value is tag-incompatible: %r vs ' + '%r' % (value, componentType)) + + componentValues[idx] = value + + self._componentValues = componentValues + + return self + + @property + def componentTagMap(self): + if self.componentType is not None: + return self.componentType.tagMap + + @property + def components(self): + return [self._componentValues[idx] + for idx in sorted(self._componentValues)] + + def clear(self): + """Remove all components and become an empty |ASN.1| value object. + + Has the same effect on |ASN.1| object as it does on :class:`list` + built-in. + """ + self._componentValues = {} + return self + + def reset(self): + """Remove all components and become a |ASN.1| schema object. + + See :meth:`isValue` property for more information on the + distinction between value and schema objects. + """ + self._componentValues = noValue + return self + + def prettyPrint(self, scope=0): + scope += 1 + representation = self.__class__.__name__ + ':\n' + + if not self.isValue: + return representation + + for idx, componentValue in enumerate(self): + representation += ' ' * scope + if (componentValue is noValue and + self.componentType is not None): + representation += '' + else: + representation += componentValue.prettyPrint(scope) + + return representation + + def prettyPrintType(self, scope=0): + scope += 1 + representation = '%s -> %s {\n' % (self.tagSet, self.__class__.__name__) + if self.componentType is not None: + representation += ' ' * scope + representation += self.componentType.prettyPrintType(scope) + return representation + '\n' + ' ' * (scope - 1) + '}' + + + @property + def isValue(self): + """Indicate that |ASN.1| object represents ASN.1 value. + + If *isValue* is :obj:`False` then this object represents just ASN.1 schema. + + If *isValue* is :obj:`True` then, in addition to its ASN.1 schema features, + this object can also be used like a Python built-in object + (e.g. :class:`int`, :class:`str`, :class:`dict` etc.). + + Returns + ------- + : :class:`bool` + :obj:`False` if object represents just ASN.1 schema. + :obj:`True` if object represents ASN.1 schema and can be used as a normal value. + + Note + ---- + There is an important distinction between PyASN1 schema and value objects. + The PyASN1 schema objects can only participate in ASN.1 schema-related + operations (e.g. defining or testing the structure of the data). Most + obvious uses of ASN.1 schema is to guide serialisation codecs whilst + encoding/decoding serialised ASN.1 contents. + + The PyASN1 value objects can **additionally** participate in many operations + involving regular Python objects (e.g. arithmetic, comprehension etc). + """ + if self._componentValues is noValue: + return False + + if len(self._componentValues) != len(self): + return False + + for componentValue in self._componentValues.values(): + if componentValue is noValue or not componentValue.isValue: + return False + + return True + + @property + def isInconsistent(self): + """Run necessary checks to ensure |ASN.1| object consistency. + + Default action is to verify |ASN.1| object against constraints imposed + by `subtypeSpec`. + + Raises + ------ + :py:class:`~pyasn1.error.PyAsn1tError` on any inconsistencies found + """ + if self.componentType is noValue or not self.subtypeSpec: + return False + + if self._componentValues is noValue: + return True + + mapping = {} + + for idx, value in self._componentValues.items(): + # Absent fields are not in the mapping + if value is noValue: + continue + + mapping[idx] = value + + try: + # Represent SequenceOf/SetOf as a bare dict to constraints chain + self.subtypeSpec(mapping) + + except error.PyAsn1Error as exc: + return exc + + return False + +class SequenceOf(SequenceOfAndSetOfBase): + __doc__ = SequenceOfAndSetOfBase.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatConstructed, 0x10) + ) + + #: Default :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + #: object representing ASN.1 type allowed within |ASN.1| type + componentType = None + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Disambiguation ASN.1 types identification + typeId = SequenceOfAndSetOfBase.getTypeId() + + +class SetOf(SequenceOfAndSetOfBase): + __doc__ = SequenceOfAndSetOfBase.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatConstructed, 0x11) + ) + + #: Default :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + #: object representing ASN.1 type allowed within |ASN.1| type + componentType = None + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Disambiguation ASN.1 types identification + typeId = SequenceOfAndSetOfBase.getTypeId() + + +class SequenceAndSetBase(base.ConstructedAsn1Type): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.ConstructedAsn1Type`, + its objects are mutable and duck-type Python :class:`dict` objects. + + Keyword Args + ------------ + componentType: :py:class:`~pyasn1.type.namedtype.NamedType` + Object holding named ASN.1 types allowed within this collection + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type can only occur on explicit + `.isInconsistent` call. + + Examples + -------- + + .. code-block:: python + + class Description(Sequence): # Set is similar + ''' + ASN.1 specification: + + Description ::= SEQUENCE { + surname IA5String, + first-name IA5String OPTIONAL, + age INTEGER DEFAULT 40 + } + ''' + componentType = NamedTypes( + NamedType('surname', IA5String()), + OptionalNamedType('first-name', IA5String()), + DefaultedNamedType('age', Integer(40)) + ) + + descr = Description() + descr['surname'] = 'Smith' + descr['first-name'] = 'John' + """ + #: Default :py:class:`~pyasn1.type.namedtype.NamedTypes` + #: object representing named ASN.1 types allowed within |ASN.1| type + componentType = namedtype.NamedTypes() + + + class DynamicNames(object): + """Fields names/positions mapping for component-less objects""" + def __init__(self): + self._keyToIdxMap = {} + self._idxToKeyMap = {} + + def __len__(self): + return len(self._keyToIdxMap) + + def __contains__(self, item): + return item in self._keyToIdxMap or item in self._idxToKeyMap + + def __iter__(self): + return (self._idxToKeyMap[idx] for idx in range(len(self._idxToKeyMap))) + + def __getitem__(self, item): + try: + return self._keyToIdxMap[item] + + except KeyError: + return self._idxToKeyMap[item] + + def getNameByPosition(self, idx): + try: + return self._idxToKeyMap[idx] + + except KeyError: + raise error.PyAsn1Error('Type position out of range') + + def getPositionByName(self, name): + try: + return self._keyToIdxMap[name] + + except KeyError: + raise error.PyAsn1Error('Name %s not found' % (name,)) + + def addField(self, idx): + self._keyToIdxMap['field-%d' % idx] = idx + self._idxToKeyMap[idx] = 'field-%d' % idx + + + def __init__(self, **kwargs): + base.ConstructedAsn1Type.__init__(self, **kwargs) + self._componentTypeLen = len(self.componentType) + if self._componentTypeLen: + self._componentValues = [] + else: + self._componentValues = noValue + self._dynamicNames = self._componentTypeLen or self.DynamicNames() + + def __getitem__(self, idx): + if isinstance(idx, str): + try: + return self.getComponentByName(idx) + + except error.PyAsn1Error as exc: + # duck-typing dict + raise KeyError(exc) + + else: + try: + return self.getComponentByPosition(idx) + + except error.PyAsn1Error as exc: + # duck-typing list + raise IndexError(exc) + + def __setitem__(self, idx, value): + if isinstance(idx, str): + try: + self.setComponentByName(idx, value) + + except error.PyAsn1Error as exc: + # duck-typing dict + raise KeyError(exc) + + else: + try: + self.setComponentByPosition(idx, value) + + except error.PyAsn1Error as exc: + # duck-typing list + raise IndexError(exc) + + def __contains__(self, key): + if self._componentTypeLen: + return key in self.componentType + else: + return key in self._dynamicNames + + def __len__(self): + return len(self._componentValues) + + def __iter__(self): + return iter(self.componentType or self._dynamicNames) + + # Python dict protocol + + def values(self): + for idx in range(self._componentTypeLen or len(self._dynamicNames)): + yield self[idx] + + def keys(self): + return iter(self) + + def items(self): + for idx in range(self._componentTypeLen or len(self._dynamicNames)): + if self._componentTypeLen: + yield self.componentType[idx].name, self[idx] + else: + yield self._dynamicNames[idx], self[idx] + + def update(self, *iterValue, **mappingValue): + for k, v in iterValue: + self[k] = v + for k in mappingValue: + self[k] = mappingValue[k] + + def clear(self): + """Remove all components and become an empty |ASN.1| value object. + + Has the same effect on |ASN.1| object as it does on :class:`dict` + built-in. + """ + self._componentValues = [] + self._dynamicNames = self.DynamicNames() + return self + + def reset(self): + """Remove all components and become a |ASN.1| schema object. + + See :meth:`isValue` property for more information on the + distinction between value and schema objects. + """ + self._componentValues = noValue + self._dynamicNames = self.DynamicNames() + return self + + @property + def components(self): + return self._componentValues + + def _cloneComponentValues(self, myClone, cloneValueFlag): + if self._componentValues is noValue: + return + + for idx, componentValue in enumerate(self._componentValues): + if componentValue is not noValue: + if isinstance(componentValue, base.ConstructedAsn1Type): + myClone.setComponentByPosition( + idx, componentValue.clone(cloneValueFlag=cloneValueFlag) + ) + else: + myClone.setComponentByPosition(idx, componentValue.clone()) + + def getComponentByName(self, name, default=noValue, instantiate=True): + """Returns |ASN.1| type component by name. + + Equivalent to Python :class:`dict` subscription operation (e.g. `[]`). + + Parameters + ---------- + name: :class:`str` + |ASN.1| type component name + + Keyword Args + ------------ + default: :class:`object` + If set and requested component is a schema object, return the `default` + object instead of the requested component. + + instantiate: :class:`bool` + If :obj:`True` (default), inner component will be automatically + instantiated. + If :obj:`False` either existing component or the :class:`NoValue` + object will be returned. + + Returns + ------- + : :py:class:`~pyasn1.type.base.PyAsn1Item` + Instantiate |ASN.1| component type or return existing + component value + """ + if self._componentTypeLen: + idx = self.componentType.getPositionByName(name) + else: + try: + idx = self._dynamicNames.getPositionByName(name) + + except KeyError: + raise error.PyAsn1Error('Name %s not found' % (name,)) + + return self.getComponentByPosition(idx, default=default, instantiate=instantiate) + + def setComponentByName(self, name, value=noValue, + verifyConstraints=True, + matchTags=True, + matchConstraints=True): + """Assign |ASN.1| type component by name. + + Equivalent to Python :class:`dict` item assignment operation (e.g. `[]`). + + Parameters + ---------- + name: :class:`str` + |ASN.1| type component name + + Keyword Args + ------------ + value: :class:`object` or :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + A Python value to initialize |ASN.1| component with (if *componentType* is set) + or ASN.1 value object to assign to |ASN.1| component. + If `value` is not given, schema object will be set as a component. + + verifyConstraints: :class:`bool` + If :obj:`False`, skip constraints validation + + matchTags: :class:`bool` + If :obj:`False`, skip component tags matching + + matchConstraints: :class:`bool` + If :obj:`False`, skip component constraints matching + + Returns + ------- + self + """ + if self._componentTypeLen: + idx = self.componentType.getPositionByName(name) + else: + try: + idx = self._dynamicNames.getPositionByName(name) + + except KeyError: + raise error.PyAsn1Error('Name %s not found' % (name,)) + + return self.setComponentByPosition( + idx, value, verifyConstraints, matchTags, matchConstraints + ) + + def getComponentByPosition(self, idx, default=noValue, instantiate=True): + """Returns |ASN.1| type component by index. + + Equivalent to Python sequence subscription operation (e.g. `[]`). + + Parameters + ---------- + idx: :class:`int` + Component index (zero-based). Must either refer to an existing + component or (if *componentType* is set) new ASN.1 schema object gets + instantiated. + + Keyword Args + ------------ + default: :class:`object` + If set and requested component is a schema object, return the `default` + object instead of the requested component. + + instantiate: :class:`bool` + If :obj:`True` (default), inner component will be automatically + instantiated. + If :obj:`False` either existing component or the :class:`NoValue` + object will be returned. + + Returns + ------- + : :py:class:`~pyasn1.type.base.PyAsn1Item` + a PyASN1 object + + Examples + -------- + + .. code-block:: python + + # can also be Set + class MySequence(Sequence): + componentType = NamedTypes( + NamedType('id', OctetString()) + ) + + s = MySequence() + + # returns component #0 with `.isValue` property False + s.getComponentByPosition(0) + + # returns None + s.getComponentByPosition(0, default=None) + + s.clear() + + # returns noValue + s.getComponentByPosition(0, instantiate=False) + + # sets component #0 to OctetString() ASN.1 schema + # object and returns it + s.getComponentByPosition(0, instantiate=True) + + # sets component #0 to ASN.1 value object + s.setComponentByPosition(0, 'ABCD') + + # returns OctetString('ABCD') value object + s.getComponentByPosition(0, instantiate=False) + + s.clear() + + # returns noValue + s.getComponentByPosition(0, instantiate=False) + """ + try: + if self._componentValues is noValue: + componentValue = noValue + + else: + componentValue = self._componentValues[idx] + + except IndexError: + componentValue = noValue + + if not instantiate: + if componentValue is noValue or not componentValue.isValue: + return default + else: + return componentValue + + if componentValue is noValue: + self.setComponentByPosition(idx) + + componentValue = self._componentValues[idx] + + if default is noValue or componentValue.isValue: + return componentValue + else: + return default + + def setComponentByPosition(self, idx, value=noValue, + verifyConstraints=True, + matchTags=True, + matchConstraints=True): + """Assign |ASN.1| type component by position. + + Equivalent to Python sequence item assignment operation (e.g. `[]`). + + Parameters + ---------- + idx : :class:`int` + Component index (zero-based). Must either refer to existing + component (if *componentType* is set) or to N+1 component + otherwise. In the latter case a new component of given ASN.1 + type gets instantiated and appended to |ASN.1| sequence. + + Keyword Args + ------------ + value: :class:`object` or :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + A Python value to initialize |ASN.1| component with (if *componentType* is set) + or ASN.1 value object to assign to |ASN.1| component. + If `value` is not given, schema object will be set as a component. + + verifyConstraints : :class:`bool` + If :obj:`False`, skip constraints validation + + matchTags: :class:`bool` + If :obj:`False`, skip component tags matching + + matchConstraints: :class:`bool` + If :obj:`False`, skip component constraints matching + + Returns + ------- + self + """ + componentType = self.componentType + componentTypeLen = self._componentTypeLen + + if self._componentValues is noValue: + componentValues = [] + + else: + componentValues = self._componentValues + + try: + currentValue = componentValues[idx] + + except IndexError: + currentValue = noValue + if componentTypeLen: + if componentTypeLen < idx: + raise error.PyAsn1Error('component index out of range') + + componentValues = [noValue] * componentTypeLen + + if value is noValue: + if componentTypeLen: + value = componentType.getTypeByPosition(idx) + if isinstance(value, base.ConstructedAsn1Type): + value = value.clone(cloneValueFlag=componentType[idx].isDefaulted) + + elif currentValue is noValue: + raise error.PyAsn1Error('Component type not defined') + + elif not isinstance(value, base.Asn1Item): + if componentTypeLen: + subComponentType = componentType.getTypeByPosition(idx) + if isinstance(subComponentType, base.SimpleAsn1Type): + value = subComponentType.clone(value=value) + + else: + raise error.PyAsn1Error('%s can cast only scalar values' % componentType.__class__.__name__) + + elif currentValue is not noValue and isinstance(currentValue, base.SimpleAsn1Type): + value = currentValue.clone(value=value) + + else: + raise error.PyAsn1Error('%s undefined component type' % componentType.__class__.__name__) + + elif ((verifyConstraints or matchTags or matchConstraints) and + componentTypeLen): + subComponentType = componentType.getTypeByPosition(idx) + if subComponentType is not noValue: + subtypeChecker = (self.strictConstraints and + subComponentType.isSameTypeWith or + subComponentType.isSuperTypeOf) + + if not subtypeChecker(value, verifyConstraints and matchTags, + verifyConstraints and matchConstraints): + if not componentType[idx].openType: + raise error.PyAsn1Error('Component value is tag-incompatible: %r vs %r' % (value, componentType)) + + if componentTypeLen or idx in self._dynamicNames: + componentValues[idx] = value + + elif len(componentValues) == idx: + componentValues.append(value) + self._dynamicNames.addField(idx) + + else: + raise error.PyAsn1Error('Component index out of range') + + self._componentValues = componentValues + + return self + + @property + def isValue(self): + """Indicate that |ASN.1| object represents ASN.1 value. + + If *isValue* is :obj:`False` then this object represents just ASN.1 schema. + + If *isValue* is :obj:`True` then, in addition to its ASN.1 schema features, + this object can also be used like a Python built-in object (e.g. + :class:`int`, :class:`str`, :class:`dict` etc.). + + Returns + ------- + : :class:`bool` + :obj:`False` if object represents just ASN.1 schema. + :obj:`True` if object represents ASN.1 schema and can be used as a + normal value. + + Note + ---- + There is an important distinction between PyASN1 schema and value objects. + The PyASN1 schema objects can only participate in ASN.1 schema-related + operations (e.g. defining or testing the structure of the data). Most + obvious uses of ASN.1 schema is to guide serialisation codecs whilst + encoding/decoding serialised ASN.1 contents. + + The PyASN1 value objects can **additionally** participate in many operations + involving regular Python objects (e.g. arithmetic, comprehension etc). + + It is sufficient for |ASN.1| objects to have all non-optional and non-defaulted + components being value objects to be considered as a value objects as a whole. + In other words, even having one or more optional components not turned into + value objects, |ASN.1| object is still considered as a value object. Defaulted + components are normally value objects by default. + """ + if self._componentValues is noValue: + return False + + componentType = self.componentType + + if componentType: + for idx, subComponentType in enumerate(componentType.namedTypes): + if subComponentType.isDefaulted or subComponentType.isOptional: + continue + + if not self._componentValues: + return False + + componentValue = self._componentValues[idx] + if componentValue is noValue or not componentValue.isValue: + return False + + else: + for componentValue in self._componentValues: + if componentValue is noValue or not componentValue.isValue: + return False + + return True + + @property + def isInconsistent(self): + """Run necessary checks to ensure |ASN.1| object consistency. + + Default action is to verify |ASN.1| object against constraints imposed + by `subtypeSpec`. + + Raises + ------ + :py:class:`~pyasn1.error.PyAsn1tError` on any inconsistencies found + """ + if self.componentType is noValue or not self.subtypeSpec: + return False + + if self._componentValues is noValue: + return True + + mapping = {} + + for idx, value in enumerate(self._componentValues): + # Absent fields are not in the mapping + if value is noValue: + continue + + name = self.componentType.getNameByPosition(idx) + + mapping[name] = value + + try: + # Represent Sequence/Set as a bare dict to constraints chain + self.subtypeSpec(mapping) + + except error.PyAsn1Error as exc: + return exc + + return False + + def prettyPrint(self, scope=0): + """Return an object representation string. + + Returns + ------- + : :class:`str` + Human-friendly object representation. + """ + scope += 1 + representation = self.__class__.__name__ + ':\n' + for idx, componentValue in enumerate(self._componentValues): + if componentValue is not noValue and componentValue.isValue: + representation += ' ' * scope + if self.componentType: + representation += self.componentType.getNameByPosition(idx) + else: + representation += self._dynamicNames.getNameByPosition(idx) + representation = '%s=%s\n' % ( + representation, componentValue.prettyPrint(scope) + ) + return representation + + def prettyPrintType(self, scope=0): + scope += 1 + representation = '%s -> %s {\n' % (self.tagSet, self.__class__.__name__) + for idx, componentType in enumerate(self.componentType.values() or self._componentValues): + representation += ' ' * scope + if self.componentType: + representation += '"%s"' % self.componentType.getNameByPosition(idx) + else: + representation += '"%s"' % self._dynamicNames.getNameByPosition(idx) + representation = '%s = %s\n' % ( + representation, componentType.prettyPrintType(scope) + ) + return representation + '\n' + ' ' * (scope - 1) + '}' + + # backward compatibility + + def setDefaultComponents(self): + return self + + def getComponentType(self): + if self._componentTypeLen: + return self.componentType + + def getNameByPosition(self, idx): + if self._componentTypeLen: + return self.componentType[idx].name + +class Sequence(SequenceAndSetBase): + __doc__ = SequenceAndSetBase.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatConstructed, 0x10) + ) + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + #: Default collection of ASN.1 types of component (e.g. :py:class:`~pyasn1.type.namedtype.NamedType`) + #: object imposing size constraint on |ASN.1| objects + componentType = namedtype.NamedTypes() + + # Disambiguation ASN.1 types identification + typeId = SequenceAndSetBase.getTypeId() + + # backward compatibility + + def getComponentTagMapNearPosition(self, idx): + if self.componentType: + return self.componentType.getTagMapNearPosition(idx) + + def getComponentPositionNearType(self, tagSet, idx): + if self.componentType: + return self.componentType.getPositionNearType(tagSet, idx) + else: + return idx + + +class Set(SequenceAndSetBase): + __doc__ = SequenceAndSetBase.__doc__ + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.initTagSet( + tag.Tag(tag.tagClassUniversal, tag.tagFormatConstructed, 0x11) + ) + + #: Default collection of ASN.1 types of component (e.g. :py:class:`~pyasn1.type.namedtype.NamedType`) + #: object representing ASN.1 type allowed within |ASN.1| type + componentType = namedtype.NamedTypes() + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Disambiguation ASN.1 types identification + typeId = SequenceAndSetBase.getTypeId() + + def getComponent(self, innerFlag=False): + return self + + def getComponentByType(self, tagSet, default=noValue, + instantiate=True, innerFlag=False): + """Returns |ASN.1| type component by ASN.1 tag. + + Parameters + ---------- + tagSet : :py:class:`~pyasn1.type.tag.TagSet` + Object representing ASN.1 tags to identify one of + |ASN.1| object component + + Keyword Args + ------------ + default: :class:`object` + If set and requested component is a schema object, return the `default` + object instead of the requested component. + + instantiate: :class:`bool` + If :obj:`True` (default), inner component will be automatically + instantiated. + If :obj:`False` either existing component or the :class:`noValue` + object will be returned. + + Returns + ------- + : :py:class:`~pyasn1.type.base.PyAsn1Item` + a pyasn1 object + """ + componentValue = self.getComponentByPosition( + self.componentType.getPositionByType(tagSet), + default=default, instantiate=instantiate + ) + if innerFlag and isinstance(componentValue, Set): + # get inner component by inner tagSet + return componentValue.getComponent(innerFlag=True) + else: + # get outer component by inner tagSet + return componentValue + + def setComponentByType(self, tagSet, value=noValue, + verifyConstraints=True, + matchTags=True, + matchConstraints=True, + innerFlag=False): + """Assign |ASN.1| type component by ASN.1 tag. + + Parameters + ---------- + tagSet : :py:class:`~pyasn1.type.tag.TagSet` + Object representing ASN.1 tags to identify one of + |ASN.1| object component + + Keyword Args + ------------ + value: :class:`object` or :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + A Python value to initialize |ASN.1| component with (if *componentType* is set) + or ASN.1 value object to assign to |ASN.1| component. + If `value` is not given, schema object will be set as a component. + + verifyConstraints : :class:`bool` + If :obj:`False`, skip constraints validation + + matchTags: :class:`bool` + If :obj:`False`, skip component tags matching + + matchConstraints: :class:`bool` + If :obj:`False`, skip component constraints matching + + innerFlag: :class:`bool` + If :obj:`True`, search for matching *tagSet* recursively. + + Returns + ------- + self + """ + idx = self.componentType.getPositionByType(tagSet) + + if innerFlag: # set inner component by inner tagSet + componentType = self.componentType.getTypeByPosition(idx) + + if componentType.tagSet: + return self.setComponentByPosition( + idx, value, verifyConstraints, matchTags, matchConstraints + ) + else: + componentType = self.getComponentByPosition(idx) + return componentType.setComponentByType( + tagSet, value, verifyConstraints, matchTags, matchConstraints, innerFlag=innerFlag + ) + else: # set outer component by inner tagSet + return self.setComponentByPosition( + idx, value, verifyConstraints, matchTags, matchConstraints + ) + + @property + def componentTagMap(self): + if self.componentType: + return self.componentType.tagMapUnique + + +class Choice(Set): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.ConstructedAsn1Type`, + its objects are mutable and duck-type Python :class:`list` objects. + + Keyword Args + ------------ + componentType: :py:class:`~pyasn1.type.namedtype.NamedType` + Object holding named ASN.1 types allowed within this collection + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type can only occur on explicit + `.isInconsistent` call. + + Examples + -------- + + .. code-block:: python + + class Afters(Choice): + ''' + ASN.1 specification: + + Afters ::= CHOICE { + cheese [0] IA5String, + dessert [1] IA5String + } + ''' + componentType = NamedTypes( + NamedType('cheese', IA5String().subtype( + implicitTag=Tag(tagClassContext, tagFormatSimple, 0) + ), + NamedType('dessert', IA5String().subtype( + implicitTag=Tag(tagClassContext, tagFormatSimple, 1) + ) + ) + + afters = Afters() + afters['cheese'] = 'Mascarpone' + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.TagSet() # untagged + + #: Default collection of ASN.1 types of component (e.g. :py:class:`~pyasn1.type.namedtype.NamedType`) + #: object representing ASN.1 type allowed within |ASN.1| type + componentType = namedtype.NamedTypes() + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection( + constraint.ValueSizeConstraint(1, 1) + ) + + # Disambiguation ASN.1 types identification + typeId = Set.getTypeId() + + _currentIdx = None + + def __eq__(self, other): + if self._componentValues: + return self._componentValues[self._currentIdx] == other + return NotImplemented + + def __ne__(self, other): + if self._componentValues: + return self._componentValues[self._currentIdx] != other + return NotImplemented + + def __lt__(self, other): + if self._componentValues: + return self._componentValues[self._currentIdx] < other + return NotImplemented + + def __le__(self, other): + if self._componentValues: + return self._componentValues[self._currentIdx] <= other + return NotImplemented + + def __gt__(self, other): + if self._componentValues: + return self._componentValues[self._currentIdx] > other + return NotImplemented + + def __ge__(self, other): + if self._componentValues: + return self._componentValues[self._currentIdx] >= other + return NotImplemented + + def __bool__(self): + return bool(self._componentValues) + + def __len__(self): + return self._currentIdx is not None and 1 or 0 + + def __contains__(self, key): + if self._currentIdx is None: + return False + return key == self.componentType[self._currentIdx].getName() + + def __iter__(self): + if self._currentIdx is None: + raise StopIteration + yield self.componentType[self._currentIdx].getName() + + # Python dict protocol + + def values(self): + if self._currentIdx is not None: + yield self._componentValues[self._currentIdx] + + def keys(self): + if self._currentIdx is not None: + yield self.componentType[self._currentIdx].getName() + + def items(self): + if self._currentIdx is not None: + yield self.componentType[self._currentIdx].getName(), self[self._currentIdx] + + def checkConsistency(self): + if self._currentIdx is None: + raise error.PyAsn1Error('Component not chosen') + + def _cloneComponentValues(self, myClone, cloneValueFlag): + try: + component = self.getComponent() + except error.PyAsn1Error: + pass + else: + if isinstance(component, Choice): + tagSet = component.effectiveTagSet + else: + tagSet = component.tagSet + if isinstance(component, base.ConstructedAsn1Type): + myClone.setComponentByType( + tagSet, component.clone(cloneValueFlag=cloneValueFlag) + ) + else: + myClone.setComponentByType(tagSet, component.clone()) + + def getComponentByPosition(self, idx, default=noValue, instantiate=True): + __doc__ = Set.__doc__ + + if self._currentIdx is None or self._currentIdx != idx: + return Set.getComponentByPosition(self, idx, default=default, + instantiate=instantiate) + + return self._componentValues[idx] + + def setComponentByPosition(self, idx, value=noValue, + verifyConstraints=True, + matchTags=True, + matchConstraints=True): + """Assign |ASN.1| type component by position. + + Equivalent to Python sequence item assignment operation (e.g. `[]`). + + Parameters + ---------- + idx: :class:`int` + Component index (zero-based). Must either refer to existing + component or to N+1 component. In the latter case a new component + type gets instantiated (if *componentType* is set, or given ASN.1 + object is taken otherwise) and appended to the |ASN.1| sequence. + + Keyword Args + ------------ + value: :class:`object` or :py:class:`~pyasn1.type.base.PyAsn1Item` derivative + A Python value to initialize |ASN.1| component with (if *componentType* is set) + or ASN.1 value object to assign to |ASN.1| component. Once a new value is + set to *idx* component, previous value is dropped. + If `value` is not given, schema object will be set as a component. + + verifyConstraints : :class:`bool` + If :obj:`False`, skip constraints validation + + matchTags: :class:`bool` + If :obj:`False`, skip component tags matching + + matchConstraints: :class:`bool` + If :obj:`False`, skip component constraints matching + + Returns + ------- + self + """ + oldIdx = self._currentIdx + Set.setComponentByPosition(self, idx, value, verifyConstraints, matchTags, matchConstraints) + self._currentIdx = idx + if oldIdx is not None and oldIdx != idx: + self._componentValues[oldIdx] = noValue + return self + + @property + def effectiveTagSet(self): + """Return a :class:`~pyasn1.type.tag.TagSet` object of the currently initialized component or self (if |ASN.1| is tagged).""" + if self.tagSet: + return self.tagSet + else: + component = self.getComponent() + return component.effectiveTagSet + + @property + def tagMap(self): + """"Return a :class:`~pyasn1.type.tagmap.TagMap` object mapping + ASN.1 tags to ASN.1 objects contained within callee. + """ + if self.tagSet: + return Set.tagMap.fget(self) + else: + return self.componentType.tagMapUnique + + def getComponent(self, innerFlag=False): + """Return currently assigned component of the |ASN.1| object. + + Returns + ------- + : :py:class:`~pyasn1.type.base.PyAsn1Item` + a PyASN1 object + """ + if self._currentIdx is None: + raise error.PyAsn1Error('Component not chosen') + else: + c = self._componentValues[self._currentIdx] + if innerFlag and isinstance(c, Choice): + return c.getComponent(innerFlag) + else: + return c + + def getName(self, innerFlag=False): + """Return the name of currently assigned component of the |ASN.1| object. + + Returns + ------- + : :py:class:`str` + |ASN.1| component name + """ + if self._currentIdx is None: + raise error.PyAsn1Error('Component not chosen') + else: + if innerFlag: + c = self._componentValues[self._currentIdx] + if isinstance(c, Choice): + return c.getName(innerFlag) + return self.componentType.getNameByPosition(self._currentIdx) + + @property + def isValue(self): + """Indicate that |ASN.1| object represents ASN.1 value. + + If *isValue* is :obj:`False` then this object represents just ASN.1 schema. + + If *isValue* is :obj:`True` then, in addition to its ASN.1 schema features, + this object can also be used like a Python built-in object (e.g. + :class:`int`, :class:`str`, :class:`dict` etc.). + + Returns + ------- + : :class:`bool` + :obj:`False` if object represents just ASN.1 schema. + :obj:`True` if object represents ASN.1 schema and can be used as a normal + value. + + Note + ---- + There is an important distinction between PyASN1 schema and value objects. + The PyASN1 schema objects can only participate in ASN.1 schema-related + operations (e.g. defining or testing the structure of the data). Most + obvious uses of ASN.1 schema is to guide serialisation codecs whilst + encoding/decoding serialised ASN.1 contents. + + The PyASN1 value objects can **additionally** participate in many operations + involving regular Python objects (e.g. arithmetic, comprehension etc). + """ + if self._currentIdx is None: + return False + + componentValue = self._componentValues[self._currentIdx] + + return componentValue is not noValue and componentValue.isValue + + def clear(self): + self._currentIdx = None + return Set.clear(self) + + # compatibility stubs + + def getMinTagSet(self): + return self.minTagSet + + +class Any(OctetString): + """Create |ASN.1| schema or value object. + + |ASN.1| class is based on :class:`~pyasn1.type.base.SimpleAsn1Type`, + its objects are immutable and duck-type :class:`bytes`. + When used in Unicode context, |ASN.1| type assumes + "|encoding|" serialisation. + + Keyword Args + ------------ + value: :class:`unicode`, :class:`str`, :class:`bytes` or |ASN.1| object + :class:`bytes`, alternatively :class:`str` + representing character string to be serialised into octets (note + `encoding` parameter) or |ASN.1| object. + If `value` is not given, schema object will be created. + + tagSet: :py:class:`~pyasn1.type.tag.TagSet` + Object representing non-default ASN.1 tag(s) + + subtypeSpec: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` + Object representing non-default ASN.1 subtype constraint(s). Constraints + verification for |ASN.1| type occurs automatically on object + instantiation. + + encoding: :py:class:`str` + Unicode codec ID to encode/decode + :class:`str` the payload when |ASN.1| object is used + in text string context. + + binValue: :py:class:`str` + Binary string initializer to use instead of the *value*. + Example: '10110011'. + + hexValue: :py:class:`str` + Hexadecimal string initializer to use instead of the *value*. + Example: 'DEADBEEF'. + + Raises + ------ + ~pyasn1.error.ValueConstraintError, ~pyasn1.error.PyAsn1Error + On constraint violation or bad initializer. + + Examples + -------- + .. code-block:: python + + class Error(Sequence): + ''' + ASN.1 specification: + + Error ::= SEQUENCE { + code INTEGER, + parameter ANY DEFINED BY code -- Either INTEGER or REAL + } + ''' + componentType=NamedTypes( + NamedType('code', Integer()), + NamedType('parameter', Any(), + openType=OpenType('code', {1: Integer(), + 2: Real()})) + ) + + error = Error() + error['code'] = 1 + error['parameter'] = Integer(1234) + """ + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.tag.TagSet` object representing ASN.1 tag(s) + #: associated with |ASN.1| type. + tagSet = tag.TagSet() # untagged + + #: Set (on class, not on instance) or return a + #: :py:class:`~pyasn1.type.constraint.ConstraintsIntersection` object + #: imposing constraints on |ASN.1| type initialization values. + subtypeSpec = constraint.ConstraintsIntersection() + + # Disambiguation ASN.1 types identification + typeId = OctetString.getTypeId() + + @property + def tagMap(self): + """"Return a :class:`~pyasn1.type.tagmap.TagMap` object mapping + ASN.1 tags to ASN.1 objects contained within callee. + """ + try: + return self._tagMap + + except AttributeError: + self._tagMap = tagmap.TagMap( + {self.tagSet: self}, + {eoo.endOfOctets.tagSet: eoo.endOfOctets}, + self + ) + + return self._tagMap + +# XXX +# coercion rules? diff --git a/module/scripts/lib/pyasn1/type/useful.py b/module/scripts/lib/pyasn1/type/useful.py new file mode 100644 index 0000000..49c00f5 --- /dev/null +++ b/module/scripts/lib/pyasn1/type/useful.py @@ -0,0 +1,190 @@ +# +# This file is part of pyasn1 software. +# +# Copyright (c) 2005-2020, Ilya Etingof +# License: https://pyasn1.readthedocs.io/en/latest/license.html +# +import datetime + +from pyasn1 import error +from pyasn1.type import char +from pyasn1.type import tag +from pyasn1.type import univ + +__all__ = ['ObjectDescriptor', 'GeneralizedTime', 'UTCTime'] + +NoValue = univ.NoValue +noValue = univ.noValue + + +class ObjectDescriptor(char.GraphicString): + __doc__ = char.GraphicString.__doc__ + + #: Default :py:class:`~pyasn1.type.tag.TagSet` object for |ASN.1| objects + tagSet = char.GraphicString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 7) + ) + + # Optimization for faster codec lookup + typeId = char.GraphicString.getTypeId() + + +class TimeMixIn(object): + + _yearsDigits = 4 + _hasSubsecond = False + _optionalMinutes = False + _shortTZ = False + + class FixedOffset(datetime.tzinfo): + """Fixed offset in minutes east from UTC.""" + + # defaulted arguments required + # https: // docs.python.org / 2.3 / lib / datetime - tzinfo.html + def __init__(self, offset=0, name='UTC'): + self.__offset = datetime.timedelta(minutes=offset) + self.__name = name + + def utcoffset(self, dt): + return self.__offset + + def tzname(self, dt): + return self.__name + + def dst(self, dt): + return datetime.timedelta(0) + + UTC = FixedOffset() + + @property + def asDateTime(self): + """Create :py:class:`datetime.datetime` object from a |ASN.1| object. + + Returns + ------- + : + new instance of :py:class:`datetime.datetime` object + """ + text = str(self) + if text.endswith('Z'): + tzinfo = TimeMixIn.UTC + text = text[:-1] + + elif '-' in text or '+' in text: + if '+' in text: + text, plusminus, tz = text.partition('+') + else: + text, plusminus, tz = text.partition('-') + + if self._shortTZ and len(tz) == 2: + tz += '00' + + if len(tz) != 4: + raise error.PyAsn1Error('malformed time zone offset %s' % tz) + + try: + minutes = int(tz[:2]) * 60 + int(tz[2:]) + if plusminus == '-': + minutes *= -1 + + except ValueError: + raise error.PyAsn1Error('unknown time specification %s' % self) + + tzinfo = TimeMixIn.FixedOffset(minutes, '?') + + else: + tzinfo = None + + if '.' in text or ',' in text: + if '.' in text: + text, _, ms = text.partition('.') + else: + text, _, ms = text.partition(',') + + try: + # Normalize variable-length fraction to microseconds + ms = int(ms.ljust(6, '0')[:6]) + + except ValueError: + raise error.PyAsn1Error('bad sub-second time specification %s' % self) + + else: + ms = 0 + + if self._optionalMinutes and len(text) - self._yearsDigits == 6: + text += '0000' + elif len(text) - self._yearsDigits == 8: + text += '00' + + try: + dt = datetime.datetime.strptime(text, self._yearsDigits == 4 and '%Y%m%d%H%M%S' or '%y%m%d%H%M%S') + + except ValueError: + raise error.PyAsn1Error('malformed datetime format %s' % self) + + return dt.replace(microsecond=ms, tzinfo=tzinfo) + + @classmethod + def fromDateTime(cls, dt): + """Create |ASN.1| object from a :py:class:`datetime.datetime` object. + + Parameters + ---------- + dt: :py:class:`datetime.datetime` object + The `datetime.datetime` object to initialize the |ASN.1| object + from + + Returns + ------- + : + new instance of |ASN.1| value + """ + text = dt.strftime(cls._yearsDigits == 4 and '%Y%m%d%H%M%S' or '%y%m%d%H%M%S') + if cls._hasSubsecond and dt.microsecond: + text += ('.%06d' % dt.microsecond).rstrip('0') + + if dt.utcoffset(): + seconds = dt.utcoffset().seconds + if seconds < 0: + text += '-' + else: + text += '+' + text += '%.2d%.2d' % (seconds // 3600, seconds % 3600) + else: + text += 'Z' + + return cls(text) + + +class GeneralizedTime(char.VisibleString, TimeMixIn): + __doc__ = char.VisibleString.__doc__ + + #: Default :py:class:`~pyasn1.type.tag.TagSet` object for |ASN.1| objects + tagSet = char.VisibleString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 24) + ) + + # Optimization for faster codec lookup + typeId = char.VideotexString.getTypeId() + + _yearsDigits = 4 + _hasSubsecond = True + _optionalMinutes = True + _shortTZ = True + + +class UTCTime(char.VisibleString, TimeMixIn): + __doc__ = char.VisibleString.__doc__ + + #: Default :py:class:`~pyasn1.type.tag.TagSet` object for |ASN.1| objects + tagSet = char.VisibleString.tagSet.tagImplicitly( + tag.Tag(tag.tagClassUniversal, tag.tagFormatSimple, 23) + ) + + # Optimization for faster codec lookup + typeId = char.VideotexString.getTypeId() + + _yearsDigits = 2 + _hasSubsecond = False + _optionalMinutes = False + _shortTZ = False diff --git a/module/scripts/lib/rsa/__init__.py b/module/scripts/lib/rsa/__init__.py new file mode 100644 index 0000000..e768907 --- /dev/null +++ b/module/scripts/lib/rsa/__init__.py @@ -0,0 +1,60 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""RSA module + +Module for calculating large primes, and RSA encryption, decryption, signing +and verification. Includes generating public and private keys. + +WARNING: this implementation does not use compression of the cleartext input to +prevent repetitions, or other common security improvements. Use with care. + +""" + +from rsa.key import newkeys, PrivateKey, PublicKey +from rsa.pkcs1 import ( + encrypt, + decrypt, + sign, + verify, + DecryptionError, + VerificationError, + find_signature_hash, + sign_hash, + compute_hash, +) + +__author__ = "Sybren Stuvel, Barry Mead and Yesudeep Mangalapilly" +__date__ = "2025-04-16" +__version__ = "4.9.1" + +# Do doctest if we're run directly +if __name__ == "__main__": + import doctest + + doctest.testmod() + +__all__ = [ + "newkeys", + "encrypt", + "decrypt", + "sign", + "verify", + "PublicKey", + "PrivateKey", + "DecryptionError", + "VerificationError", + "find_signature_hash", + "compute_hash", + "sign_hash", +] diff --git a/module/scripts/lib/rsa/asn1.py b/module/scripts/lib/rsa/asn1.py new file mode 100644 index 0000000..4cc4dd3 --- /dev/null +++ b/module/scripts/lib/rsa/asn1.py @@ -0,0 +1,52 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ASN.1 definitions. + +Not all ASN.1-handling code use these definitions, but when it does, they should be here. +""" + +from pyasn1.type import univ, namedtype, tag + + +class PubKeyHeader(univ.Sequence): + componentType = namedtype.NamedTypes( + namedtype.NamedType("oid", univ.ObjectIdentifier()), + namedtype.NamedType("parameters", univ.Null()), + ) + + +class OpenSSLPubKey(univ.Sequence): + componentType = namedtype.NamedTypes( + namedtype.NamedType("header", PubKeyHeader()), + # This little hack (the implicit tag) allows us to get a Bit String as Octet String + namedtype.NamedType( + "key", + univ.OctetString().subtype(implicitTag=tag.Tag(tagClass=0, tagFormat=0, tagId=3)), + ), + ) + + +class AsnPubKey(univ.Sequence): + """ASN.1 contents of DER encoded public key: + + RSAPublicKey ::= SEQUENCE { + modulus INTEGER, -- n + publicExponent INTEGER, -- e + """ + + componentType = namedtype.NamedTypes( + namedtype.NamedType("modulus", univ.Integer()), + namedtype.NamedType("publicExponent", univ.Integer()), + ) diff --git a/module/scripts/lib/rsa/cli.py b/module/scripts/lib/rsa/cli.py new file mode 100644 index 0000000..4db3f0b --- /dev/null +++ b/module/scripts/lib/rsa/cli.py @@ -0,0 +1,321 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Commandline scripts. + +These scripts are called by the executables defined in setup.py. +""" + +import abc +import sys +import typing +import optparse + +import rsa +import rsa.key +import rsa.pkcs1 + +HASH_METHODS = sorted(rsa.pkcs1.HASH_METHODS.keys()) +Indexable = typing.Union[typing.Tuple, typing.List[str]] + + +def keygen() -> None: + """Key generator.""" + + # Parse the CLI options + parser = optparse.OptionParser( + usage="usage: %prog [options] keysize", + description='Generates a new RSA key pair of "keysize" bits.', + ) + + parser.add_option( + "--pubout", + type="string", + help="Output filename for the public key. The public key is " + "not saved if this option is not present. You can use " + "pyrsa-priv2pub to create the public key file later.", + ) + + parser.add_option( + "-o", + "--out", + type="string", + help="Output filename for the private key. The key is " + "written to stdout if this option is not present.", + ) + + parser.add_option( + "--form", + help="key format of the private and public keys - default PEM", + choices=("PEM", "DER"), + default="PEM", + ) + + (cli, cli_args) = parser.parse_args(sys.argv[1:]) + + if len(cli_args) != 1: + parser.print_help() + raise SystemExit(1) + + try: + keysize = int(cli_args[0]) + except ValueError as ex: + parser.print_help() + print("Not a valid number: %s" % cli_args[0], file=sys.stderr) + raise SystemExit(1) from ex + + print("Generating %i-bit key" % keysize, file=sys.stderr) + (pub_key, priv_key) = rsa.newkeys(keysize) + + # Save public key + if cli.pubout: + print("Writing public key to %s" % cli.pubout, file=sys.stderr) + data = pub_key.save_pkcs1(format=cli.form) + with open(cli.pubout, "wb") as outfile: + outfile.write(data) + + # Save private key + data = priv_key.save_pkcs1(format=cli.form) + + if cli.out: + print("Writing private key to %s" % cli.out, file=sys.stderr) + with open(cli.out, "wb") as outfile: + outfile.write(data) + else: + print("Writing private key to stdout", file=sys.stderr) + sys.stdout.buffer.write(data) + + +class CryptoOperation(metaclass=abc.ABCMeta): + """CLI callable that operates with input, output, and a key.""" + + keyname = "public" # or 'private' + usage = "usage: %%prog [options] %(keyname)s_key" + description = "" + operation = "decrypt" + operation_past = "decrypted" + operation_progressive = "decrypting" + input_help = "Name of the file to %(operation)s. Reads from stdin if " "not specified." + output_help = ( + "Name of the file to write the %(operation_past)s file " + "to. Written to stdout if this option is not present." + ) + expected_cli_args = 1 + has_output = True + + key_class = rsa.PublicKey # type: typing.Type[rsa.key.AbstractKey] + + def __init__(self) -> None: + self.usage = self.usage % self.__class__.__dict__ + self.input_help = self.input_help % self.__class__.__dict__ + self.output_help = self.output_help % self.__class__.__dict__ + + @abc.abstractmethod + def perform_operation( + self, indata: bytes, key: rsa.key.AbstractKey, cli_args: Indexable + ) -> typing.Any: + """Performs the program's operation. + + Implement in a subclass. + + :returns: the data to write to the output. + """ + + def __call__(self) -> None: + """Runs the program.""" + + (cli, cli_args) = self.parse_cli() + + key = self.read_key(cli_args[0], cli.keyform) + + indata = self.read_infile(cli.input) + + print(self.operation_progressive.title(), file=sys.stderr) + outdata = self.perform_operation(indata, key, cli_args) + + if self.has_output: + self.write_outfile(outdata, cli.output) + + def parse_cli(self) -> typing.Tuple[optparse.Values, typing.List[str]]: + """Parse the CLI options + + :returns: (cli_opts, cli_args) + """ + + parser = optparse.OptionParser(usage=self.usage, description=self.description) + + parser.add_option("-i", "--input", type="string", help=self.input_help) + + if self.has_output: + parser.add_option("-o", "--output", type="string", help=self.output_help) + + parser.add_option( + "--keyform", + help="Key format of the %s key - default PEM" % self.keyname, + choices=("PEM", "DER"), + default="PEM", + ) + + (cli, cli_args) = parser.parse_args(sys.argv[1:]) + + if len(cli_args) != self.expected_cli_args: + parser.print_help() + raise SystemExit(1) + + return cli, cli_args + + def read_key(self, filename: str, keyform: str) -> rsa.key.AbstractKey: + """Reads a public or private key.""" + + print("Reading %s key from %s" % (self.keyname, filename), file=sys.stderr) + with open(filename, "rb") as keyfile: + keydata = keyfile.read() + + return self.key_class.load_pkcs1(keydata, keyform) + + def read_infile(self, inname: str) -> bytes: + """Read the input file""" + + if inname: + print("Reading input from %s" % inname, file=sys.stderr) + with open(inname, "rb") as infile: + return infile.read() + + print("Reading input from stdin", file=sys.stderr) + return sys.stdin.buffer.read() + + def write_outfile(self, outdata: bytes, outname: str) -> None: + """Write the output file""" + + if outname: + print("Writing output to %s" % outname, file=sys.stderr) + with open(outname, "wb") as outfile: + outfile.write(outdata) + else: + print("Writing output to stdout", file=sys.stderr) + sys.stdout.buffer.write(outdata) + + +class EncryptOperation(CryptoOperation): + """Encrypts a file.""" + + keyname = "public" + description = ( + "Encrypts a file. The file must be shorter than the key " "length in order to be encrypted." + ) + operation = "encrypt" + operation_past = "encrypted" + operation_progressive = "encrypting" + + def perform_operation( + self, indata: bytes, pub_key: rsa.key.AbstractKey, cli_args: Indexable = () + ) -> bytes: + """Encrypts files.""" + assert isinstance(pub_key, rsa.key.PublicKey) + return rsa.encrypt(indata, pub_key) + + +class DecryptOperation(CryptoOperation): + """Decrypts a file.""" + + keyname = "private" + description = ( + "Decrypts a file. The original file must be shorter than " + "the key length in order to have been encrypted." + ) + operation = "decrypt" + operation_past = "decrypted" + operation_progressive = "decrypting" + key_class = rsa.PrivateKey + + def perform_operation( + self, indata: bytes, priv_key: rsa.key.AbstractKey, cli_args: Indexable = () + ) -> bytes: + """Decrypts files.""" + assert isinstance(priv_key, rsa.key.PrivateKey) + return rsa.decrypt(indata, priv_key) + + +class SignOperation(CryptoOperation): + """Signs a file.""" + + keyname = "private" + usage = "usage: %%prog [options] private_key hash_method" + description = ( + "Signs a file, outputs the signature. Choose the hash " + "method from %s" % ", ".join(HASH_METHODS) + ) + operation = "sign" + operation_past = "signature" + operation_progressive = "Signing" + key_class = rsa.PrivateKey + expected_cli_args = 2 + + output_help = ( + "Name of the file to write the signature to. Written " + "to stdout if this option is not present." + ) + + def perform_operation( + self, indata: bytes, priv_key: rsa.key.AbstractKey, cli_args: Indexable + ) -> bytes: + """Signs files.""" + assert isinstance(priv_key, rsa.key.PrivateKey) + + hash_method = cli_args[1] + if hash_method not in HASH_METHODS: + raise SystemExit("Invalid hash method, choose one of %s" % ", ".join(HASH_METHODS)) + + return rsa.sign(indata, priv_key, hash_method) + + +class VerifyOperation(CryptoOperation): + """Verify a signature.""" + + keyname = "public" + usage = "usage: %%prog [options] public_key signature_file" + description = ( + "Verifies a signature, exits with status 0 upon success, " + "prints an error message and exits with status 1 upon error." + ) + operation = "verify" + operation_past = "verified" + operation_progressive = "Verifying" + key_class = rsa.PublicKey + expected_cli_args = 2 + has_output = False + + def perform_operation( + self, indata: bytes, pub_key: rsa.key.AbstractKey, cli_args: Indexable + ) -> None: + """Verifies files.""" + assert isinstance(pub_key, rsa.key.PublicKey) + + signature_file = cli_args[1] + + with open(signature_file, "rb") as sigfile: + signature = sigfile.read() + + try: + rsa.verify(indata, signature, pub_key) + except rsa.VerificationError as ex: + raise SystemExit("Verification failed.") from ex + + print("Verification OK", file=sys.stderr) + + +encrypt = EncryptOperation() +decrypt = DecryptOperation() +sign = SignOperation() +verify = VerifyOperation() diff --git a/module/scripts/lib/rsa/common.py b/module/scripts/lib/rsa/common.py new file mode 100644 index 0000000..ca732e5 --- /dev/null +++ b/module/scripts/lib/rsa/common.py @@ -0,0 +1,184 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Common functionality shared by several modules.""" + +import typing + + +class NotRelativePrimeError(ValueError): + def __init__(self, a: int, b: int, d: int, msg: str = "") -> None: + super().__init__(msg or "%d and %d are not relatively prime, divider=%i" % (a, b, d)) + self.a = a + self.b = b + self.d = d + + +def bit_size(num: int) -> int: + """ + Number of bits needed to represent a integer excluding any prefix + 0 bits. + + Usage:: + + >>> bit_size(1023) + 10 + >>> bit_size(1024) + 11 + >>> bit_size(1025) + 11 + + :param num: + Integer value. If num is 0, returns 0. Only the absolute value of the + number is considered. Therefore, signed integers will be abs(num) + before the number's bit length is determined. + :returns: + Returns the number of bits in the integer. + """ + + try: + return num.bit_length() + except AttributeError as ex: + raise TypeError("bit_size(num) only supports integers, not %r" % type(num)) from ex + + +def byte_size(number: int) -> int: + """ + Returns the number of bytes required to hold a specific long number. + + The number of bytes is rounded up. + + Usage:: + + >>> byte_size(1 << 1023) + 128 + >>> byte_size((1 << 1024) - 1) + 128 + >>> byte_size(1 << 1024) + 129 + + :param number: + An unsigned integer + :returns: + The number of bytes required to hold a specific long number. + """ + if number == 0: + return 1 + return ceil_div(bit_size(number), 8) + + +def ceil_div(num: int, div: int) -> int: + """ + Returns the ceiling function of a division between `num` and `div`. + + Usage:: + + >>> ceil_div(100, 7) + 15 + >>> ceil_div(100, 10) + 10 + >>> ceil_div(1, 4) + 1 + + :param num: Division's numerator, a number + :param div: Division's divisor, a number + + :return: Rounded up result of the division between the parameters. + """ + quanta, mod = divmod(num, div) + if mod: + quanta += 1 + return quanta + + +def extended_gcd(a: int, b: int) -> typing.Tuple[int, int, int]: + """Returns a tuple (r, i, j) such that r = gcd(a, b) = ia + jb""" + # r = gcd(a,b) i = multiplicitive inverse of a mod b + # or j = multiplicitive inverse of b mod a + # Neg return values for i or j are made positive mod b or a respectively + # Iterateive Version is faster and uses much less stack space + x = 0 + y = 1 + lx = 1 + ly = 0 + oa = a # Remember original a/b to remove + ob = b # negative values from return results + while b != 0: + q = a // b + (a, b) = (b, a % b) + (x, lx) = ((lx - (q * x)), x) + (y, ly) = ((ly - (q * y)), y) + if lx < 0: + lx += ob # If neg wrap modulo original b + if ly < 0: + ly += oa # If neg wrap modulo original a + return a, lx, ly # Return only positive values + + +def inverse(x: int, n: int) -> int: + """Returns the inverse of x % n under multiplication, a.k.a x^-1 (mod n) + + >>> inverse(7, 4) + 3 + >>> (inverse(143, 4) * 143) % 4 + 1 + """ + + (divider, inv, _) = extended_gcd(x, n) + + if divider != 1: + raise NotRelativePrimeError(x, n, divider) + + return inv + + +def crt(a_values: typing.Iterable[int], modulo_values: typing.Iterable[int]) -> int: + """Chinese Remainder Theorem. + + Calculates x such that x = a[i] (mod m[i]) for each i. + + :param a_values: the a-values of the above equation + :param modulo_values: the m-values of the above equation + :returns: x such that x = a[i] (mod m[i]) for each i + + + >>> crt([2, 3], [3, 5]) + 8 + + >>> crt([2, 3, 2], [3, 5, 7]) + 23 + + >>> crt([2, 3, 0], [7, 11, 15]) + 135 + """ + + m = 1 + x = 0 + + for modulo in modulo_values: + m *= modulo + + for (m_i, a_i) in zip(modulo_values, a_values): + M_i = m // m_i + inv = inverse(M_i, m_i) + + x = (x + a_i * M_i * inv) % m + + return x + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/module/scripts/lib/rsa/core.py b/module/scripts/lib/rsa/core.py new file mode 100644 index 0000000..84ed3f8 --- /dev/null +++ b/module/scripts/lib/rsa/core.py @@ -0,0 +1,53 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Core mathematical operations. + +This is the actual core RSA implementation, which is only defined +mathematically on integers. +""" + + +def assert_int(var: int, name: str) -> None: + if isinstance(var, int): + return + + raise TypeError("%s should be an integer, not %s" % (name, var.__class__)) + + +def encrypt_int(message: int, ekey: int, n: int) -> int: + """Encrypts a message using encryption key 'ekey', working modulo n""" + + assert_int(message, "message") + assert_int(ekey, "ekey") + assert_int(n, "n") + + if message < 0: + raise ValueError("Only non-negative numbers are supported") + + if message > n: + raise OverflowError("The message %i is too long for n=%i" % (message, n)) + + return pow(message, ekey, n) + + +def decrypt_int(cyphertext: int, dkey: int, n: int) -> int: + """Decrypts a cypher text using the decryption key 'dkey', working modulo n""" + + assert_int(cyphertext, "cyphertext") + assert_int(dkey, "dkey") + assert_int(n, "n") + + message = pow(cyphertext, dkey, n) + return message diff --git a/module/scripts/lib/rsa/key.py b/module/scripts/lib/rsa/key.py new file mode 100644 index 0000000..f800644 --- /dev/null +++ b/module/scripts/lib/rsa/key.py @@ -0,0 +1,858 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""RSA key generation code. + +Create new keys with the newkeys() function. It will give you a PublicKey and a +PrivateKey object. + +Loading and saving keys requires the pyasn1 module. This module is imported as +late as possible, such that other functionality will remain working in absence +of pyasn1. + +.. note:: + + Storing public and private keys via the `pickle` module is possible. + However, it is insecure to load a key from an untrusted source. + The pickle module is not secure against erroneous or maliciously + constructed data. Never unpickle data received from an untrusted + or unauthenticated source. + +""" + +import threading +import typing +import warnings + +import rsa.prime +import rsa.pem +import rsa.common +import rsa.randnum +import rsa.core + + +DEFAULT_EXPONENT = 65537 + + +T = typing.TypeVar("T", bound="AbstractKey") + + +class AbstractKey: + """Abstract superclass for private and public keys.""" + + __slots__ = ("n", "e", "blindfac", "blindfac_inverse", "mutex") + + def __init__(self, n: int, e: int) -> None: + self.n = n + self.e = e + + # These will be computed properly on the first call to blind(). + self.blindfac = self.blindfac_inverse = -1 + + # Used to protect updates to the blinding factor in multi-threaded + # environments. + self.mutex = threading.Lock() + + @classmethod + def _load_pkcs1_pem(cls: typing.Type[T], keyfile: bytes) -> T: + """Loads a key in PKCS#1 PEM format, implement in a subclass. + + :param keyfile: contents of a PEM-encoded file that contains + the public key. + :type keyfile: bytes + + :return: the loaded key + :rtype: AbstractKey + """ + + @classmethod + def _load_pkcs1_der(cls: typing.Type[T], keyfile: bytes) -> T: + """Loads a key in PKCS#1 PEM format, implement in a subclass. + + :param keyfile: contents of a DER-encoded file that contains + the public key. + :type keyfile: bytes + + :return: the loaded key + :rtype: AbstractKey + """ + + def _save_pkcs1_pem(self) -> bytes: + """Saves the key in PKCS#1 PEM format, implement in a subclass. + + :returns: the PEM-encoded key. + :rtype: bytes + """ + + def _save_pkcs1_der(self) -> bytes: + """Saves the key in PKCS#1 DER format, implement in a subclass. + + :returns: the DER-encoded key. + :rtype: bytes + """ + + @classmethod + def load_pkcs1(cls: typing.Type[T], keyfile: bytes, format: str = "PEM") -> T: + """Loads a key in PKCS#1 DER or PEM format. + + :param keyfile: contents of a DER- or PEM-encoded file that contains + the key. + :type keyfile: bytes + :param format: the format of the file to load; 'PEM' or 'DER' + :type format: str + + :return: the loaded key + :rtype: AbstractKey + """ + + methods = { + "PEM": cls._load_pkcs1_pem, + "DER": cls._load_pkcs1_der, + } + + method = cls._assert_format_exists(format, methods) + return method(keyfile) + + @staticmethod + def _assert_format_exists( + file_format: str, methods: typing.Mapping[str, typing.Callable] + ) -> typing.Callable: + """Checks whether the given file format exists in 'methods'.""" + + try: + return methods[file_format] + except KeyError as ex: + formats = ", ".join(sorted(methods.keys())) + raise ValueError( + "Unsupported format: %r, try one of %s" % (file_format, formats) + ) from ex + + def save_pkcs1(self, format: str = "PEM") -> bytes: + """Saves the key in PKCS#1 DER or PEM format. + + :param format: the format to save; 'PEM' or 'DER' + :type format: str + :returns: the DER- or PEM-encoded key. + :rtype: bytes + """ + + methods = { + "PEM": self._save_pkcs1_pem, + "DER": self._save_pkcs1_der, + } + + method = self._assert_format_exists(format, methods) + return method() + + def blind(self, message: int) -> typing.Tuple[int, int]: + """Performs blinding on the message. + + :param message: the message, as integer, to blind. + :param r: the random number to blind with. + :return: tuple (the blinded message, the inverse of the used blinding factor) + + The blinding is such that message = unblind(decrypt(blind(encrypt(message))). + + See https://en.wikipedia.org/wiki/Blinding_%28cryptography%29 + """ + blindfac, blindfac_inverse = self._update_blinding_factor() + blinded = (message * pow(blindfac, self.e, self.n)) % self.n + return blinded, blindfac_inverse + + def unblind(self, blinded: int, blindfac_inverse: int) -> int: + """Performs blinding on the message using random number 'blindfac_inverse'. + + :param blinded: the blinded message, as integer, to unblind. + :param blindfac: the factor to unblind with. + :return: the original message. + + The blinding is such that message = unblind(decrypt(blind(encrypt(message))). + + See https://en.wikipedia.org/wiki/Blinding_%28cryptography%29 + """ + return (blindfac_inverse * blinded) % self.n + + def _initial_blinding_factor(self) -> int: + for _ in range(1000): + blind_r = rsa.randnum.randint(self.n - 1) + if rsa.prime.are_relatively_prime(self.n, blind_r): + return blind_r + raise RuntimeError("unable to find blinding factor") + + def _update_blinding_factor(self) -> typing.Tuple[int, int]: + """Update blinding factors. + + Computing a blinding factor is expensive, so instead this function + does this once, then updates the blinding factor as per section 9 + of 'A Timing Attack against RSA with the Chinese Remainder Theorem' + by Werner Schindler. + See https://tls.mbed.org/public/WSchindler-RSA_Timing_Attack.pdf + + :return: the new blinding factor and its inverse. + """ + + with self.mutex: + if self.blindfac < 0: + # Compute initial blinding factor, which is rather slow to do. + self.blindfac = self._initial_blinding_factor() + self.blindfac_inverse = rsa.common.inverse(self.blindfac, self.n) + else: + # Reuse previous blinding factor. + self.blindfac = pow(self.blindfac, 2, self.n) + self.blindfac_inverse = pow(self.blindfac_inverse, 2, self.n) + + return self.blindfac, self.blindfac_inverse + + +class PublicKey(AbstractKey): + """Represents a public RSA key. + + This key is also known as the 'encryption key'. It contains the 'n' and 'e' + values. + + Supports attributes as well as dictionary-like access. Attribute access is + faster, though. + + >>> PublicKey(5, 3) + PublicKey(5, 3) + + >>> key = PublicKey(5, 3) + >>> key.n + 5 + >>> key['n'] + 5 + >>> key.e + 3 + >>> key['e'] + 3 + + """ + + __slots__ = () + + def __getitem__(self, key: str) -> int: + return getattr(self, key) + + def __repr__(self) -> str: + return "PublicKey(%i, %i)" % (self.n, self.e) + + def __getstate__(self) -> typing.Tuple[int, int]: + """Returns the key as tuple for pickling.""" + return self.n, self.e + + def __setstate__(self, state: typing.Tuple[int, int]) -> None: + """Sets the key from tuple.""" + self.n, self.e = state + AbstractKey.__init__(self, self.n, self.e) + + def __eq__(self, other: typing.Any) -> bool: + if other is None: + return False + + if not isinstance(other, PublicKey): + return False + + return self.n == other.n and self.e == other.e + + def __ne__(self, other: typing.Any) -> bool: + return not (self == other) + + def __hash__(self) -> int: + return hash((self.n, self.e)) + + @classmethod + def _load_pkcs1_der(cls, keyfile: bytes) -> "PublicKey": + """Loads a key in PKCS#1 DER format. + + :param keyfile: contents of a DER-encoded file that contains the public + key. + :return: a PublicKey object + + First let's construct a DER encoded key: + + >>> import base64 + >>> b64der = 'MAwCBQCNGmYtAgMBAAE=' + >>> der = base64.standard_b64decode(b64der) + + This loads the file: + + >>> PublicKey._load_pkcs1_der(der) + PublicKey(2367317549, 65537) + + """ + + from pyasn1.codec.der import decoder + from rsa.asn1 import AsnPubKey + + (priv, _) = decoder.decode(keyfile, asn1Spec=AsnPubKey()) + return cls(n=int(priv["modulus"]), e=int(priv["publicExponent"])) + + def _save_pkcs1_der(self) -> bytes: + """Saves the public key in PKCS#1 DER format. + + :returns: the DER-encoded public key. + :rtype: bytes + """ + + from pyasn1.codec.der import encoder + from rsa.asn1 import AsnPubKey + + # Create the ASN object + asn_key = AsnPubKey() + asn_key.setComponentByName("modulus", self.n) + asn_key.setComponentByName("publicExponent", self.e) + + return encoder.encode(asn_key) + + @classmethod + def _load_pkcs1_pem(cls, keyfile: bytes) -> "PublicKey": + """Loads a PKCS#1 PEM-encoded public key file. + + The contents of the file before the "-----BEGIN RSA PUBLIC KEY-----" and + after the "-----END RSA PUBLIC KEY-----" lines is ignored. + + :param keyfile: contents of a PEM-encoded file that contains the public + key. + :return: a PublicKey object + """ + + der = rsa.pem.load_pem(keyfile, "RSA PUBLIC KEY") + return cls._load_pkcs1_der(der) + + def _save_pkcs1_pem(self) -> bytes: + """Saves a PKCS#1 PEM-encoded public key file. + + :return: contents of a PEM-encoded file that contains the public key. + :rtype: bytes + """ + + der = self._save_pkcs1_der() + return rsa.pem.save_pem(der, "RSA PUBLIC KEY") + + @classmethod + def load_pkcs1_openssl_pem(cls, keyfile: bytes) -> "PublicKey": + """Loads a PKCS#1.5 PEM-encoded public key file from OpenSSL. + + These files can be recognised in that they start with BEGIN PUBLIC KEY + rather than BEGIN RSA PUBLIC KEY. + + The contents of the file before the "-----BEGIN PUBLIC KEY-----" and + after the "-----END PUBLIC KEY-----" lines is ignored. + + :param keyfile: contents of a PEM-encoded file that contains the public + key, from OpenSSL. + :type keyfile: bytes + :return: a PublicKey object + """ + + der = rsa.pem.load_pem(keyfile, "PUBLIC KEY") + return cls.load_pkcs1_openssl_der(der) + + @classmethod + def load_pkcs1_openssl_der(cls, keyfile: bytes) -> "PublicKey": + """Loads a PKCS#1 DER-encoded public key file from OpenSSL. + + :param keyfile: contents of a DER-encoded file that contains the public + key, from OpenSSL. + :return: a PublicKey object + """ + + from rsa.asn1 import OpenSSLPubKey + from pyasn1.codec.der import decoder + from pyasn1.type import univ + + (keyinfo, _) = decoder.decode(keyfile, asn1Spec=OpenSSLPubKey()) + + if keyinfo["header"]["oid"] != univ.ObjectIdentifier("1.2.840.113549.1.1.1"): + raise TypeError("This is not a DER-encoded OpenSSL-compatible public key") + + return cls._load_pkcs1_der(keyinfo["key"][1:]) + + +class PrivateKey(AbstractKey): + """Represents a private RSA key. + + This key is also known as the 'decryption key'. It contains the 'n', 'e', + 'd', 'p', 'q' and other values. + + Supports attributes as well as dictionary-like access. Attribute access is + faster, though. + + >>> PrivateKey(3247, 65537, 833, 191, 17) + PrivateKey(3247, 65537, 833, 191, 17) + + exp1, exp2 and coef will be calculated: + + >>> pk = PrivateKey(3727264081, 65537, 3349121513, 65063, 57287) + >>> pk.exp1 + 55063 + >>> pk.exp2 + 10095 + >>> pk.coef + 50797 + + """ + + __slots__ = ("d", "p", "q", "exp1", "exp2", "coef") + + def __init__(self, n: int, e: int, d: int, p: int, q: int) -> None: + AbstractKey.__init__(self, n, e) + self.d = d + self.p = p + self.q = q + + # Calculate exponents and coefficient. + self.exp1 = int(d % (p - 1)) + self.exp2 = int(d % (q - 1)) + self.coef = rsa.common.inverse(q, p) + + def __getitem__(self, key: str) -> int: + return getattr(self, key) + + def __repr__(self) -> str: + return "PrivateKey(%i, %i, %i, %i, %i)" % ( + self.n, + self.e, + self.d, + self.p, + self.q, + ) + + def __getstate__(self) -> typing.Tuple[int, int, int, int, int, int, int, int]: + """Returns the key as tuple for pickling.""" + return self.n, self.e, self.d, self.p, self.q, self.exp1, self.exp2, self.coef + + def __setstate__(self, state: typing.Tuple[int, int, int, int, int, int, int, int]) -> None: + """Sets the key from tuple.""" + self.n, self.e, self.d, self.p, self.q, self.exp1, self.exp2, self.coef = state + AbstractKey.__init__(self, self.n, self.e) + + def __eq__(self, other: typing.Any) -> bool: + if other is None: + return False + + if not isinstance(other, PrivateKey): + return False + + return ( + self.n == other.n + and self.e == other.e + and self.d == other.d + and self.p == other.p + and self.q == other.q + and self.exp1 == other.exp1 + and self.exp2 == other.exp2 + and self.coef == other.coef + ) + + def __ne__(self, other: typing.Any) -> bool: + return not (self == other) + + def __hash__(self) -> int: + return hash((self.n, self.e, self.d, self.p, self.q, self.exp1, self.exp2, self.coef)) + + def blinded_decrypt(self, encrypted: int) -> int: + """Decrypts the message using blinding to prevent side-channel attacks. + + :param encrypted: the encrypted message + :type encrypted: int + + :returns: the decrypted message + :rtype: int + """ + + # Blinding and un-blinding should be using the same factor + blinded, blindfac_inverse = self.blind(encrypted) + + # Instead of using the core functionality, use the Chinese Remainder + # Theorem and be 2-4x faster. This the same as: + # + # decrypted = rsa.core.decrypt_int(blinded, self.d, self.n) + s1 = pow(blinded, self.exp1, self.p) + s2 = pow(blinded, self.exp2, self.q) + h = ((s1 - s2) * self.coef) % self.p + decrypted = s2 + self.q * h + + return self.unblind(decrypted, blindfac_inverse) + + def blinded_encrypt(self, message: int) -> int: + """Encrypts the message using blinding to prevent side-channel attacks. + + :param message: the message to encrypt + :type message: int + + :returns: the encrypted message + :rtype: int + """ + + blinded, blindfac_inverse = self.blind(message) + encrypted = rsa.core.encrypt_int(blinded, self.d, self.n) + return self.unblind(encrypted, blindfac_inverse) + + @classmethod + def _load_pkcs1_der(cls, keyfile: bytes) -> "PrivateKey": + """Loads a key in PKCS#1 DER format. + + :param keyfile: contents of a DER-encoded file that contains the private + key. + :type keyfile: bytes + :return: a PrivateKey object + + First let's construct a DER encoded key: + + >>> import base64 + >>> b64der = 'MC4CAQACBQDeKYlRAgMBAAECBQDHn4npAgMA/icCAwDfxwIDANcXAgInbwIDAMZt' + >>> der = base64.standard_b64decode(b64der) + + This loads the file: + + >>> PrivateKey._load_pkcs1_der(der) + PrivateKey(3727264081, 65537, 3349121513, 65063, 57287) + + """ + + from pyasn1.codec.der import decoder + + (priv, _) = decoder.decode(keyfile) + + # ASN.1 contents of DER encoded private key: + # + # RSAPrivateKey ::= SEQUENCE { + # version Version, + # modulus INTEGER, -- n + # publicExponent INTEGER, -- e + # privateExponent INTEGER, -- d + # prime1 INTEGER, -- p + # prime2 INTEGER, -- q + # exponent1 INTEGER, -- d mod (p-1) + # exponent2 INTEGER, -- d mod (q-1) + # coefficient INTEGER, -- (inverse of q) mod p + # otherPrimeInfos OtherPrimeInfos OPTIONAL + # } + + if priv[0] != 0: + raise ValueError("Unable to read this file, version %s != 0" % priv[0]) + + as_ints = map(int, priv[1:6]) + key = cls(*as_ints) + + exp1, exp2, coef = map(int, priv[6:9]) + + if (key.exp1, key.exp2, key.coef) != (exp1, exp2, coef): + warnings.warn( + "You have provided a malformed keyfile. Either the exponents " + "or the coefficient are incorrect. Using the correct values " + "instead.", + UserWarning, + ) + + return key + + def _save_pkcs1_der(self) -> bytes: + """Saves the private key in PKCS#1 DER format. + + :returns: the DER-encoded private key. + :rtype: bytes + """ + + from pyasn1.type import univ, namedtype + from pyasn1.codec.der import encoder + + class AsnPrivKey(univ.Sequence): + componentType = namedtype.NamedTypes( + namedtype.NamedType("version", univ.Integer()), + namedtype.NamedType("modulus", univ.Integer()), + namedtype.NamedType("publicExponent", univ.Integer()), + namedtype.NamedType("privateExponent", univ.Integer()), + namedtype.NamedType("prime1", univ.Integer()), + namedtype.NamedType("prime2", univ.Integer()), + namedtype.NamedType("exponent1", univ.Integer()), + namedtype.NamedType("exponent2", univ.Integer()), + namedtype.NamedType("coefficient", univ.Integer()), + ) + + # Create the ASN object + asn_key = AsnPrivKey() + asn_key.setComponentByName("version", 0) + asn_key.setComponentByName("modulus", self.n) + asn_key.setComponentByName("publicExponent", self.e) + asn_key.setComponentByName("privateExponent", self.d) + asn_key.setComponentByName("prime1", self.p) + asn_key.setComponentByName("prime2", self.q) + asn_key.setComponentByName("exponent1", self.exp1) + asn_key.setComponentByName("exponent2", self.exp2) + asn_key.setComponentByName("coefficient", self.coef) + + return encoder.encode(asn_key) + + @classmethod + def _load_pkcs1_pem(cls, keyfile: bytes) -> "PrivateKey": + """Loads a PKCS#1 PEM-encoded private key file. + + The contents of the file before the "-----BEGIN RSA PRIVATE KEY-----" and + after the "-----END RSA PRIVATE KEY-----" lines is ignored. + + :param keyfile: contents of a PEM-encoded file that contains the private + key. + :type keyfile: bytes + :return: a PrivateKey object + """ + + der = rsa.pem.load_pem(keyfile, b"RSA PRIVATE KEY") + return cls._load_pkcs1_der(der) + + def _save_pkcs1_pem(self) -> bytes: + """Saves a PKCS#1 PEM-encoded private key file. + + :return: contents of a PEM-encoded file that contains the private key. + :rtype: bytes + """ + + der = self._save_pkcs1_der() + return rsa.pem.save_pem(der, b"RSA PRIVATE KEY") + + +def find_p_q( + nbits: int, + getprime_func: typing.Callable[[int], int] = rsa.prime.getprime, + accurate: bool = True, +) -> typing.Tuple[int, int]: + """Returns a tuple of two different primes of nbits bits each. + + The resulting p * q has exactly 2 * nbits bits, and the returned p and q + will not be equal. + + :param nbits: the number of bits in each of p and q. + :param getprime_func: the getprime function, defaults to + :py:func:`rsa.prime.getprime`. + + *Introduced in Python-RSA 3.1* + + :param accurate: whether to enable accurate mode or not. + :returns: (p, q), where p > q + + >>> (p, q) = find_p_q(128) + >>> from rsa import common + >>> common.bit_size(p * q) + 256 + + When not in accurate mode, the number of bits can be slightly less + + >>> (p, q) = find_p_q(128, accurate=False) + >>> from rsa import common + >>> common.bit_size(p * q) <= 256 + True + >>> common.bit_size(p * q) > 240 + True + + """ + + total_bits = nbits * 2 + + # Make sure that p and q aren't too close or the factoring programs can + # factor n. + shift = nbits // 16 + pbits = nbits + shift + qbits = nbits - shift + + # Choose the two initial primes + p = getprime_func(pbits) + q = getprime_func(qbits) + + def is_acceptable(p: int, q: int) -> bool: + """Returns True iff p and q are acceptable: + + - p and q differ + - (p * q) has the right nr of bits (when accurate=True) + """ + + if p == q: + return False + + if not accurate: + return True + + # Make sure we have just the right amount of bits + found_size = rsa.common.bit_size(p * q) + return total_bits == found_size + + # Keep choosing other primes until they match our requirements. + change_p = False + while not is_acceptable(p, q): + # Change p on one iteration and q on the other + if change_p: + p = getprime_func(pbits) + else: + q = getprime_func(qbits) + + change_p = not change_p + + # We want p > q as described on + # http://www.di-mgt.com.au/rsa_alg.html#crt + return max(p, q), min(p, q) + + +def calculate_keys_custom_exponent(p: int, q: int, exponent: int) -> typing.Tuple[int, int]: + """Calculates an encryption and a decryption key given p, q and an exponent, + and returns them as a tuple (e, d) + + :param p: the first large prime + :param q: the second large prime + :param exponent: the exponent for the key; only change this if you know + what you're doing, as the exponent influences how difficult your + private key can be cracked. A very common choice for e is 65537. + :type exponent: int + + """ + + phi_n = (p - 1) * (q - 1) + + try: + d = rsa.common.inverse(exponent, phi_n) + except rsa.common.NotRelativePrimeError as ex: + raise rsa.common.NotRelativePrimeError( + exponent, + phi_n, + ex.d, + msg="e (%d) and phi_n (%d) are not relatively prime (divider=%i)" + % (exponent, phi_n, ex.d), + ) from ex + + if (exponent * d) % phi_n != 1: + raise ValueError( + "e (%d) and d (%d) are not mult. inv. modulo " "phi_n (%d)" % (exponent, d, phi_n) + ) + + return exponent, d + + +def calculate_keys(p: int, q: int) -> typing.Tuple[int, int]: + """Calculates an encryption and a decryption key given p and q, and + returns them as a tuple (e, d) + + :param p: the first large prime + :param q: the second large prime + + :return: tuple (e, d) with the encryption and decryption exponents. + """ + + return calculate_keys_custom_exponent(p, q, DEFAULT_EXPONENT) + + +def gen_keys( + nbits: int, + getprime_func: typing.Callable[[int], int], + accurate: bool = True, + exponent: int = DEFAULT_EXPONENT, +) -> typing.Tuple[int, int, int, int]: + """Generate RSA keys of nbits bits. Returns (p, q, e, d). + + Note: this can take a long time, depending on the key size. + + :param nbits: the total number of bits in ``p`` and ``q``. Both ``p`` and + ``q`` will use ``nbits/2`` bits. + :param getprime_func: either :py:func:`rsa.prime.getprime` or a function + with similar signature. + :param exponent: the exponent for the key; only change this if you know + what you're doing, as the exponent influences how difficult your + private key can be cracked. A very common choice for e is 65537. + :type exponent: int + """ + + # Regenerate p and q values, until calculate_keys doesn't raise a + # ValueError. + while True: + (p, q) = find_p_q(nbits // 2, getprime_func, accurate) + try: + (e, d) = calculate_keys_custom_exponent(p, q, exponent=exponent) + break + except ValueError: + pass + + return p, q, e, d + + +def newkeys( + nbits: int, + accurate: bool = True, + poolsize: int = 1, + exponent: int = DEFAULT_EXPONENT, +) -> typing.Tuple[PublicKey, PrivateKey]: + """Generates public and private keys, and returns them as (pub, priv). + + The public key is also known as the 'encryption key', and is a + :py:class:`rsa.PublicKey` object. The private key is also known as the + 'decryption key' and is a :py:class:`rsa.PrivateKey` object. + + :param nbits: the number of bits required to store ``n = p*q``. + :param accurate: when True, ``n`` will have exactly the number of bits you + asked for. However, this makes key generation much slower. When False, + `n`` may have slightly less bits. + :param poolsize: the number of processes to use to generate the prime + numbers. If set to a number > 1, a parallel algorithm will be used. + This requires Python 2.6 or newer. + :param exponent: the exponent for the key; only change this if you know + what you're doing, as the exponent influences how difficult your + private key can be cracked. A very common choice for e is 65537. + :type exponent: int + + :returns: a tuple (:py:class:`rsa.PublicKey`, :py:class:`rsa.PrivateKey`) + + The ``poolsize`` parameter was added in *Python-RSA 3.1* and requires + Python 2.6 or newer. + + """ + + if nbits < 16: + raise ValueError("Key too small") + + if poolsize < 1: + raise ValueError("Pool size (%i) should be >= 1" % poolsize) + + # Determine which getprime function to use + if poolsize > 1: + from rsa import parallel + + def getprime_func(nbits: int) -> int: + return parallel.getprime(nbits, poolsize=poolsize) + + else: + getprime_func = rsa.prime.getprime + + # Generate the key components + (p, q, e, d) = gen_keys(nbits, getprime_func, accurate=accurate, exponent=exponent) + + # Create the key objects + n = p * q + + return (PublicKey(n, e), PrivateKey(n, e, d, p, q)) + + +__all__ = ["PublicKey", "PrivateKey", "newkeys"] + +if __name__ == "__main__": + import doctest + + try: + for count in range(100): + (failures, tests) = doctest.testmod() + if failures: + break + + if (count % 10 == 0 and count) or count == 1: + print("%i times" % count) + except KeyboardInterrupt: + print("Aborted") + else: + print("Doctests done") diff --git a/module/scripts/lib/rsa/parallel.py b/module/scripts/lib/rsa/parallel.py new file mode 100644 index 0000000..5020edb --- /dev/null +++ b/module/scripts/lib/rsa/parallel.py @@ -0,0 +1,96 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Functions for parallel computation on multiple cores. + +Introduced in Python-RSA 3.1. + +.. note:: + + Requires Python 2.6 or newer. + +""" + +import multiprocessing as mp +from multiprocessing.connection import Connection + +import rsa.prime +import rsa.randnum + + +def _find_prime(nbits: int, pipe: Connection) -> None: + while True: + integer = rsa.randnum.read_random_odd_int(nbits) + + # Test for primeness + if rsa.prime.is_prime(integer): + pipe.send(integer) + return + + +def getprime(nbits: int, poolsize: int) -> int: + """Returns a prime number that can be stored in 'nbits' bits. + + Works in multiple threads at the same time. + + >>> p = getprime(128, 3) + >>> rsa.prime.is_prime(p-1) + False + >>> rsa.prime.is_prime(p) + True + >>> rsa.prime.is_prime(p+1) + False + + >>> from rsa import common + >>> common.bit_size(p) == 128 + True + + """ + + (pipe_recv, pipe_send) = mp.Pipe(duplex=False) + + # Create processes + try: + procs = [mp.Process(target=_find_prime, args=(nbits, pipe_send)) for _ in range(poolsize)] + # Start processes + for p in procs: + p.start() + + result = pipe_recv.recv() + finally: + pipe_recv.close() + pipe_send.close() + + # Terminate processes + for p in procs: + p.terminate() + + return result + + +__all__ = ["getprime"] + +if __name__ == "__main__": + print("Running doctests 1000x or until failure") + import doctest + + for count in range(100): + (failures, tests) = doctest.testmod() + if failures: + break + + if count % 10 == 0 and count: + print("%i times" % count) + + print("Doctests done") diff --git a/module/scripts/lib/rsa/pem.py b/module/scripts/lib/rsa/pem.py new file mode 100644 index 0000000..5d26e6e --- /dev/null +++ b/module/scripts/lib/rsa/pem.py @@ -0,0 +1,134 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Functions that load and write PEM-encoded files.""" + +import base64 +import typing + +# Should either be ASCII strings or bytes. +FlexiText = typing.Union[str, bytes] + + +def _markers(pem_marker: FlexiText) -> typing.Tuple[bytes, bytes]: + """ + Returns the start and end PEM markers, as bytes. + """ + + if not isinstance(pem_marker, bytes): + pem_marker = pem_marker.encode("ascii") + + return ( + b"-----BEGIN " + pem_marker + b"-----", + b"-----END " + pem_marker + b"-----", + ) + + +def _pem_lines(contents: bytes, pem_start: bytes, pem_end: bytes) -> typing.Iterator[bytes]: + """Generator over PEM lines between pem_start and pem_end.""" + + in_pem_part = False + seen_pem_start = False + + for line in contents.splitlines(): + line = line.strip() + + # Skip empty lines + if not line: + continue + + # Handle start marker + if line == pem_start: + if in_pem_part: + raise ValueError('Seen start marker "%r" twice' % pem_start) + + in_pem_part = True + seen_pem_start = True + continue + + # Skip stuff before first marker + if not in_pem_part: + continue + + # Handle end marker + if in_pem_part and line == pem_end: + in_pem_part = False + break + + # Load fields + if b":" in line: + continue + + yield line + + # Do some sanity checks + if not seen_pem_start: + raise ValueError('No PEM start marker "%r" found' % pem_start) + + if in_pem_part: + raise ValueError('No PEM end marker "%r" found' % pem_end) + + +def load_pem(contents: FlexiText, pem_marker: FlexiText) -> bytes: + """Loads a PEM file. + + :param contents: the contents of the file to interpret + :param pem_marker: the marker of the PEM content, such as 'RSA PRIVATE KEY' + when your file has '-----BEGIN RSA PRIVATE KEY-----' and + '-----END RSA PRIVATE KEY-----' markers. + + :return: the base64-decoded content between the start and end markers. + + @raise ValueError: when the content is invalid, for example when the start + marker cannot be found. + + """ + + # We want bytes, not text. If it's text, it can be converted to ASCII bytes. + if not isinstance(contents, bytes): + contents = contents.encode("ascii") + + (pem_start, pem_end) = _markers(pem_marker) + pem_lines = [line for line in _pem_lines(contents, pem_start, pem_end)] + + # Base64-decode the contents + pem = b"".join(pem_lines) + return base64.standard_b64decode(pem) + + +def save_pem(contents: bytes, pem_marker: FlexiText) -> bytes: + """Saves a PEM file. + + :param contents: the contents to encode in PEM format + :param pem_marker: the marker of the PEM content, such as 'RSA PRIVATE KEY' + when your file has '-----BEGIN RSA PRIVATE KEY-----' and + '-----END RSA PRIVATE KEY-----' markers. + + :return: the base64-encoded content between the start and end markers, as bytes. + + """ + + (pem_start, pem_end) = _markers(pem_marker) + + b64 = base64.standard_b64encode(contents).replace(b"\n", b"") + pem_lines = [pem_start] + + for block_start in range(0, len(b64), 64): + block = b64[block_start : block_start + 64] + pem_lines.append(block) + + pem_lines.append(pem_end) + pem_lines.append(b"") + + return b"\n".join(pem_lines) diff --git a/module/scripts/lib/rsa/pkcs1.py b/module/scripts/lib/rsa/pkcs1.py new file mode 100644 index 0000000..ec6998e --- /dev/null +++ b/module/scripts/lib/rsa/pkcs1.py @@ -0,0 +1,485 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Functions for PKCS#1 version 1.5 encryption and signing + +This module implements certain functionality from PKCS#1 version 1.5. For a +very clear example, read http://www.di-mgt.com.au/rsa_alg.html#pkcs1schemes + +At least 8 bytes of random padding is used when encrypting a message. This makes +these methods much more secure than the ones in the ``rsa`` module. + +WARNING: this module leaks information when decryption fails. The exceptions +that are raised contain the Python traceback information, which can be used to +deduce where in the process the failure occurred. DO NOT PASS SUCH INFORMATION +to your users. +""" + +import hashlib +import os +import sys +import typing +from hmac import compare_digest + +from . import common, transform, core, key + +if typing.TYPE_CHECKING: + HashType = hashlib._Hash +else: + HashType = typing.Any + +# ASN.1 codes that describe the hash algorithm used. +HASH_ASN1 = { + "MD5": b"\x30\x20\x30\x0c\x06\x08\x2a\x86\x48\x86\xf7\x0d\x02\x05\x05\x00\x04\x10", + "SHA-1": b"\x30\x21\x30\x09\x06\x05\x2b\x0e\x03\x02\x1a\x05\x00\x04\x14", + "SHA-224": b"\x30\x2d\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x04\x05\x00\x04\x1c", + "SHA-256": b"\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x01\x05\x00\x04\x20", + "SHA-384": b"\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x02\x05\x00\x04\x30", + "SHA-512": b"\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x03\x05\x00\x04\x40", +} + +HASH_METHODS: typing.Dict[str, typing.Callable[[], HashType]] = { + "MD5": hashlib.md5, + "SHA-1": hashlib.sha1, + "SHA-224": hashlib.sha224, + "SHA-256": hashlib.sha256, + "SHA-384": hashlib.sha384, + "SHA-512": hashlib.sha512, +} +"""Hash methods supported by this library.""" + + +if sys.version_info >= (3, 6): + # Python 3.6 introduced SHA3 support. + HASH_ASN1.update( + { + "SHA3-256": b"\x30\x31\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x08\x05\x00\x04\x20", + "SHA3-384": b"\x30\x41\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x09\x05\x00\x04\x30", + "SHA3-512": b"\x30\x51\x30\x0d\x06\x09\x60\x86\x48\x01\x65\x03\x04\x02\x0a\x05\x00\x04\x40", + } + ) + + HASH_METHODS.update( + { + "SHA3-256": hashlib.sha3_256, + "SHA3-384": hashlib.sha3_384, + "SHA3-512": hashlib.sha3_512, + } + ) + + +class CryptoError(Exception): + """Base class for all exceptions in this module.""" + + +class DecryptionError(CryptoError): + """Raised when decryption fails.""" + + +class VerificationError(CryptoError): + """Raised when verification fails.""" + + +def _pad_for_encryption(message: bytes, target_length: int) -> bytes: + r"""Pads the message for encryption, returning the padded message. + + :return: 00 02 RANDOM_DATA 00 MESSAGE + + >>> block = _pad_for_encryption(b'hello', 16) + >>> len(block) + 16 + >>> block[0:2] + b'\x00\x02' + >>> block[-6:] + b'\x00hello' + + """ + + max_msglength = target_length - 11 + msglength = len(message) + + if msglength > max_msglength: + raise OverflowError( + "%i bytes needed for message, but there is only" + " space for %i" % (msglength, max_msglength) + ) + + # Get random padding + padding = b"" + padding_length = target_length - msglength - 3 + + # We remove 0-bytes, so we'll end up with less padding than we've asked for, + # so keep adding data until we're at the correct length. + while len(padding) < padding_length: + needed_bytes = padding_length - len(padding) + + # Always read at least 8 bytes more than we need, and trim off the rest + # after removing the 0-bytes. This increases the chance of getting + # enough bytes, especially when needed_bytes is small + new_padding = os.urandom(needed_bytes + 5) + new_padding = new_padding.replace(b"\x00", b"") + padding = padding + new_padding[:needed_bytes] + + assert len(padding) == padding_length + + return b"".join([b"\x00\x02", padding, b"\x00", message]) + + +def _pad_for_signing(message: bytes, target_length: int) -> bytes: + r"""Pads the message for signing, returning the padded message. + + The padding is always a repetition of FF bytes. + + :return: 00 01 PADDING 00 MESSAGE + + >>> block = _pad_for_signing(b'hello', 16) + >>> len(block) + 16 + >>> block[0:2] + b'\x00\x01' + >>> block[-6:] + b'\x00hello' + >>> block[2:-6] + b'\xff\xff\xff\xff\xff\xff\xff\xff' + + """ + + max_msglength = target_length - 11 + msglength = len(message) + + if msglength > max_msglength: + raise OverflowError( + "%i bytes needed for message, but there is only" + " space for %i" % (msglength, max_msglength) + ) + + padding_length = target_length - msglength - 3 + + return b"".join([b"\x00\x01", padding_length * b"\xff", b"\x00", message]) + + +def encrypt(message: bytes, pub_key: key.PublicKey) -> bytes: + """Encrypts the given message using PKCS#1 v1.5 + + :param message: the message to encrypt. Must be a byte string no longer than + ``k-11`` bytes, where ``k`` is the number of bytes needed to encode + the ``n`` component of the public key. + :param pub_key: the :py:class:`rsa.PublicKey` to encrypt with. + :raise OverflowError: when the message is too large to fit in the padded + block. + + >>> from rsa import key, common + >>> (pub_key, priv_key) = key.newkeys(256) + >>> message = b'hello' + >>> crypto = encrypt(message, pub_key) + + The crypto text should be just as long as the public key 'n' component: + + >>> len(crypto) == common.byte_size(pub_key.n) + True + + """ + + keylength = common.byte_size(pub_key.n) + padded = _pad_for_encryption(message, keylength) + + payload = transform.bytes2int(padded) + encrypted = core.encrypt_int(payload, pub_key.e, pub_key.n) + block = transform.int2bytes(encrypted, keylength) + + return block + + +def decrypt(crypto: bytes, priv_key: key.PrivateKey) -> bytes: + r"""Decrypts the given message using PKCS#1 v1.5 + + The decryption is considered 'failed' when the resulting cleartext doesn't + start with the bytes 00 02, or when the 00 byte between the padding and + the message cannot be found. + + :param crypto: the crypto text as returned by :py:func:`rsa.encrypt` + :param priv_key: the :py:class:`rsa.PrivateKey` to decrypt with. + :raise DecryptionError: when the decryption fails. No details are given as + to why the code thinks the decryption fails, as this would leak + information about the private key. + + + >>> import rsa + >>> (pub_key, priv_key) = rsa.newkeys(256) + + It works with strings: + + >>> crypto = encrypt(b'hello', pub_key) + >>> decrypt(crypto, priv_key) + b'hello' + + And with binary data: + + >>> crypto = encrypt(b'\x00\x00\x00\x00\x01', pub_key) + >>> decrypt(crypto, priv_key) + b'\x00\x00\x00\x00\x01' + + Altering the encrypted information will *likely* cause a + :py:class:`rsa.pkcs1.DecryptionError`. If you want to be *sure*, use + :py:func:`rsa.sign`. + + + .. warning:: + + Never display the stack trace of a + :py:class:`rsa.pkcs1.DecryptionError` exception. It shows where in the + code the exception occurred, and thus leaks information about the key. + It's only a tiny bit of information, but every bit makes cracking the + keys easier. + + >>> crypto = encrypt(b'hello', pub_key) + >>> crypto = crypto[0:5] + b'X' + crypto[6:] # change a byte + >>> decrypt(crypto, priv_key) + Traceback (most recent call last): + ... + rsa.pkcs1.DecryptionError: Decryption failed + + """ + + blocksize = common.byte_size(priv_key.n) + encrypted = transform.bytes2int(crypto) + decrypted = priv_key.blinded_decrypt(encrypted) + cleartext = transform.int2bytes(decrypted, blocksize) + + # Detect leading zeroes in the crypto. These are not reflected in the + # encrypted value (as leading zeroes do not influence the value of an + # integer). This fixes CVE-2020-13757. + if len(crypto) > blocksize: + # This is operating on public information, so doesn't need to be constant-time. + raise DecryptionError("Decryption failed") + + # If we can't find the cleartext marker, decryption failed. + cleartext_marker_bad = not compare_digest(cleartext[:2], b"\x00\x02") + + # Find the 00 separator between the padding and the message + sep_idx = cleartext.find(b"\x00", 2) + + # sep_idx indicates the position of the `\x00` separator that separates the + # padding from the actual message. The padding should be at least 8 bytes + # long (see https://tools.ietf.org/html/rfc8017#section-7.2.2 step 3), which + # means the separator should be at least at index 10 (because of the + # `\x00\x02` marker that precedes it). + sep_idx_bad = sep_idx < 10 + + anything_bad = cleartext_marker_bad | sep_idx_bad + if anything_bad: + raise DecryptionError("Decryption failed") + + return cleartext[sep_idx + 1 :] + + +def sign_hash(hash_value: bytes, priv_key: key.PrivateKey, hash_method: str) -> bytes: + """Signs a precomputed hash with the private key. + + Hashes the message, then signs the hash with the given key. This is known + as a "detached signature", because the message itself isn't altered. + + :param hash_value: A precomputed hash to sign (ignores message). + :param priv_key: the :py:class:`rsa.PrivateKey` to sign with + :param hash_method: the hash method used on the message. Use 'MD5', 'SHA-1', + 'SHA-224', SHA-256', 'SHA-384' or 'SHA-512'. + :return: a message signature block. + :raise OverflowError: if the private key is too small to contain the + requested hash. + + """ + + # Get the ASN1 code for this hash method + if hash_method not in HASH_ASN1: + raise ValueError("Invalid hash method: %s" % hash_method) + asn1code = HASH_ASN1[hash_method] + + # Encrypt the hash with the private key + cleartext = asn1code + hash_value + keylength = common.byte_size(priv_key.n) + padded = _pad_for_signing(cleartext, keylength) + + payload = transform.bytes2int(padded) + encrypted = priv_key.blinded_encrypt(payload) + block = transform.int2bytes(encrypted, keylength) + + return block + + +def sign(message: bytes, priv_key: key.PrivateKey, hash_method: str) -> bytes: + """Signs the message with the private key. + + Hashes the message, then signs the hash with the given key. This is known + as a "detached signature", because the message itself isn't altered. + + :param message: the message to sign. Can be an 8-bit string or a file-like + object. If ``message`` has a ``read()`` method, it is assumed to be a + file-like object. + :param priv_key: the :py:class:`rsa.PrivateKey` to sign with + :param hash_method: the hash method used on the message. Use 'MD5', 'SHA-1', + 'SHA-224', SHA-256', 'SHA-384' or 'SHA-512'. + :return: a message signature block. + :raise OverflowError: if the private key is too small to contain the + requested hash. + + """ + + msg_hash = compute_hash(message, hash_method) + return sign_hash(msg_hash, priv_key, hash_method) + + +def verify(message: bytes, signature: bytes, pub_key: key.PublicKey) -> str: + """Verifies that the signature matches the message. + + The hash method is detected automatically from the signature. + + :param message: the signed message. Can be an 8-bit string or a file-like + object. If ``message`` has a ``read()`` method, it is assumed to be a + file-like object. + :param signature: the signature block, as created with :py:func:`rsa.sign`. + :param pub_key: the :py:class:`rsa.PublicKey` of the person signing the message. + :raise VerificationError: when the signature doesn't match the message. + :returns: the name of the used hash. + + """ + + keylength = common.byte_size(pub_key.n) + encrypted = transform.bytes2int(signature) + decrypted = core.decrypt_int(encrypted, pub_key.e, pub_key.n) + clearsig = transform.int2bytes(decrypted, keylength) + + # Get the hash method + method_name = _find_method_hash(clearsig) + message_hash = compute_hash(message, method_name) + + # Reconstruct the expected padded hash + cleartext = HASH_ASN1[method_name] + message_hash + expected = _pad_for_signing(cleartext, keylength) + + if len(signature) != keylength: + raise VerificationError("Verification failed") + + # Compare with the signed one + if expected != clearsig: + raise VerificationError("Verification failed") + + return method_name + + +def find_signature_hash(signature: bytes, pub_key: key.PublicKey) -> str: + """Returns the hash name detected from the signature. + + If you also want to verify the message, use :py:func:`rsa.verify()` instead. + It also returns the name of the used hash. + + :param signature: the signature block, as created with :py:func:`rsa.sign`. + :param pub_key: the :py:class:`rsa.PublicKey` of the person signing the message. + :returns: the name of the used hash. + """ + + keylength = common.byte_size(pub_key.n) + encrypted = transform.bytes2int(signature) + decrypted = core.decrypt_int(encrypted, pub_key.e, pub_key.n) + clearsig = transform.int2bytes(decrypted, keylength) + + return _find_method_hash(clearsig) + + +def yield_fixedblocks(infile: typing.BinaryIO, blocksize: int) -> typing.Iterator[bytes]: + """Generator, yields each block of ``blocksize`` bytes in the input file. + + :param infile: file to read and separate in blocks. + :param blocksize: block size in bytes. + :returns: a generator that yields the contents of each block + """ + + while True: + block = infile.read(blocksize) + + read_bytes = len(block) + if read_bytes == 0: + break + + yield block + + if read_bytes < blocksize: + break + + +def compute_hash(message: typing.Union[bytes, typing.BinaryIO], method_name: str) -> bytes: + """Returns the message digest. + + :param message: the signed message. Can be an 8-bit string or a file-like + object. If ``message`` has a ``read()`` method, it is assumed to be a + file-like object. + :param method_name: the hash method, must be a key of + :py:const:`rsa.pkcs1.HASH_METHODS`. + + """ + + if method_name not in HASH_METHODS: + raise ValueError("Invalid hash method: %s" % method_name) + + method = HASH_METHODS[method_name] + hasher = method() + + if isinstance(message, bytes): + hasher.update(message) + else: + assert hasattr(message, "read") and hasattr(message.read, "__call__") + # read as 1K blocks + for block in yield_fixedblocks(message, 1024): + hasher.update(block) + + return hasher.digest() + + +def _find_method_hash(clearsig: bytes) -> str: + """Finds the hash method. + + :param clearsig: full padded ASN1 and hash. + :return: the used hash method. + :raise VerificationFailed: when the hash method cannot be found + """ + + for (hashname, asn1code) in HASH_ASN1.items(): + if asn1code in clearsig: + return hashname + + raise VerificationError("Verification failed") + + +__all__ = [ + "encrypt", + "decrypt", + "sign", + "verify", + "DecryptionError", + "VerificationError", + "CryptoError", +] + +if __name__ == "__main__": + print("Running doctests 1000x or until failure") + import doctest + + for count in range(1000): + (failures, tests) = doctest.testmod() + if failures: + break + + if count % 100 == 0 and count: + print("%i times" % count) + + print("Doctests done") diff --git a/module/scripts/lib/rsa/pkcs1_v2.py b/module/scripts/lib/rsa/pkcs1_v2.py new file mode 100644 index 0000000..d68b907 --- /dev/null +++ b/module/scripts/lib/rsa/pkcs1_v2.py @@ -0,0 +1,100 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Functions for PKCS#1 version 2 encryption and signing + +This module implements certain functionality from PKCS#1 version 2. Main +documentation is RFC 2437: https://tools.ietf.org/html/rfc2437 +""" + +from rsa import ( + common, + pkcs1, + transform, +) + + +def mgf1(seed: bytes, length: int, hasher: str = "SHA-1") -> bytes: + """ + MGF1 is a Mask Generation Function based on a hash function. + + A mask generation function takes an octet string of variable length and a + desired output length as input, and outputs an octet string of the desired + length. The plaintext-awareness of RSAES-OAEP relies on the random nature of + the output of the mask generation function, which in turn relies on the + random nature of the underlying hash. + + :param bytes seed: seed from which mask is generated, an octet string + :param int length: intended length in octets of the mask, at most 2^32(hLen) + :param str hasher: hash function (hLen denotes the length in octets of the hash + function output) + + :return: mask, an octet string of length `length` + :rtype: bytes + + :raise OverflowError: when `length` is too large for the specified `hasher` + :raise ValueError: when specified `hasher` is invalid + """ + + try: + hash_length = pkcs1.HASH_METHODS[hasher]().digest_size + except KeyError as ex: + raise ValueError( + "Invalid `hasher` specified. Please select one of: {hash_list}".format( + hash_list=", ".join(sorted(pkcs1.HASH_METHODS.keys())) + ) + ) from ex + + # If l > 2^32(hLen), output "mask too long" and stop. + if length > (2 ** 32 * hash_length): + raise OverflowError( + "Desired length should be at most 2**32 times the hasher's output " + "length ({hash_length} for {hasher} function)".format( + hash_length=hash_length, + hasher=hasher, + ) + ) + + # Looping `counter` from 0 to ceil(l / hLen)-1, build `output` based on the + # hashes formed by (`seed` + C), being `C` an octet string of length 4 + # generated by converting `counter` with the primitive I2OSP + output = b"".join( + pkcs1.compute_hash( + seed + transform.int2bytes(counter, fill_size=4), + method_name=hasher, + ) + for counter in range(common.ceil_div(length, hash_length) + 1) + ) + + # Output the leading `length` octets of `output` as the octet string mask. + return output[:length] + + +__all__ = [ + "mgf1", +] + +if __name__ == "__main__": + print("Running doctests 1000x or until failure") + import doctest + + for count in range(1000): + (failures, tests) = doctest.testmod() + if failures: + break + + if count % 100 == 0 and count: + print("%i times" % count) + + print("Doctests done") diff --git a/module/scripts/lib/rsa/prime.py b/module/scripts/lib/rsa/prime.py new file mode 100644 index 0000000..ec486bc --- /dev/null +++ b/module/scripts/lib/rsa/prime.py @@ -0,0 +1,198 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Numerical functions related to primes. + +Implementation based on the book Algorithm Design by Michael T. Goodrich and +Roberto Tamassia, 2002. +""" + +import rsa.common +import rsa.randnum + +__all__ = ["getprime", "are_relatively_prime"] + + +def gcd(p: int, q: int) -> int: + """Returns the greatest common divisor of p and q + + >>> gcd(48, 180) + 12 + """ + + while q != 0: + (p, q) = (q, p % q) + return p + + +def get_primality_testing_rounds(number: int) -> int: + """Returns minimum number of rounds for Miller-Rabing primality testing, + based on number bitsize. + + According to NIST FIPS 186-4, Appendix C, Table C.3, minimum number of + rounds of M-R testing, using an error probability of 2 ** (-100), for + different p, q bitsizes are: + * p, q bitsize: 512; rounds: 7 + * p, q bitsize: 1024; rounds: 4 + * p, q bitsize: 1536; rounds: 3 + See: http://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.186-4.pdf + """ + + # Calculate number bitsize. + bitsize = rsa.common.bit_size(number) + # Set number of rounds. + if bitsize >= 1536: + return 3 + if bitsize >= 1024: + return 4 + if bitsize >= 512: + return 7 + # For smaller bitsizes, set arbitrary number of rounds. + return 10 + + +def miller_rabin_primality_testing(n: int, k: int) -> bool: + """Calculates whether n is composite (which is always correct) or prime + (which theoretically is incorrect with error probability 4**-k), by + applying Miller-Rabin primality testing. + + For reference and implementation example, see: + https://en.wikipedia.org/wiki/Miller%E2%80%93Rabin_primality_test + + :param n: Integer to be tested for primality. + :type n: int + :param k: Number of rounds (witnesses) of Miller-Rabin testing. + :type k: int + :return: False if the number is composite, True if it's probably prime. + :rtype: bool + """ + + # prevent potential infinite loop when d = 0 + if n < 2: + return False + + # Decompose (n - 1) to write it as (2 ** r) * d + # While d is even, divide it by 2 and increase the exponent. + d = n - 1 + r = 0 + + while not (d & 1): + r += 1 + d >>= 1 + + # Test k witnesses. + for _ in range(k): + # Generate random integer a, where 2 <= a <= (n - 2) + a = rsa.randnum.randint(n - 3) + 1 + + x = pow(a, d, n) + if x == 1 or x == n - 1: + continue + + for _ in range(r - 1): + x = pow(x, 2, n) + if x == 1: + # n is composite. + return False + if x == n - 1: + # Exit inner loop and continue with next witness. + break + else: + # If loop doesn't break, n is composite. + return False + + return True + + +def is_prime(number: int) -> bool: + """Returns True if the number is prime, and False otherwise. + + >>> is_prime(2) + True + >>> is_prime(42) + False + >>> is_prime(41) + True + """ + + # Check for small numbers. + if number < 10: + return number in {2, 3, 5, 7} + + # Check for even numbers. + if not (number & 1): + return False + + # Calculate minimum number of rounds. + k = get_primality_testing_rounds(number) + + # Run primality testing with (minimum + 1) rounds. + return miller_rabin_primality_testing(number, k + 1) + + +def getprime(nbits: int) -> int: + """Returns a prime number that can be stored in 'nbits' bits. + + >>> p = getprime(128) + >>> is_prime(p-1) + False + >>> is_prime(p) + True + >>> is_prime(p+1) + False + + >>> from rsa import common + >>> common.bit_size(p) == 128 + True + """ + + assert nbits > 3 # the loop will hang on too small numbers + + while True: + integer = rsa.randnum.read_random_odd_int(nbits) + + # Test for primeness + if is_prime(integer): + return integer + + # Retry if not prime + + +def are_relatively_prime(a: int, b: int) -> bool: + """Returns True if a and b are relatively prime, and False if they + are not. + + >>> are_relatively_prime(2, 3) + True + >>> are_relatively_prime(2, 4) + False + """ + + d = gcd(a, b) + return d == 1 + + +if __name__ == "__main__": + print("Running doctests 1000x or until failure") + import doctest + + for count in range(1000): + (failures, tests) = doctest.testmod() + if failures: + break + + if count % 100 == 0 and count: + print("%i times" % count) + + print("Doctests done") diff --git a/module/scripts/lib/rsa/py.typed b/module/scripts/lib/rsa/py.typed new file mode 100644 index 0000000..6c27071 --- /dev/null +++ b/module/scripts/lib/rsa/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. The rsa package uses inline types. diff --git a/module/scripts/lib/rsa/randnum.py b/module/scripts/lib/rsa/randnum.py new file mode 100644 index 0000000..c65facd --- /dev/null +++ b/module/scripts/lib/rsa/randnum.py @@ -0,0 +1,95 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Functions for generating random numbers.""" + +# Source inspired by code by Yesudeep Mangalapilly + +import os +import struct + +from rsa import common, transform + + +def read_random_bits(nbits: int) -> bytes: + """Reads 'nbits' random bits. + + If nbits isn't a whole number of bytes, an extra byte will be appended with + only the lower bits set. + """ + + nbytes, rbits = divmod(nbits, 8) + + # Get the random bytes + randomdata = os.urandom(nbytes) + + # Add the remaining random bits + if rbits > 0: + randomvalue = ord(os.urandom(1)) + randomvalue >>= 8 - rbits + randomdata = struct.pack("B", randomvalue) + randomdata + + return randomdata + + +def read_random_int(nbits: int) -> int: + """Reads a random integer of approximately nbits bits.""" + + randomdata = read_random_bits(nbits) + value = transform.bytes2int(randomdata) + + # Ensure that the number is large enough to just fill out the required + # number of bits. + value |= 1 << (nbits - 1) + + return value + + +def read_random_odd_int(nbits: int) -> int: + """Reads a random odd integer of approximately nbits bits. + + >>> read_random_odd_int(512) & 1 + 1 + """ + + value = read_random_int(nbits) + + # Make sure it's odd + return value | 1 + + +def randint(maxvalue: int) -> int: + """Returns a random integer x with 1 <= x <= maxvalue + + May take a very long time in specific situations. If maxvalue needs N bits + to store, the closer maxvalue is to (2 ** N) - 1, the faster this function + is. + """ + + bit_size = common.bit_size(maxvalue) + + tries = 0 + while True: + value = read_random_int(bit_size) + if value <= maxvalue: + break + + if tries % 10 == 0 and tries: + # After a lot of tries to get the right number of bits but still + # smaller than maxvalue, decrease the number of bits by 1. That'll + # dramatically increase the chances to get a large enough number. + bit_size -= 1 + tries += 1 + + return value diff --git a/module/scripts/lib/rsa/transform.py b/module/scripts/lib/rsa/transform.py new file mode 100644 index 0000000..c609b65 --- /dev/null +++ b/module/scripts/lib/rsa/transform.py @@ -0,0 +1,72 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Data transformation functions. + +From bytes to a number, number to bytes, etc. +""" + +import math + + +def bytes2int(raw_bytes: bytes) -> int: + r"""Converts a list of bytes or an 8-bit string to an integer. + + When using unicode strings, encode it to some encoding like UTF8 first. + + >>> (((128 * 256) + 64) * 256) + 15 + 8405007 + >>> bytes2int(b'\x80@\x0f') + 8405007 + + """ + return int.from_bytes(raw_bytes, "big", signed=False) + + +def int2bytes(number: int, fill_size: int = 0) -> bytes: + """ + Convert an unsigned integer to bytes (big-endian):: + + Does not preserve leading zeros if you don't specify a fill size. + + :param number: + Integer value + :param fill_size: + If the optional fill size is given the length of the resulting + byte string is expected to be the fill size and will be padded + with prefix zero bytes to satisfy that length. + :returns: + Raw bytes (base-256 representation). + :raises: + ``OverflowError`` when fill_size is given and the number takes up more + bytes than fit into the block. This requires the ``overflow`` + argument to this function to be set to ``False`` otherwise, no + error will be raised. + """ + + if number < 0: + raise ValueError("Number must be an unsigned integer: %d" % number) + + bytes_required = max(1, math.ceil(number.bit_length() / 8)) + + if fill_size > 0: + return number.to_bytes(fill_size, "big") + + return number.to_bytes(bytes_required, "big") + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/module/scripts/lib/rsa/util.py b/module/scripts/lib/rsa/util.py new file mode 100644 index 0000000..087caf8 --- /dev/null +++ b/module/scripts/lib/rsa/util.py @@ -0,0 +1,97 @@ +# Copyright 2011 Sybren A. Stüvel +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Utility functions.""" + +import sys +from optparse import OptionParser + +import rsa.key + + +def private_to_public() -> None: + """Reads a private key and outputs the corresponding public key.""" + + # Parse the CLI options + parser = OptionParser( + usage="usage: %prog [options]", + description="Reads a private key and outputs the " + "corresponding public key. Both private and public keys use " + "the format described in PKCS#1 v1.5", + ) + + parser.add_option( + "-i", + "--input", + dest="infilename", + type="string", + help="Input filename. Reads from stdin if not specified", + ) + parser.add_option( + "-o", + "--output", + dest="outfilename", + type="string", + help="Output filename. Writes to stdout of not specified", + ) + + parser.add_option( + "--inform", + dest="inform", + help="key format of input - default PEM", + choices=("PEM", "DER"), + default="PEM", + ) + + parser.add_option( + "--outform", + dest="outform", + help="key format of output - default PEM", + choices=("PEM", "DER"), + default="PEM", + ) + + (cli, cli_args) = parser.parse_args(sys.argv) + + # Read the input data + if cli.infilename: + print( + "Reading private key from %s in %s format" % (cli.infilename, cli.inform), + file=sys.stderr, + ) + with open(cli.infilename, "rb") as infile: + in_data = infile.read() + else: + print("Reading private key from stdin in %s format" % cli.inform, file=sys.stderr) + in_data = sys.stdin.read().encode("ascii") + + assert type(in_data) == bytes, type(in_data) + + # Take the public fields and create a public key + priv_key = rsa.key.PrivateKey.load_pkcs1(in_data, cli.inform) + pub_key = rsa.key.PublicKey(priv_key.n, priv_key.e) + + # Save to the output file + out_data = pub_key.save_pkcs1(cli.outform) + + if cli.outfilename: + print( + "Writing public key to %s in %s format" % (cli.outfilename, cli.outform), + file=sys.stderr, + ) + with open(cli.outfilename, "wb") as outfile: + outfile.write(out_data) + else: + print("Writing public key to stdout in %s format" % cli.outform, file=sys.stderr) + sys.stdout.write(out_data.decode("ascii")) diff --git a/module/scripts/lib/telethon/__init__.py b/module/scripts/lib/telethon/__init__.py new file mode 100644 index 0000000..c3334d7 --- /dev/null +++ b/module/scripts/lib/telethon/__init__.py @@ -0,0 +1,13 @@ +from .client.telegramclient import TelegramClient +from .network import connection +from .tl.custom import Button +from .tl import patched as _ # import for its side-effects +from . import version, events, utils, errors, types, functions, custom + +__version__ = version.__version__ + +__all__ = [ + 'TelegramClient', 'Button', + 'types', 'functions', 'custom', 'errors', + 'events', 'utils', 'connection' +] diff --git a/module/scripts/lib/telethon/_updates/__init__.py b/module/scripts/lib/telethon/_updates/__init__.py new file mode 100644 index 0000000..f9cc249 --- /dev/null +++ b/module/scripts/lib/telethon/_updates/__init__.py @@ -0,0 +1,3 @@ +from .entitycache import EntityCache +from .messagebox import MessageBox, GapError, PrematureEndReason +from .session import SessionState, ChannelState, Entity, EntityType diff --git a/module/scripts/lib/telethon/_updates/entitycache.py b/module/scripts/lib/telethon/_updates/entitycache.py new file mode 100644 index 0000000..f8b18a9 --- /dev/null +++ b/module/scripts/lib/telethon/_updates/entitycache.py @@ -0,0 +1,62 @@ +from .session import EntityType, Entity +from ..tl import types + + +_sentinel = object() + + +class EntityCache: + def __init__( + self, + hash_map: dict = _sentinel, + self_id: int = None, + self_bot: bool = None + ): + self.hash_map = {} if hash_map is _sentinel else hash_map + self.self_id = self_id + self.self_bot = self_bot + + def set_self_user(self, id, bot, hash): + self.self_id = id + self.self_bot = bot + if hash: + self.hash_map[id] = (hash, EntityType.BOT if bot else EntityType.USER) + + def get(self, id): + try: + hash, ty = self.hash_map[id] + return Entity(ty, id, hash) + except KeyError: + return None + + def extend(self, users, chats): + # See https://core.telegram.org/api/min for "issues" with "min constructors". + self.hash_map.update( + (u.id, ( + u.access_hash, + EntityType.BOT if u.bot else EntityType.USER, + )) + for u in users + if getattr(u, 'access_hash', None) and not u.min + ) + self.hash_map.update( + (c.id, ( + c.access_hash, + EntityType.COMMUNITY if isinstance(c, types.Community) else ( + EntityType.MEGAGROUP if getattr(c, 'megagroup', None) else ( + EntityType.GIGAGROUP if getattr(c, 'gigagroup', None) else EntityType.CHANNEL + ) + ), + )) + for c in chats + if getattr(c, 'access_hash', None) and not getattr(c, 'min', None) + ) + + def put(self, entity): + self.hash_map[entity.id] = (entity.hash, entity.ty) + + def retain(self, filter): + self.hash_map = {k: v for k, v in self.hash_map.items() if filter(k)} + + def __len__(self): + return len(self.hash_map) diff --git a/module/scripts/lib/telethon/_updates/messagebox.py b/module/scripts/lib/telethon/_updates/messagebox.py new file mode 100644 index 0000000..88c1e71 --- /dev/null +++ b/module/scripts/lib/telethon/_updates/messagebox.py @@ -0,0 +1,825 @@ +""" +This module deals with correct handling of updates, including gaps, and knowing when the code +should "get difference" (the set of updates that the client should know by now minus the set +of updates that it actually knows). + +Each chat has its own [`Entry`] in the [`MessageBox`] (this `struct` is the "entry point"). +At any given time, the message box may be either getting difference for them (entry is in +[`MessageBox::getting_diff_for`]) or not. If not getting difference, a possible gap may be +found for the updates (entry is in [`MessageBox::possible_gaps`]). Otherwise, the entry is +on its happy path. + +Gaps are cleared when they are either resolved on their own (by waiting for a short time) +or because we got the difference for the corresponding entry. + +While there are entries for which their difference must be fetched, +[`MessageBox::check_deadlines`] will always return [`Instant::now`], since "now" is the time +to get the difference. +""" +import asyncio +import datetime +import time +import logging +from enum import Enum +from .session import SessionState, ChannelState +from ..tl import types as tl, functions as fn +from ..helpers import get_running_loop + + +# Telegram sends `seq` equal to `0` when "it doesn't matter", so we use that value too. +NO_SEQ = 0 + +# See https://core.telegram.org/method/updates.getChannelDifference. +BOT_CHANNEL_DIFF_LIMIT = 100000 +USER_CHANNEL_DIFF_LIMIT = 100 + +# > It may be useful to wait up to 0.5 seconds +POSSIBLE_GAP_TIMEOUT = 0.5 + +# After how long without updates the client will "timeout". +# +# When this timeout occurs, the client will attempt to fetch updates by itself, ignoring all the +# updates that arrive in the meantime. After all updates are fetched when this happens, the +# client will resume normal operation, and the timeout will reset. +# +# Documentation recommends 15 minutes without updates (https://core.telegram.org/api/updates). +NO_UPDATES_TIMEOUT = 15 * 60 + +# object() but with a tag to make it easier to debug +class Sentinel: + __slots__ = ('tag',) + + def __init__(self, tag=None): + self.tag = tag or '_' + + def __repr__(self): + return self.tag + +# Entry "enum". +# Account-wide `pts` includes private conversations (one-to-one) and small group chats. +ENTRY_ACCOUNT = Sentinel('ACCOUNT') +# Account-wide `qts` includes only "secret" one-to-one chats. +ENTRY_SECRET = Sentinel('SECRET') +# Integers will be Channel-specific `pts`, and includes "megagroup", "broadcast" and "supergroup" channels. + +# Python's logging doesn't define a TRACE level. Pick halfway between DEBUG and NOTSET. +# We don't define a name for this as libraries shouldn't do that though. +LOG_LEVEL_TRACE = (logging.DEBUG - logging.NOTSET) // 2 + +_sentinel = Sentinel() + +def next_updates_deadline(): + return get_running_loop().time() + NO_UPDATES_TIMEOUT + +def epoch(): + return datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc) + +class GapError(ValueError): + def __repr__(self): + return 'GapError()' + + +class PrematureEndReason(Enum): + TEMPORARY_SERVER_ISSUES = 'tmp' + BANNED = 'ban' + + +# Represents the information needed to correctly handle a specific `tl::enums::Update`. +class PtsInfo: + __slots__ = ('pts', 'pts_count', 'entry') + + def __init__( + self, + pts: int, + pts_count: int, + entry: object + ): + self.pts = pts + self.pts_count = pts_count + self.entry = entry + + @classmethod + def from_update(cls, update): + pts = getattr(update, 'pts', None) + if pts: + pts_count = getattr(update, 'pts_count', None) or 0 + try: + entry = update.message.peer_id.channel_id + except AttributeError: + entry = getattr(update, 'channel_id', None) or ENTRY_ACCOUNT + return cls(pts=pts, pts_count=pts_count, entry=entry) + + qts = getattr(update, 'qts', None) + if qts: + return cls(pts=qts, pts_count=1, entry=ENTRY_SECRET) + + return None + + def __repr__(self): + return f'PtsInfo(pts={self.pts}, pts_count={self.pts_count}, entry={self.entry})' + + +# The state of a particular entry in the message box. +class State: + __slots__ = ('pts', 'deadline') + + def __init__( + self, + # Current local persistent timestamp. + pts: int, + # Next instant when we would get the update difference if no updates arrived before then. + deadline: float + ): + self.pts = pts + self.deadline = deadline + + def __repr__(self): + return f'State(pts={self.pts}, deadline={self.deadline})' + + +# > ### Recovering gaps +# > […] Manually obtaining updates is also required in the following situations: +# > • Loss of sync: a gap was found in `seq` / `pts` / `qts` (as described above). +# > It may be useful to wait up to 0.5 seconds in this situation and abort the sync in case a new update +# > arrives, that fills the gap. +# +# This is really easy to trigger by spamming messages in a channel (with as little as 3 members works), because +# the updates produced by the RPC request take a while to arrive (whereas the read update comes faster alone). +class PossibleGap: + __slots__ = ('deadline', 'updates') + + def __init__( + self, + deadline: float, + # Pending updates (those with a larger PTS, producing the gap which may later be filled). + updates: list # of updates + ): + self.deadline = deadline + self.updates = updates + + def __repr__(self): + return f'PossibleGap(deadline={self.deadline}, update_count={len(self.updates)})' + + +# Represents a "message box" (event `pts` for a specific entry). +# +# See https://core.telegram.org/api/updates#message-related-event-sequences. +class MessageBox: + __slots__ = ('_log', 'map', 'date', 'seq', 'next_deadline', 'possible_gaps', 'getting_diff_for') + + def __init__( + self, + log, + # Map each entry to their current state. + map: dict = _sentinel, # entry -> state + + # Additional fields beyond PTS needed by `ENTRY_ACCOUNT`. + date: datetime.datetime = epoch() + datetime.timedelta(seconds=1), + seq: int = NO_SEQ, + + # Holds the entry with the closest deadline (optimization to avoid recalculating the minimum deadline). + next_deadline: object = None, # entry + + # Which entries have a gap and may soon trigger a need to get difference. + # + # If a gap is found, stores the required information to resolve it (when should it timeout and what updates + # should be held in case the gap is resolved on its own). + # + # Not stored directly in `map` as an optimization (else we would need another way of knowing which entries have + # a gap in them). + possible_gaps: dict = _sentinel, # entry -> possiblegap + + # For which entries are we currently getting difference. + getting_diff_for: set = _sentinel, # entry + ): + self._log = log + self.map = {} if map is _sentinel else map + self.date = date + self.seq = seq + self.next_deadline = next_deadline + self.possible_gaps = {} if possible_gaps is _sentinel else possible_gaps + self.getting_diff_for = set() if getting_diff_for is _sentinel else getting_diff_for + + if __debug__: + self._trace('MessageBox initialized') + + def _trace(self, msg, *args, **kwargs): + # Calls to trace can't really be removed beforehand without some dark magic. + # So every call to trace is prefixed with `if __debug__`` instead, to remove + # it when using `python -O`. Probably unnecessary, but it's nice to avoid + # paying the cost for something that is not used. + self._log.log(LOG_LEVEL_TRACE, 'Current MessageBox state: seq = %r, date = %s, map = %r', + self.seq, self.date.isoformat(), self.map) + self._log.log(LOG_LEVEL_TRACE, msg, *args, **kwargs) + + # region Creation, querying, and setting base state. + + def load(self, session_state, channel_states): + """ + Create a [`MessageBox`] from a previously known update state. + """ + if __debug__: + self._trace('Loading MessageBox with session_state = %r, channel_states = %r', session_state, channel_states) + + deadline = next_updates_deadline() + + self.map.clear() + if session_state.pts != NO_SEQ: + self.map[ENTRY_ACCOUNT] = State(pts=session_state.pts, deadline=deadline) + if session_state.qts != NO_SEQ: + self.map[ENTRY_SECRET] = State(pts=session_state.qts, deadline=deadline) + self.map.update((s.channel_id, State(pts=s.pts, deadline=deadline)) for s in channel_states) + + self.date = datetime.datetime.fromtimestamp(session_state.date, tz=datetime.timezone.utc) + self.seq = session_state.seq + self.next_deadline = ENTRY_ACCOUNT + + def session_state(self): + """ + Return the current state. + + This should be used for persisting the state. + """ + return dict( + pts=self.map[ENTRY_ACCOUNT].pts if ENTRY_ACCOUNT in self.map else NO_SEQ, + qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ, + date=self.date, + seq=self.seq, + ), {id: state.pts for id, state in self.map.items() if isinstance(id, int)} + + def is_empty(self) -> bool: + """ + Return true if the message box is empty and has no state yet. + """ + return ENTRY_ACCOUNT not in self.map + + def check_deadlines(self): + """ + Return the next deadline when receiving updates should timeout. + + If a deadline expired, the corresponding entries will be marked as needing to get its difference. + While there are entries pending of getting their difference, this method returns the current instant. + """ + now = get_running_loop().time() + + if self.getting_diff_for: + return now + + deadline = next_updates_deadline() + + # Most of the time there will be zero or one gap in flight so finding the minimum is cheap. + if self.possible_gaps: + deadline = min(deadline, *(gap.deadline for gap in self.possible_gaps.values())) + elif self.next_deadline in self.map: + deadline = min(deadline, self.map[self.next_deadline].deadline) + + # asyncio's loop time precision only seems to be about 3 decimal places, so it's possible that + # we find the same number again on repeated calls. Without the "or equal" part we would log the + # timeout for updates several times (it also makes sense to get difference if now is the deadline). + if now >= deadline: + # Check all expired entries and add them to the list that needs getting difference. + self.getting_diff_for.update(entry for entry, gap in self.possible_gaps.items() if now >= gap.deadline) + self.getting_diff_for.update(entry for entry, state in self.map.items() if now >= state.deadline) + + if __debug__: + self._trace('Deadlines met, now getting diff for %r', self.getting_diff_for) + + # When extending `getting_diff_for`, it's important to have the moral equivalent of + # `begin_get_diff` (that is, clear possible gaps if we're now getting difference). + for entry in self.getting_diff_for: + self.possible_gaps.pop(entry, None) + + return deadline + + # Reset the deadline for the periods without updates for the given entries. + # + # It also updates the next deadline time to reflect the new closest deadline. + def reset_deadlines(self, entries, deadline): + if not entries: + return + for entry in entries: + if entry not in self.map: + raise RuntimeError('Called reset_deadline on an entry for which we do not have state') + self.map[entry].deadline = deadline + + if self.next_deadline in entries: + # If the updated deadline was the closest one, recalculate the new minimum. + self.next_deadline = min(self.map.items(), key=lambda entry_state: entry_state[1].deadline)[0] + elif self.next_deadline in self.map and deadline < self.map[self.next_deadline].deadline: + # If the updated deadline is smaller than the next deadline, change the next deadline to be the new one. + # Any entry will do, so the one from the last iteration is fine. + self.next_deadline = entry + # else an unrelated deadline was updated, so the closest one remains unchanged. + + # Convenience to reset a channel's deadline, with optional timeout. + def reset_channel_deadline(self, channel_id, timeout): + self.reset_deadlines({channel_id}, get_running_loop().time() + (timeout or NO_UPDATES_TIMEOUT)) + + # Sets the update state. + # + # Should be called right after login if [`MessageBox::new`] was used, otherwise undesirable + # updates will be fetched. + def set_state(self, state, reset=True): + if __debug__: + self._trace('Setting state %s', state) + + deadline = next_updates_deadline() + + if state.pts != NO_SEQ or not reset: + self.map[ENTRY_ACCOUNT] = State(pts=state.pts, deadline=deadline) + else: + self.map.pop(ENTRY_ACCOUNT, None) + + # Telegram seems to use the `qts` for bot accounts, but while applying difference, + # it might be reset back to 0. See issue #3873 for more details. + # + # During login, a value of zero would mean the `pts` is unknown, + # so the map shouldn't contain that entry. + # But while applying difference, if the value is zero, it (probably) + # truly means that's what should be used (hence the `reset` flag). + if state.qts != NO_SEQ or not reset: + self.map[ENTRY_SECRET] = State(pts=state.qts, deadline=deadline) + else: + self.map.pop(ENTRY_SECRET, None) + + self.date = state.date + self.seq = state.seq + + # Like [`MessageBox::set_state`], but for channels. Useful when getting dialogs. + # + # The update state will only be updated if no entry was known previously. + def try_set_channel_state(self, id, pts): + if __debug__: + self._trace('Trying to set channel state for %r: %r', id, pts) + + if id not in self.map: + self.map[id] = State(pts=pts, deadline=next_updates_deadline()) + + # Try to begin getting difference for the given entry. + # Fails if the entry does not have a previously-known state that can be used to get its difference. + # + # Clears any previous gaps. + def try_begin_get_diff(self, entry, reason): + if entry not in self.map: + # Won't actually be able to get difference for this entry if we don't have a pts to start off from. + if entry in self.possible_gaps: + raise RuntimeError('Should not have a possible_gap for an entry not in the state map') + + if __debug__: + self._trace('Should get difference for %r because %s but cannot due to missing hash', entry, reason) + return + + if __debug__: + self._trace('Marking %r as needing difference because %s', entry, reason) + self.getting_diff_for.add(entry) + self.possible_gaps.pop(entry, None) + + # Finish getting difference for the given entry. + # + # It also resets the deadline. + def end_get_diff(self, entry): + try: + self.getting_diff_for.remove(entry) + except KeyError: + raise RuntimeError('Called end_get_diff on an entry which was not getting diff for') + + self.reset_deadlines({entry}, next_updates_deadline()) + assert entry not in self.possible_gaps, "gaps shouldn't be created while getting difference" + + # endregion Creation, querying, and setting base state. + + # region "Normal" updates flow (processing and detection of gaps). + + # Process an update and return what should be done with it. + # + # Updates corresponding to entries for which their difference is currently being fetched + # will be ignored. While according to the [updates' documentation]: + # + # > Implementations [have] to postpone updates received via the socket while + # > filling gaps in the event and `Update` sequences, as well as avoid filling + # > gaps in the same sequence. + # + # In practice, these updates should have also been retrieved through getting difference. + # + # [updates documentation] https://core.telegram.org/api/updates + def process_updates( + self, + updates, + chat_hashes, + result, # out list of updates; returns list of user, chat, or raise if gap + ): + + # v1 has never sent updates produced by the client itself to the handlers. + # However proper update handling requires those to be processed. + # This is an ugly workaround for that. + self_outgoing = getattr(updates, '_self_outgoing', False) + real_result = result + result = [] + + date = getattr(updates, 'date', None) + seq = getattr(updates, 'seq', None) + seq_start = getattr(updates, 'seq_start', None) + users = getattr(updates, 'users', None) or [] + chats = getattr(updates, 'chats', None) or [] + + if __debug__: + self._trace('Processing updates with seq = %r, seq_start = %r, date = %s: %s', + seq, seq_start, date.isoformat() if date else None, updates) + + if date is None: + # updatesTooLong is the only one with no date (we treat it as a gap) + self.try_begin_get_diff(ENTRY_ACCOUNT, 'received updatesTooLong') + raise GapError + if seq is None: + seq = NO_SEQ + if seq_start is None: + seq_start = seq + + # updateShort is the only update which cannot be dispatched directly but doesn't have 'updates' field + updates = getattr(updates, 'updates', None) or [updates.update if isinstance(updates, tl.UpdateShort) else updates] + + for u in updates: + u._self_outgoing = self_outgoing + + # > For all the other [not `updates` or `updatesCombined`] `Updates` type constructors + # > there is no need to check `seq` or change a local state. + if seq_start != NO_SEQ: + if self.seq + 1 > seq_start: + # Skipping updates that were already handled + if __debug__: + self._trace('Skipping updates as they should have already been handled') + return (users, chats) + elif self.seq + 1 < seq_start: + # Gap detected + self.try_begin_get_diff(ENTRY_ACCOUNT, 'detected gap') + raise GapError + # else apply + + def _sort_gaps(update): + pts = PtsInfo.from_update(update) + return pts.pts - pts.pts_count if pts else 0 + + reset_deadlines = set() # temporary buffer + + result.extend(filter(None, ( + self.apply_pts_info(u, reset_deadlines=reset_deadlines) + # Telegram can send updates out of order (e.g. ReadChannelInbox first + # and then NewChannelMessage, both with the same pts, but the count is + # 0 and 1 respectively), so we sort them first. + for u in sorted(updates, key=_sort_gaps)))) + + self.reset_deadlines(reset_deadlines, next_updates_deadline()) + + if self.possible_gaps: + if __debug__: + self._trace('Trying to re-apply %r possible gaps', len(self.possible_gaps)) + + # For each update in possible gaps, see if the gap has been resolved already. + for key in list(self.possible_gaps.keys()): + self.possible_gaps[key].updates.sort(key=_sort_gaps) + + for _ in range(len(self.possible_gaps[key].updates)): + update = self.possible_gaps[key].updates.pop(0) + + # If this fails to apply, it will get re-inserted at the end. + # All should fail, so the order will be preserved (it would've cycled once). + update = self.apply_pts_info(update, reset_deadlines=None) + if update: + result.append(update) + if __debug__: + self._trace('Resolved gap with %r: %s', PtsInfo.from_update(update), update) + + # Clear now-empty gaps. + self.possible_gaps = {entry: gap for entry, gap in self.possible_gaps.items() if gap.updates} + + real_result.extend(u for u in result if not u._self_outgoing) + + if result and not self.possible_gaps: + # > If the updates were applied, local *Updates* state must be updated + # > with `seq` (unless it's 0) and `date` from the constructor. + if __debug__: + self._trace('Updating seq as all updates were applied') + if date != epoch(): + self.date = date + if seq != NO_SEQ: + self.seq = seq + + return (users, chats) + + # Tries to apply the input update if its `PtsInfo` follows the correct order. + # + # If the update can be applied, it is returned; otherwise, the update is stored in a + # possible gap (unless it was already handled or would be handled through getting + # difference) and `None` is returned. + def apply_pts_info( + self, + update, + *, + reset_deadlines, + ): + # This update means we need to call getChannelDifference to get the updates from the channel + if isinstance(update, tl.UpdateChannelTooLong): + self.try_begin_get_diff(update.channel_id, 'received updateChannelTooLong') + return None + + pts = PtsInfo.from_update(update) + if not pts: + # No pts means that the update can be applied in any order. + if __debug__: + self._trace('No pts in update, so it can be applied in any order: %s', update) + return update + + # As soon as we receive an update of any form related to messages (has `PtsInfo`), + # the "no updates" period for that entry is reset. + # + # Build the `HashSet` to avoid calling `reset_deadline` more than once for the same entry. + # + # By the time this method returns, self.map will have an entry for which we can reset its deadline. + if reset_deadlines: + reset_deadlines.add(pts.entry) + + if pts.entry in self.getting_diff_for: + # Note: early returning here also prevents gap from being inserted (which they should + # not be while getting difference). + if __debug__: + self._trace('Skipping update with %r as its difference is being fetched', pts) + return None + + if pts.entry in self.map: + local_pts = self.map[pts.entry].pts + if local_pts + pts.pts_count > pts.pts: + # Ignore + if __debug__: + self._trace('Skipping update since local pts %r > %r: %s', local_pts, pts, update) + return None + elif local_pts + pts.pts_count < pts.pts: + # Possible gap + # TODO store chats too? + if __debug__: + self._trace('Possible gap since local pts %r < %r: %s', local_pts, pts, update) + if pts.entry not in self.possible_gaps: + self.possible_gaps[pts.entry] = PossibleGap( + deadline=get_running_loop().time() + POSSIBLE_GAP_TIMEOUT, + updates=[] + ) + + self.possible_gaps[pts.entry].updates.append(update) + return None + else: + # Apply + if __debug__: + self._trace('Applying update pts since local pts %r = %r: %s', local_pts, pts, update) + + # In a channel, we may immediately receive: + # * ReadChannelInbox (pts = X, pts_count = 0) + # * NewChannelMessage (pts = X, pts_count = 1) + # + # Notice how both `pts` are the same. If they were to be applied out of order, the first + # one however would've triggered a gap because `local_pts` + `pts_count` of 0 would be + # less than `remote_pts`. So there is no risk by setting the `local_pts` to match the + # `remote_pts` here of missing the new message. + # + # The message would however be lost if we initialized the pts with the first one, since + # the second one would appear "already handled". To prevent this we set the pts to be + # one less when the count is 0 (which might be wrong and trigger a gap later on, but is + # unlikely). This will prevent us from losing updates in the unlikely scenario where these + # two updates arrive in different packets (and therefore couldn't be sorted beforehand). + if pts.entry in self.map: + self.map[pts.entry].pts = pts.pts + else: + # When a chat is migrated to a megagroup, the first update can be a `ReadChannelInbox` + # with `pts = 1, pts_count = 0` followed by a `NewChannelMessage` with `pts = 2, pts_count=1`. + # Note how the `pts` for the message is 2 and not 1 unlike the case described before! + # This is likely because the `pts` cannot be 0 (or it would fail with PERSISTENT_TIMESTAMP_EMPTY), + # which forces the first update to be 1. But if we got difference with 1 and the second update + # also used 1, we would miss it, so Telegram probably uses 2 to work around that. + self.map[pts.entry] = State( + pts=(pts.pts - (0 if pts.pts_count else 1)) or 1, + deadline=next_updates_deadline() + ) + + return update + + # endregion "Normal" updates flow (processing and detection of gaps). + + # region Getting and applying account difference. + + # Return the request that needs to be made to get the difference, if any. + def get_difference(self): + for entry in (ENTRY_ACCOUNT, ENTRY_SECRET): + if entry in self.getting_diff_for: + if entry not in self.map: + raise RuntimeError('Should not try to get difference for an entry without known state') + + gd = fn.updates.GetDifferenceRequest( + pts=self.map[ENTRY_ACCOUNT].pts, + pts_total_limit=None, + date=self.date, + qts=self.map[ENTRY_SECRET].pts if ENTRY_SECRET in self.map else NO_SEQ, + ) + if __debug__: + self._trace('Requesting account difference %s', gd) + return gd + + return None + + # Similar to [`MessageBox::process_updates`], but using the result from getting difference. + def apply_difference( + self, + diff, + chat_hashes, + ): + if __debug__: + self._trace('Applying account difference %s', diff) + + finish = None + result = None + + if isinstance(diff, tl.updates.DifferenceEmpty): + finish = True + self.date = diff.date + self.seq = diff.seq + result = [], [], [] + elif isinstance(diff, tl.updates.Difference): + finish = True + chat_hashes.extend(diff.users, diff.chats) + result = self.apply_difference_type(diff, chat_hashes) + elif isinstance(diff, tl.updates.DifferenceSlice): + finish = False + chat_hashes.extend(diff.users, diff.chats) + result = self.apply_difference_type(diff, chat_hashes) + elif isinstance(diff, tl.updates.DifferenceTooLong): + finish = True + self.map[ENTRY_ACCOUNT].pts = diff.pts # the deadline will be reset once the diff ends + result = [], [], [] + + if finish: + account = ENTRY_ACCOUNT in self.getting_diff_for + secret = ENTRY_SECRET in self.getting_diff_for + + if not account and not secret: + raise RuntimeError('Should not be applying the difference when neither account or secret was diff was active') + + # Both may be active if both expired at the same time. + if account: + self.end_get_diff(ENTRY_ACCOUNT) + if secret: + self.end_get_diff(ENTRY_SECRET) + + return result + + def apply_difference_type( + self, + diff, + chat_hashes, + ): + state = getattr(diff, 'intermediate_state', None) or diff.state + self.set_state(state, reset=False) + + # diff.other_updates can contain things like UpdateChannelTooLong and UpdateNewChannelMessage. + # We need to process those as if they were socket updates to discard any we have already handled. + updates = [] + self.process_updates(tl.Updates( + updates=diff.other_updates, + users=diff.users, + chats=diff.chats, + date=epoch(), + seq=NO_SEQ, # this way date is not used + ), chat_hashes, updates) + + updates.extend(tl.UpdateNewMessage( + message=m, + pts=NO_SEQ, + pts_count=NO_SEQ, + ) for m in diff.new_messages) + updates.extend(tl.UpdateNewEncryptedMessage( + message=m, + qts=NO_SEQ, + ) for m in diff.new_encrypted_messages) + + return updates, diff.users, diff.chats + + def end_difference(self): + if __debug__: + self._trace('Ending account difference') + + account = ENTRY_ACCOUNT in self.getting_diff_for + secret = ENTRY_SECRET in self.getting_diff_for + + if not account and not secret: + raise RuntimeError('Should not be ending get difference when neither account or secret was diff was active') + + # Both may be active if both expired at the same time. + if account: + self.end_get_diff(ENTRY_ACCOUNT) + if secret: + self.end_get_diff(ENTRY_SECRET) + + # endregion Getting and applying account difference. + + # region Getting and applying channel difference. + + # Return the request that needs to be made to get a channel's difference, if any. + def get_channel_difference( + self, + chat_hashes, + ): + entry = next((id for id in self.getting_diff_for if isinstance(id, int)), None) + if not entry: + return None + + packed = chat_hashes.get(entry) + if not packed: + # Cannot get channel difference as we're missing its hash + # TODO we should probably log this + self.end_get_diff(entry) + # Remove the outdated `pts` entry from the map so that the next update can correct + # it. Otherwise, it will spam that the access hash is missing. + self.map.pop(entry, None) + return None + + state = self.map.get(entry) + if not state: + raise RuntimeError('Should not try to get difference for an entry without known state') + + gd = fn.updates.GetChannelDifferenceRequest( + force=False, + channel=tl.InputChannel(packed.id, packed.hash), + filter=tl.ChannelMessagesFilterEmpty(), + pts=state.pts, + limit=BOT_CHANNEL_DIFF_LIMIT if chat_hashes.self_bot else USER_CHANNEL_DIFF_LIMIT + ) + if __debug__: + self._trace('Requesting channel difference %s', gd) + return gd + + # Similar to [`MessageBox::process_updates`], but using the result from getting difference. + def apply_channel_difference( + self, + request, + diff, + chat_hashes, + ): + entry = request.channel.channel_id + if __debug__: + self._trace('Applying channel difference for %r: %s', entry, diff) + + self.possible_gaps.pop(entry, None) + + if isinstance(diff, tl.updates.ChannelDifferenceEmpty): + assert diff.final + self.end_get_diff(entry) + self.map[entry].pts = diff.pts + return [], [], [] + elif isinstance(diff, tl.updates.ChannelDifferenceTooLong): + assert diff.final + self.map[entry].pts = diff.dialog.pts + chat_hashes.extend(diff.users, diff.chats) + self.reset_channel_deadline(entry, diff.timeout) + # This `diff` has the "latest messages and corresponding chats", but it would + # be strange to give the user only partial changes of these when they would + # expect all updates to be fetched. Instead, nothing is returned. + return [], [], [] + elif isinstance(diff, tl.updates.ChannelDifference): + if diff.final: + self.end_get_diff(entry) + + self.map[entry].pts = diff.pts + chat_hashes.extend(diff.users, diff.chats) + + updates = [] + self.process_updates(tl.Updates( + updates=diff.other_updates, + users=diff.users, + chats=diff.chats, + date=epoch(), + seq=NO_SEQ, # this way date is not used + ), chat_hashes, updates) + + updates.extend(tl.UpdateNewChannelMessage( + message=m, + pts=NO_SEQ, + pts_count=NO_SEQ, + ) for m in diff.new_messages) + self.reset_channel_deadline(entry, None) + + return updates, diff.users, diff.chats + + def end_channel_difference(self, request, reason: PrematureEndReason, chat_hashes): + entry = request.channel.channel_id + if __debug__: + self._trace('Ending channel difference for %r because %s', entry, reason) + + if reason == PrematureEndReason.TEMPORARY_SERVER_ISSUES: + # Temporary issues. End getting difference without updating the pts so we can retry later. + self.possible_gaps.pop(entry, None) + self.end_get_diff(entry) + elif reason == PrematureEndReason.BANNED: + # Banned in the channel. Forget its state since we can no longer fetch updates from it. + self.possible_gaps.pop(entry, None) + self.end_get_diff(entry) + del self.map[entry] + else: + raise RuntimeError('Unknown reason to end channel difference') + + # endregion Getting and applying channel difference. diff --git a/module/scripts/lib/telethon/_updates/session.py b/module/scripts/lib/telethon/_updates/session.py new file mode 100644 index 0000000..165c170 --- /dev/null +++ b/module/scripts/lib/telethon/_updates/session.py @@ -0,0 +1,198 @@ +from typing import Optional, Tuple +from enum import IntEnum +from ..tl.types import InputPeerUser, InputPeerChat, InputPeerChannel +import struct + +class SessionState: + """ + Stores the information needed to fetch updates and about the current user. + + * user_id: 64-bit number representing the user identifier. + * dc_id: 32-bit number relating to the datacenter identifier where the user is. + * bot: is the logged-in user a bot? + * pts: 64-bit number holding the state needed to fetch updates. + * qts: alternative 64-bit number holding the state needed to fetch updates. + * date: 64-bit number holding the date needed to fetch updates. + * seq: 64-bit-number holding the sequence number needed to fetch updates. + * takeout_id: 64-bit-number holding the identifier of the current takeout session. + + Note that some of the numbers will only use 32 out of the 64 available bits. + However, for future-proofing reasons, we recommend you pretend they are 64-bit long. + """ + __slots__ = ('user_id', 'dc_id', 'bot', 'pts', 'qts', 'date', 'seq', 'takeout_id') + + def __init__( + self, + user_id: int, + dc_id: int, + bot: bool, + pts: int, + qts: int, + date: int, + seq: int, + takeout_id: Optional[int] + ): + self.user_id = user_id + self.dc_id = dc_id + self.bot = bot + self.pts = pts + self.qts = qts + self.date = date + self.seq = seq + self.takeout_id = takeout_id + + def __repr__(self): + return repr({k: getattr(self, k) for k in self.__slots__}) + + +class ChannelState: + """ + Stores the information needed to fetch updates from a channel. + + * channel_id: 64-bit number representing the channel identifier. + * pts: 64-bit number holding the state needed to fetch updates. + """ + __slots__ = ('channel_id', 'pts') + + def __init__( + self, + channel_id: int, + pts: int, + ): + self.channel_id = channel_id + self.pts = pts + + def __repr__(self): + return repr({k: getattr(self, k) for k in self.__slots__}) + + +class EntityType(IntEnum): + """ + You can rely on the type value to be equal to the ASCII character one of: + + * 'U' (85): this entity belongs to a :tl:`User` who is not a ``bot``. + * 'B' (66): this entity belongs to a :tl:`User` who is a ``bot``. + * 'G' (71): this entity belongs to a small group :tl:`Chat`. + * 'C' (67): this entity belongs to a standard broadcast :tl:`Channel`. + * 'M' (77): this entity belongs to a megagroup :tl:`Channel`. + * 'E' (69): this entity belongs to an "enormous" "gigagroup" :tl:`Channel`. + * 'N' (78): this entity belongs to a :tl:`Community`. + """ + USER = ord('U') + BOT = ord('B') + GROUP = ord('G') + CHANNEL = ord('C') + MEGAGROUP = ord('M') + GIGAGROUP = ord('E') + COMMUNITY = ord('N') + + def canonical(self): + """ + Return the canonical version of this type. + """ + return _canon_entity_types[self] + + +_canon_entity_types = { + EntityType.USER: EntityType.USER, + EntityType.BOT: EntityType.USER, + EntityType.GROUP: EntityType.GROUP, + EntityType.CHANNEL: EntityType.CHANNEL, + EntityType.MEGAGROUP: EntityType.CHANNEL, + EntityType.GIGAGROUP: EntityType.CHANNEL, + EntityType.COMMUNITY: EntityType.COMMUNITY, +} + + +class Entity: + """ + Stores the information needed to use a certain user, chat or channel with the API. + + * ty: 8-bit number indicating the type of the entity (of type `EntityType`). + * id: 64-bit number uniquely identifying the entity among those of the same type. + * hash: 64-bit signed number needed to use this entity with the API. + + The string representation of this class is considered to be stable, for as long as + Telegram doesn't need to add more fields to the entities. It can also be converted + to bytes with ``bytes(entity)``, for a more compact representation. + """ + __slots__ = ('ty', 'id', 'hash') + + def __init__( + self, + ty: EntityType, + id: int, + hash: int + ): + self.ty = ty + self.id = id + self.hash = hash + + @property + def is_user(self): + """ + ``True`` if the entity is either a user or a bot. + """ + return self.ty in (EntityType.USER, EntityType.BOT) + + @property + def is_group(self): + """ + ``True`` if the entity is a small group chat or `megagroup`_. + + .. _megagroup: https://telegram.org/blog/supergroups5k + """ + return self.ty in (EntityType.GROUP, EntityType.MEGAGROUP) + + @property + def is_broadcast(self): + """ + ``True`` if the entity is a broadcast channel or `broadcast group`_. + + .. _broadcast group: https://telegram.org/blog/autodelete-inv2#groups-with-unlimited-members + """ + return self.ty in (EntityType.CHANNEL, EntityType.GIGAGROUP) + + @classmethod + def from_str(cls, string: str): + """ + Convert the string into an `Entity`. + """ + try: + ty, id, hash = string.split('.') + ty, id, hash = ord(ty), int(id), int(hash) + except AttributeError: + raise TypeError(f'expected str, got {string!r}') from None + except (TypeError, ValueError): + raise ValueError(f'malformed entity str (must be T.id.hash), got {string!r}') from None + + return cls(EntityType(ty), id, hash) + + @classmethod + def from_bytes(cls, blob): + """ + Convert the bytes into an `Entity`. + """ + try: + ty, id, hash = struct.unpack(' 'TelegramClient': + """ + Returns a :ref:`telethon-client` which calls methods behind a takeout session. + + It does so by creating a proxy object over the current client through + which making requests will use :tl:`InvokeWithTakeoutRequest` to wrap + them. In other words, returns the current client modified so that + requests are done as a takeout: + + Some of the calls made through the takeout session will have lower + flood limits. This is useful if you want to export the data from + conversations or mass-download media, since the rate limits will + be lower. Only some requests will be affected, and you will need + to adjust the `wait_time` of methods like `client.iter_messages + `. + + By default, all parameters are `None`, and you need to enable those + you plan to use by setting them to either `True` or `False`. + + You should ``except errors.TakeoutInitDelayError as e``, since this + exception will raise depending on the condition of the session. You + can then access ``e.seconds`` to know how long you should wait for + before calling the method again. + + There's also a `success` property available in the takeout proxy + object, so from the `with` body you can set the boolean result that + will be sent back to Telegram. But if it's left `None` as by + default, then the action is based on the `finalize` parameter. If + it's `True` then the takeout will be finished, and if no exception + occurred during it, then `True` will be considered as a result. + Otherwise, the takeout will not be finished and its ID will be + preserved for future usage as `client.session.takeout_id + `. + + Arguments + finalize (`bool`): + Whether the takeout session should be finalized upon + exit or not. + + contacts (`bool`): + Set to `True` if you plan on downloading contacts. + + users (`bool`): + Set to `True` if you plan on downloading information + from users and their private conversations with you. + + chats (`bool`): + Set to `True` if you plan on downloading information + from small group chats, such as messages and media. + + megagroups (`bool`): + Set to `True` if you plan on downloading information + from megagroups (channels), such as messages and media. + + channels (`bool`): + Set to `True` if you plan on downloading information + from broadcast channels, such as messages and media. + + files (`bool`): + Set to `True` if you plan on downloading media and + you don't only wish to export messages. + + max_file_size (`int`): + The maximum file size, in bytes, that you plan + to download for each message with media. + + Example + .. code-block:: python + + from telethon import errors + + try: + async with client.takeout() as takeout: + await client.get_messages('me') # normal call + await takeout.get_messages('me') # wrapped through takeout (less limits) + + async for message in takeout.iter_messages(chat, wait_time=0): + ... # Do something with the message + + except errors.TakeoutInitDelayError as e: + print('Must wait', e.seconds, 'before takeout') + """ + request_kwargs = dict( + contacts=contacts, + message_users=users, + message_chats=chats, + message_megagroups=megagroups, + message_channels=channels, + files=files, + file_max_size=max_file_size + ) + arg_specified = (arg is not None for arg in request_kwargs.values()) + + if self.session.takeout_id is None or any(arg_specified): + request = functions.account.InitTakeoutSessionRequest( + **request_kwargs) + else: + request = None + + return _TakeoutClient(finalize, self, request) + + async def end_takeout(self: 'TelegramClient', success: bool) -> bool: + """ + Finishes the current takeout session. + + Arguments + success (`bool`): + Whether the takeout completed successfully or not. + + Returns + `True` if the operation was successful, `False` otherwise. + + Example + .. code-block:: python + + await client.end_takeout(success=False) + """ + try: + async with _TakeoutClient(True, self, None) as takeout: + takeout.success = success + except ValueError: + return False + return True diff --git a/module/scripts/lib/telethon/client/auth.py b/module/scripts/lib/telethon/client/auth.py new file mode 100644 index 0000000..0c9eddb --- /dev/null +++ b/module/scripts/lib/telethon/client/auth.py @@ -0,0 +1,677 @@ +import getpass +import inspect +import os +import sys +import typing +import warnings + +from .. import utils, helpers, errors, password as pwd_mod +from ..tl import types, functions, custom +from .._updates import SessionState + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +class AuthMethods: + + # region Public methods + + def start( + self: 'TelegramClient', + phone: typing.Union[typing.Callable[[], str], str] = lambda: input('Please enter your phone (or bot token): '), + password: typing.Union[typing.Callable[[], str], str] = lambda: getpass.getpass('Please enter your password: '), + *, + bot_token: str = None, + force_sms: bool = False, + code_callback: typing.Callable[[], typing.Union[str, int]] = None, + first_name: str = 'New User', + last_name: str = '', + max_attempts: int = 3) -> 'TelegramClient': + """ + Starts the client (connects and logs in if necessary). + + By default, this method will be interactive (asking for + user input if needed), and will handle 2FA if enabled too. + + If the event loop is already running, this method returns a + coroutine that you should await on your own code; otherwise + the loop is ran until said coroutine completes. + + Arguments + phone (`str` | `int` | `callable`): + The phone (or callable without arguments to get it) + to which the code will be sent. If a bot-token-like + string is given, it will be used as such instead. + The argument may be a coroutine. + + password (`str`, `callable`, optional): + The password for 2 Factor Authentication (2FA). + This is only required if it is enabled in your account. + The argument may be a coroutine. + + bot_token (`str`): + Bot Token obtained by `@BotFather `_ + to log in as a bot. Cannot be specified with ``phone`` (only + one of either allowed). + + force_sms (`bool`, optional): + Whether to force sending the code request as SMS. + This only makes sense when signing in with a `phone`. + + code_callback (`callable`, optional): + A callable that will be used to retrieve the Telegram + login code. Defaults to `input()`. + The argument may be a coroutine. + + first_name (`str`, optional): + The first name to be used if signing up. This has no + effect if the account already exists and you sign in. + + last_name (`str`, optional): + Similar to the first name, but for the last. Optional. + + max_attempts (`int`, optional): + How many times the code/password callback should be + retried or switching between signing in and signing up. + + Returns + This `TelegramClient`, so initialization + can be chained with ``.start()``. + + Example + .. code-block:: python + + client = TelegramClient('anon', api_id, api_hash) + + # Starting as a bot account + await client.start(bot_token=bot_token) + + # Starting as a user account + await client.start(phone) + # Please enter the code you received: 12345 + # Please enter your password: ******* + # (You are now logged in) + + # Starting using a context manager (this calls start()): + with client: + pass + """ + if code_callback is None: + def code_callback(): + return input('Please enter the code you received: ') + elif not callable(code_callback): + raise ValueError( + 'The code_callback parameter needs to be a callable ' + 'function that returns the code you received by Telegram.' + ) + + if not phone and not bot_token: + raise ValueError('No phone number or bot token provided.') + + if phone and bot_token and not callable(phone): + raise ValueError('Both a phone and a bot token provided, ' + 'must only provide one of either') + + coro = self._start( + phone=phone, + password=password, + bot_token=bot_token, + force_sms=force_sms, + code_callback=code_callback, + first_name=first_name, + last_name=last_name, + max_attempts=max_attempts + ) + return ( + coro if self.loop.is_running() + else self.loop.run_until_complete(coro) + ) + + async def _start( + self: 'TelegramClient', phone, password, bot_token, force_sms, + code_callback, first_name, last_name, max_attempts): + if not self.is_connected(): + await self.connect() + + # Rather than using `is_user_authorized`, use `get_me`. While this is + # more expensive and needs to retrieve more data from the server, it + # enables the library to warn users trying to login to a different + # account. See #1172. + me = await self.get_me() + if me is not None: + # The warnings here are on a best-effort and may fail. + if bot_token: + # bot_token's first part has the bot ID, but it may be invalid + # so don't try to parse as int (instead cast our ID to string). + if bot_token[:bot_token.find(':')] != str(me.id): + warnings.warn( + 'the session already had an authorized user so it did ' + 'not login to the bot account using the provided bot_token; ' + 'if you were expecting a different user, check whether ' + 'you are accidentally reusing an existing session' + ) + elif phone and not callable(phone) and utils.parse_phone(phone) != me.phone: + warnings.warn( + 'the session already had an authorized user so it did ' + 'not login to the user account using the provided phone; ' + 'if you were expecting a different user, check whether ' + 'you are accidentally reusing an existing session' + ) + + return self + + if not bot_token: + # Turn the callable into a valid phone number (or bot token) + while callable(phone): + value = phone() + if inspect.isawaitable(value): + value = await value + + if ':' in value: + # Bot tokens have 'user_id:access_hash' format + bot_token = value + break + + phone = utils.parse_phone(value) or phone + + if bot_token: + await self.sign_in(bot_token=bot_token) + return self + + me = None + attempts = 0 + two_step_detected = False + + await self.send_code_request(phone, force_sms=force_sms) + while attempts < max_attempts: + try: + value = code_callback() + if inspect.isawaitable(value): + value = await value + + # Since sign-in with no code works (it sends the code) + # we must double-check that here. Else we'll assume we + # logged in, and it will return None as the User. + if not value: + raise errors.PhoneCodeEmptyError(request=None) + + # Raises SessionPasswordNeededError if 2FA enabled + me = await self.sign_in(phone, code=value) + break + except errors.SessionPasswordNeededError: + two_step_detected = True + break + except (errors.PhoneCodeEmptyError, + errors.PhoneCodeExpiredError, + errors.PhoneCodeHashEmptyError, + errors.PhoneCodeInvalidError): + print('Invalid code. Please try again.', file=sys.stderr) + + attempts += 1 + else: + raise RuntimeError( + '{} consecutive sign-in attempts failed. Aborting' + .format(max_attempts) + ) + + if two_step_detected: + if not password: + raise ValueError( + "Two-step verification is enabled for this account. " + "Please provide the 'password' argument to 'start()'." + ) + + if callable(password): + for _ in range(max_attempts): + try: + value = password() + if inspect.isawaitable(value): + value = await value + + me = await self.sign_in(phone=phone, password=value) + break + except errors.PasswordHashInvalidError: + print('Invalid password. Please try again', + file=sys.stderr) + else: + raise errors.PasswordHashInvalidError(request=None) + else: + me = await self.sign_in(phone=phone, password=password) + + # We won't reach here if any step failed (exit by exception) + signed, name = 'Signed in successfully as ', utils.get_display_name(me) + tos = '; remember to not break the ToS or you will risk an account ban!' + try: + print(signed, name, tos, sep='') + except UnicodeEncodeError: + # Some terminals don't support certain characters + print(signed, name.encode('utf-8', errors='ignore') + .decode('ascii', errors='ignore'), tos, sep='') + + return self + + def _parse_phone_and_hash(self, phone, phone_hash): + """ + Helper method to both parse and validate phone and its hash. + """ + phone = utils.parse_phone(phone) or self._phone + if not phone: + raise ValueError( + 'Please make sure to call send_code_request first.' + ) + + phone_hash = phone_hash or self._phone_code_hash.get(phone, None) + if not phone_hash: + raise ValueError('You also need to provide a phone_code_hash.') + + return phone, phone_hash + + async def sign_in( + self: 'TelegramClient', + phone: str = None, + code: typing.Union[str, int] = None, + *, + password: str = None, + bot_token: str = None, + phone_code_hash: str = None) -> 'typing.Union[types.User, types.auth.SentCode]': + """ + Logs in to Telegram to an existing user or bot account. + + You should only use this if you are not authorized yet. + + This method will send the code if it's not provided. + + .. note:: + + In most cases, you should simply use `start()` and not this method. + + Arguments + phone (`str` | `int`): + The phone to send the code to if no code was provided, + or to override the phone that was previously used with + these requests. + + code (`str` | `int`): + The code that Telegram sent. Note that if you have sent this + code through the application itself it will immediately + expire. If you want to send the code, obfuscate it somehow. + If you're not doing any of this you can ignore this note. + + password (`str`): + 2FA password, should be used if a previous call raised + ``SessionPasswordNeededError``. + + bot_token (`str`): + Used to sign in as a bot. Not all requests will be available. + This should be the hash the `@BotFather `_ + gave you. + + phone_code_hash (`str`, optional): + The hash returned by `send_code_request`. This can be left as + `None` to use the last hash known for the phone to be used. + + Returns + The signed in user, or the information about + :meth:`send_code_request`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + await client.sign_in(phone) # send code + + code = input('enter code: ') + await client.sign_in(phone, code) + """ + me = await self.get_me() + if me: + return me + + if phone and not code and not password: + return await self.send_code_request(phone) + elif code: + phone, phone_code_hash = \ + self._parse_phone_and_hash(phone, phone_code_hash) + + # May raise PhoneCodeEmptyError, PhoneCodeExpiredError, + # PhoneCodeHashEmptyError or PhoneCodeInvalidError. + request = functions.auth.SignInRequest( + phone, phone_code_hash, str(code) + ) + elif password: + pwd = await self(functions.account.GetPasswordRequest()) + request = functions.auth.CheckPasswordRequest( + pwd_mod.compute_check(pwd, password) + ) + elif bot_token: + request = functions.auth.ImportBotAuthorizationRequest( + flags=0, bot_auth_token=bot_token, + api_id=self.api_id, api_hash=self.api_hash + ) + else: + raise ValueError( + 'You must provide a phone and a code the first time, ' + 'and a password only if an RPCError was raised before.' + ) + + try: + result = await self(request) + except errors.PhoneCodeExpiredError: + self._phone_code_hash.pop(phone, None) + raise + + if isinstance(result, types.auth.AuthorizationSignUpRequired): + # Emulate pre-layer 104 behaviour + self._tos = result.terms_of_service + raise errors.PhoneNumberUnoccupiedError(request=request) + + return await self._on_login(result.user) + + async def sign_up( + self: 'TelegramClient', + code: typing.Union[str, int], + first_name: str, + last_name: str = '', + *, + phone: str = None, + phone_code_hash: str = None) -> 'types.User': + """ + This method can no longer be used, and will immediately raise a ``ValueError``. + See `issue #4050 `_ for context. + """ + raise ValueError('Third-party applications cannot sign up for Telegram. See https://github.com/LonamiWebs/Telethon/issues/4050 for details') + + async def _on_login(self, user): + """ + Callback called whenever the login or sign up process completes. + + Returns the input user parameter. + """ + self._mb_entity_cache.set_self_user(user.id, user.bot, user.access_hash) + self._authorized = True + + state = await self(functions.updates.GetStateRequest()) + # the server may send an old qts in getState + difference = await self(functions.updates.GetDifferenceRequest(pts=state.pts, date=state.date, qts=state.qts)) + + if isinstance(difference, types.updates.Difference): + state = difference.state + elif isinstance(difference, types.updates.DifferenceSlice): + state = difference.intermediate_state + elif isinstance(difference, types.updates.DifferenceTooLong): + state.pts = difference.pts + + self._message_box.load(SessionState(0, 0, 0, state.pts, state.qts, int(state.date.timestamp()), state.seq, 0), []) + + return user + + async def send_code_request( + self: 'TelegramClient', + phone: str, + *, + force_sms: bool = False, + _retry_count: int = 0) -> 'types.auth.SentCode': + """ + Sends the Telegram code needed to login to the given phone number. + + Arguments + phone (`str` | `int`): + The phone to which the code will be sent. + + force_sms (`bool`, optional): + Whether to force sending as SMS. This has been deprecated. + See `issue #4050 `_ for context. + + Returns + An instance of :tl:`SentCode`. + + Example + .. code-block:: python + + phone = '+34 123 123 123' + sent = await client.send_code_request(phone) + print(sent) + """ + if force_sms: + warnings.warn('force_sms has been deprecated and no longer works') + force_sms = False + + result = None + phone = utils.parse_phone(phone) or self._phone + phone_hash = self._phone_code_hash.get(phone) + + if not phone_hash: + try: + result = await self(functions.auth.SendCodeRequest( + phone, self.api_id, self.api_hash, types.CodeSettings())) + except errors.AuthRestartError: + if _retry_count > 2: + raise + return await self.send_code_request( + phone, force_sms=force_sms, _retry_count=_retry_count+1) + + # TODO figure out when/if/how this can happen + if isinstance(result, types.auth.SentCodeSuccess): + raise RuntimeError('logged in right after sending the code') + + # If we already sent a SMS, do not resend the code (hash may be empty) + if isinstance(result.type, types.auth.SentCodeTypeSms): + force_sms = False + + # phone_code_hash may be empty, if it is, do not save it (#1283) + if result.phone_code_hash: + self._phone_code_hash[phone] = phone_hash = result.phone_code_hash + else: + force_sms = True + + self._phone = phone + + if force_sms: + try: + result = await self( + functions.auth.ResendCodeRequest(phone, phone_hash)) + except errors.PhoneCodeExpiredError: + if _retry_count > 2: + raise + self._phone_code_hash.pop(phone, None) + self._log[__name__].info( + "Phone code expired in ResendCodeRequest, requesting a new code" + ) + return await self.send_code_request( + phone, force_sms=False, _retry_count=_retry_count+1) + + if isinstance(result, types.auth.SentCodeSuccess): + raise RuntimeError('logged in right after resending the code') + + self._phone_code_hash[phone] = result.phone_code_hash + + return result + + async def qr_login(self: 'TelegramClient', ignored_ids: typing.List[int] = None) -> custom.QRLogin: + """ + Initiates the QR login procedure. + + Note that you must be connected before invoking this, as with any + other request. + + It is up to the caller to decide how to present the code to the user, + whether it's the URL, using the token bytes directly, or generating + a QR code and displaying it by other means. + + See the documentation for `QRLogin` to see how to proceed after this. + + Arguments + ignored_ids (List[`int`]): + List of already logged-in user IDs, to prevent logging in + twice with the same user. + + Returns + An instance of `QRLogin`. + + Example + .. code-block:: python + + def display_url_as_qr(url): + pass # do whatever to show url as a qr to the user + + qr_login = await client.qr_login() + display_url_as_qr(qr_login.url) + + # Important! You need to wait for the login to complete! + await qr_login.wait() + + # If you have 2FA enabled, `wait` will raise `telethon.errors.SessionPasswordNeededError`. + # You should except that error and call `sign_in` with the password if this happens. + """ + qr_login = custom.QRLogin(self, ignored_ids or []) + await qr_login.recreate() + return qr_login + + async def log_out(self: 'TelegramClient') -> bool: + """ + Logs out Telegram and deletes the current ``*.session`` file. + + The client is unusable after logging out and a new instance should be created. + + Returns + `True` if the operation was successful. + + Example + .. code-block:: python + + # Note: you will need to login again! + await client.log_out() + """ + try: + await self(functions.auth.LogOutRequest()) + except errors.RPCError: + return False + + self._mb_entity_cache.set_self_user(None, None, None) + self._authorized = False + + await self.disconnect() + await utils.maybe_async(self.session.delete()) + self.session = None + return True + + async def edit_2fa( + self: 'TelegramClient', + current_password: str = None, + new_password: str = None, + *, + hint: str = '', + email: str = None, + email_code_callback: typing.Callable[[int], str] = None) -> bool: + """ + Changes the 2FA settings of the logged in user. + + Review carefully the parameter explanations before using this method. + + Note that this method may be *incredibly* slow depending on the + prime numbers that must be used during the process to make sure + that everything is safe. + + Has no effect if both current and new password are omitted. + + Arguments + current_password (`str`, optional): + The current password, to authorize changing to ``new_password``. + Must be set if changing existing 2FA settings. + Must **not** be set if 2FA is currently disabled. + Passing this by itself will remove 2FA (if correct). + + new_password (`str`, optional): + The password to set as 2FA. + If 2FA was already enabled, ``current_password`` **must** be set. + Leaving this blank or `None` will remove the password. + + hint (`str`, optional): + Hint to be displayed by Telegram when it asks for 2FA. + Leaving unspecified is highly discouraged. + Has no effect if ``new_password`` is not set. + + email (`str`, optional): + Recovery and verification email. If present, you must also + set `email_code_callback`, else it raises ``ValueError``. + + email_code_callback (`callable`, optional): + If an email is provided, a callback that returns the code sent + to it must also be set. This callback may be asynchronous. + It should return a string with the code. The length of the + code will be passed to the callback as an input parameter. + + If the callback returns an invalid code, it will raise + ``CodeInvalidError``. + + Returns + `True` if successful, `False` otherwise. + + Example + .. code-block:: python + + # Setting a password for your account which didn't have + await client.edit_2fa(new_password='I_<3_Telethon') + + # Removing the password + await client.edit_2fa(current_password='I_<3_Telethon') + """ + if new_password is None and current_password is None: + return False + + if email and not callable(email_code_callback): + raise ValueError('email present without email_code_callback') + + pwd = await self(functions.account.GetPasswordRequest()) + pwd.new_algo.salt1 += os.urandom(32) + assert isinstance(pwd, types.account.Password) + if not pwd.has_password and current_password: + current_password = None + + if current_password: + password = pwd_mod.compute_check(pwd, current_password) + else: + password = types.InputCheckPasswordEmpty() + + if new_password: + new_password_hash = pwd_mod.compute_digest( + pwd.new_algo, new_password) + else: + new_password_hash = b'' + + try: + await self(functions.account.UpdatePasswordSettingsRequest( + password=password, + new_settings=types.account.PasswordInputSettings( + new_algo=pwd.new_algo, + new_password_hash=new_password_hash, + hint=hint, + email=email, + new_secure_settings=None + ) + )) + except errors.EmailUnconfirmedError as e: + code = email_code_callback(e.code_length) + if inspect.isawaitable(code): + code = await code + + code = str(code) + await self(functions.account.ConfirmPasswordEmailRequest(code)) + + return True + + # endregion + + # region with blocks + + async def __aenter__(self): + return await self.start() + + async def __aexit__(self, *args): + await self.disconnect() + + __enter__ = helpers._sync_enter + __exit__ = helpers._sync_exit + + # endregion diff --git a/module/scripts/lib/telethon/client/bots.py b/module/scripts/lib/telethon/client/bots.py new file mode 100644 index 0000000..044d851 --- /dev/null +++ b/module/scripts/lib/telethon/client/bots.py @@ -0,0 +1,72 @@ +import typing + +from .. import hints +from ..tl import types, functions, custom + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +class BotMethods: + async def inline_query( + self: 'TelegramClient', + bot: 'hints.EntityLike', + query: str, + *, + entity: 'hints.EntityLike' = None, + offset: str = None, + geo_point: 'types.GeoPoint' = None) -> custom.InlineResults: + """ + Makes an inline query to the specified bot (``@vote New Poll``). + + Arguments + bot (`entity`): + The bot entity to which the inline query should be made. + + query (`str`): + The query that should be made to the bot. + + entity (`entity`, optional): + The entity where the inline query is being made from. Certain + bots use this to display different results depending on where + it's used, such as private chats, groups or channels. + + If specified, it will also be the default entity where the + message will be sent after clicked. Otherwise, the "empty + peer" will be used, which some bots may not handle correctly. + + offset (`str`, optional): + The string offset to use for the bot. + + geo_point (:tl:`GeoPoint`, optional) + The geo point location information to send to the bot + for localised results. Available under some bots. + + Returns + A list of `custom.InlineResult + `. + + Example + .. code-block:: python + + # Make an inline query to @like + results = await client.inline_query('like', 'Do you like Telethon?') + + # Send the first result to some chat + message = await results[0].click('TelethonOffTopic') + """ + bot = await self.get_input_entity(bot) + if entity: + peer = await self.get_input_entity(entity) + else: + peer = types.InputPeerEmpty() + + result = await self(functions.messages.GetInlineBotResultsRequest( + bot=bot, + peer=peer, + query=query, + offset=offset or '', + geo_point=geo_point + )) + + return custom.InlineResults(self, result, entity=peer if entity else None) diff --git a/module/scripts/lib/telethon/client/buttons.py b/module/scripts/lib/telethon/client/buttons.py new file mode 100644 index 0000000..7590aed --- /dev/null +++ b/module/scripts/lib/telethon/client/buttons.py @@ -0,0 +1,104 @@ +import typing + +from .. import utils, hints +from ..tl import types, custom + + +class ButtonMethods: + @staticmethod + def build_reply_markup( + buttons: 'typing.Optional[hints.MarkupLike]' + ) -> 'typing.Optional[types.TypeReplyMarkup]': + """ + Builds a :tl:`ReplyInlineMarkup` or :tl:`ReplyKeyboardMarkup` for + the given buttons. + + Does nothing if either no buttons are provided or the provided + argument is already a reply markup. + + You should consider using this method if you are going to reuse + the markup very often. Otherwise, it is not necessary. + + This method is **not** asynchronous (don't use ``await`` on it). + + Arguments + buttons (`hints.MarkupLike`): + The button, list of buttons, array of buttons or markup + to convert into a markup. + + Example + .. code-block:: python + + from telethon import Button + + markup = client.build_reply_markup(Button.inline('hi')) + # later + await client.send_message(chat, 'click me', buttons=markup) + """ + if buttons is None: + return None + + try: + if buttons.SUBCLASS_OF_ID == 0xe2e10ef2: # crc32(b'ReplyMarkup'): + return buttons + except AttributeError: + pass + + if not utils.is_list_like(buttons): + buttons = [[buttons]] + elif not buttons or not utils.is_list_like(buttons[0]): + buttons = [buttons] + + is_inline = False + is_normal = False + resize = None + single_use = None + selective = None + persistent = None + placeholder = None + + rows = [] + for row in buttons: + current = [] + for button in row: + if isinstance(button, custom.Button): + if button.resize is not None: + resize = button.resize + if button.single_use is not None: + single_use = button.single_use + if button.selective is not None: + selective = button.selective + if button.persistent is not None: + persistent = button.persistent + if button.placeholder is not None: + placeholder = button.placeholder + + button = button.button + elif isinstance(button, custom.MessageButton): + button = button.button + + inline = custom.Button._is_inline(button) + is_inline |= inline + is_normal |= not inline + + if isinstance(button, (types.KeyboardButton, types.KeyboardInlineButton)): + current.append(button) + + if current: + if is_inline: + rows.append(types.KeyboardInlineButtonRow(current)) + else: + rows.append(types.KeyboardButtonRow(current)) + + if is_inline and is_normal: + raise ValueError('You cannot mix inline with normal buttons') + elif is_inline: + return types.ReplyInlineMarkup(rows) + return types.ReplyKeyboardMarkup( + rows=rows, + resize=resize, + single_use=single_use, + selective=selective, + persistent=persistent, + placeholder=placeholder + ) diff --git a/module/scripts/lib/telethon/client/chats.py b/module/scripts/lib/telethon/client/chats.py new file mode 100644 index 0000000..28faae8 --- /dev/null +++ b/module/scripts/lib/telethon/client/chats.py @@ -0,0 +1,1336 @@ +import asyncio +import inspect +import itertools +import string +import typing + +from .. import helpers, utils, hints, errors +from ..requestiter import RequestIter +from ..tl import types, functions, custom + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + +_MAX_PARTICIPANTS_CHUNK_SIZE = 200 +_MAX_ADMIN_LOG_CHUNK_SIZE = 100 +_MAX_PROFILE_PHOTO_CHUNK_SIZE = 100 + + +class _ChatAction: + _str_mapping = { + 'typing': types.SendMessageTypingAction(), + 'contact': types.SendMessageChooseContactAction(), + 'game': types.SendMessageGamePlayAction(), + 'location': types.SendMessageGeoLocationAction(), + 'sticker': types.SendMessageChooseStickerAction(), + + 'record-audio': types.SendMessageRecordAudioAction(), + 'record-voice': types.SendMessageRecordAudioAction(), # alias + 'record-round': types.SendMessageRecordRoundAction(), + 'record-video': types.SendMessageRecordVideoAction(), + + 'audio': types.SendMessageUploadAudioAction(1), + 'voice': types.SendMessageUploadAudioAction(1), # alias + 'song': types.SendMessageUploadAudioAction(1), # alias + 'round': types.SendMessageUploadRoundAction(1), + 'video': types.SendMessageUploadVideoAction(1), + + 'photo': types.SendMessageUploadPhotoAction(1), + 'document': types.SendMessageUploadDocumentAction(1), + 'file': types.SendMessageUploadDocumentAction(1), # alias + + 'cancel': types.SendMessageCancelAction() + } + + def __init__(self, client, chat, action, *, delay, auto_cancel): + self._client = client + self._chat = chat + self._action = action + self._delay = delay + self._auto_cancel = auto_cancel + self._request = None + self._task = None + self._running = False + + async def __aenter__(self): + self._chat = await self._client.get_input_entity(self._chat) + + # Since `self._action` is passed by reference we can avoid + # recreating the request all the time and still modify + # `self._action.progress` directly in `progress`. + self._request = functions.messages.SetTypingRequest( + self._chat, self._action) + + self._running = True + self._task = self._client.loop.create_task(self._update()) + return self + + async def __aexit__(self, *args): + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + + self._task = None + + __enter__ = helpers._sync_enter + __exit__ = helpers._sync_exit + + async def _update(self): + try: + while self._running: + await self._client(self._request) + await asyncio.sleep(self._delay) + except ConnectionError: + pass + except asyncio.CancelledError: + if self._auto_cancel: + await self._client(functions.messages.SetTypingRequest( + self._chat, types.SendMessageCancelAction())) + + def progress(self, current, total): + if hasattr(self._action, 'progress'): + self._action.progress = 100 * round(current / total) + + +class _ParticipantsIter(RequestIter): + async def _init(self, entity, filter, search): + if isinstance(filter, type): + if filter in (types.ChannelParticipantsBanned, + types.ChannelParticipantsKicked, + types.ChannelParticipantsSearch, + types.ChannelParticipantsContacts): + # These require a `q` parameter (support types for convenience) + filter = filter('') + else: + filter = filter() + + entity = await self.client.get_input_entity(entity) + ty = helpers._entity_type(entity) + if search and (filter or ty != helpers._EntityType.CHANNEL): + # We need to 'search' ourselves unless we have a PeerChannel + search = search.casefold() + + self.filter_entity = lambda ent: ( + search in utils.get_display_name(ent).casefold() or + search in (getattr(ent, 'username', None) or '').casefold() + ) + else: + self.filter_entity = lambda ent: True + + # Only used for channels, but we should always set the attribute + # Called `requests` even though it's just one for legacy purposes. + self.requests = None + + if ty == helpers._EntityType.CHANNEL: + if self.limit <= 0: + # May not have access to the channel, but getFull can get the .total. + self.total = (await self.client( + functions.channels.GetFullChannelRequest(entity) + )).full_chat.participants_count + raise StopAsyncIteration + + self.seen = set() + self.requests = functions.channels.GetParticipantsRequest( + channel=entity, + filter=filter or types.ChannelParticipantsSearch(search), + offset=0, + limit=_MAX_PARTICIPANTS_CHUNK_SIZE, + hash=0 + ) + + elif ty == helpers._EntityType.CHAT: + full = await self.client( + functions.messages.GetFullChatRequest(entity.chat_id)) + if not isinstance( + full.full_chat.participants, types.ChatParticipants): + # ChatParticipantsForbidden won't have ``.participants`` + self.total = 0 + raise StopAsyncIteration + + self.total = len(full.full_chat.participants.participants) + + users = {user.id: user for user in full.users} + for participant in full.full_chat.participants.participants: + if isinstance(participant, types.ChannelParticipantLeft): + # See issue #3231 to learn why this is ignored. + continue + elif isinstance(participant, types.ChannelParticipantBanned): + user_id = participant.peer.user_id + else: + user_id = participant.user_id + user = users[user_id] + if not self.filter_entity(user): + continue + + user = users[user_id] + user.participant = participant + self.buffer.append(user) + + return True + else: + self.total = 1 + if self.limit != 0: + user = await self.client.get_entity(entity) + if self.filter_entity(user): + user.participant = None + self.buffer.append(user) + + return True + + async def _load_next_chunk(self): + if not self.requests: + return True + + self.requests.limit = min(self.limit - self.requests.offset, _MAX_PARTICIPANTS_CHUNK_SIZE) + + if self.requests.offset > self.limit: + return True + + if self.total is None: + f = self.requests.filter + if ( + not isinstance(f, types.ChannelParticipantsRecent) + and (not isinstance(f, types.ChannelParticipantsSearch) or f.q) + ): + # Only do an additional getParticipants here to get the total + # if there's a filter which would reduce the real total number. + # getParticipants is cheaper than getFull. + self.total = (await self.client(functions.channels.GetParticipantsRequest( + channel=self.requests.channel, + filter=types.ChannelParticipantsRecent(), + offset=0, + limit=1, + hash=0 + ))).count + + participants = await self.client(self.requests) + if self.total is None: + # Will only get here if there was one request with a filter that matched all users. + self.total = participants.count + if not participants.users: + self.requests = None + return + + self.requests.offset += len(participants.participants) + users = {user.id: user for user in participants.users} + for participant in participants.participants: + if isinstance(participant, types.ChannelParticipantLeft): + # See issue #3231 to learn why this is ignored. + continue + elif isinstance(participant, types.ChannelParticipantBanned): + if not isinstance(participant.peer, types.PeerUser): + # May have the entire channel banned. See #3105. + continue + user_id = participant.peer.user_id + else: + user_id = participant.user_id + + user = users[user_id] + if not self.filter_entity(user) or user.id in self.seen: + continue + self.seen.add(user_id) + user = users[user_id] + user.participant = participant + self.buffer.append(user) + + +class _AdminLogIter(RequestIter): + async def _init( + self, entity, admins, search, min_id, max_id, + join, leave, invite, restrict, unrestrict, ban, unban, + promote, demote, info, settings, pinned, edit, delete, + group_call + ): + if any((join, leave, invite, restrict, unrestrict, ban, unban, + promote, demote, info, settings, pinned, edit, delete, + group_call)): + events_filter = types.ChannelAdminLogEventsFilter( + join=join, leave=leave, invite=invite, ban=restrict, + unban=unrestrict, kick=ban, unkick=unban, promote=promote, + demote=demote, info=info, settings=settings, pinned=pinned, + edit=edit, delete=delete, group_call=group_call + ) + else: + events_filter = None + + self.entity = await self.client.get_input_entity(entity) + + admin_list = [] + if admins: + if not utils.is_list_like(admins): + admins = (admins,) + + for admin in admins: + admin_list.append(await self.client.get_input_entity(admin)) + + self.request = functions.channels.GetAdminLogRequest( + self.entity, q=search or '', min_id=min_id, max_id=max_id, + limit=0, events_filter=events_filter, admins=admin_list or None + ) + + async def _load_next_chunk(self): + self.request.limit = min(self.left, _MAX_ADMIN_LOG_CHUNK_SIZE) + r = await self.client(self.request) + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + self.request.max_id = min((e.id for e in r.events), default=0) + for ev in r.events: + if isinstance(ev.action, + types.ChannelAdminLogEventActionEditMessage): + ev.action.prev_message._finish_init( + self.client, entities, self.entity) + + ev.action.new_message._finish_init( + self.client, entities, self.entity) + + elif isinstance(ev.action, + types.ChannelAdminLogEventActionDeleteMessage): + ev.action.message._finish_init( + self.client, entities, self.entity) + + self.buffer.append(custom.AdminLogEvent(ev, entities)) + + if len(r.events) < self.request.limit: + return True + + +class _ProfilePhotoIter(RequestIter): + async def _init( + self, entity, offset, max_id + ): + entity = await self.client.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.USER: + self.request = functions.photos.GetUserPhotosRequest( + entity, + offset=offset, + max_id=max_id, + limit=1 + ) + else: + self.request = functions.messages.SearchRequest( + peer=entity, + q='', + filter=types.InputMessagesFilterChatPhotos(), + min_date=None, + max_date=None, + offset_id=0, + add_offset=offset, + limit=1, + max_id=max_id, + min_id=0, + hash=0 + ) + + if self.limit == 0: + self.request.limit = 1 + result = await self.client(self.request) + if isinstance(result, types.photos.Photos): + self.total = len(result.photos) + elif isinstance(result, types.messages.Messages): + self.total = len(result.messages) + else: + # Luckily both photosSlice and messages have a count for total + self.total = getattr(result, 'count', None) + + async def _load_next_chunk(self): + self.request.limit = min(self.left, _MAX_PROFILE_PHOTO_CHUNK_SIZE) + result = await self.client(self.request) + + if isinstance(result, types.photos.Photos): + self.buffer = result.photos + self.left = len(self.buffer) + self.total = len(self.buffer) + elif isinstance(result, types.messages.Messages): + self.buffer = [x.action.photo for x in result.messages + if isinstance(x.action, types.MessageActionChatEditPhoto)] + + self.left = len(self.buffer) + self.total = len(self.buffer) + elif isinstance(result, types.photos.PhotosSlice): + self.buffer = result.photos + self.total = result.count + if len(self.buffer) < self.request.limit: + self.left = len(self.buffer) + else: + self.request.offset += len(result.photos) + else: + # Some broadcast channels have a photo that this request doesn't + # retrieve for whatever random reason the Telegram server feels. + # + # This means the `total` count may be wrong but there's not much + # that can be done around it (perhaps there are too many photos + # and this is only a partial result so it's not possible to just + # use the len of the result). + self.total = getattr(result, 'count', None) + + # Unconditionally fetch the full channel to obtain this photo and + # yield it with the rest (unless it's a duplicate). + seen_id = None + if isinstance(result, types.messages.ChannelMessages): + channel = await self.client(functions.channels.GetFullChannelRequest(self.request.peer)) + photo = channel.full_chat.chat_photo + if isinstance(photo, types.Photo): + self.buffer.append(photo) + seen_id = photo.id + + self.buffer.extend( + x.action.photo for x in result.messages + if isinstance(x.action, types.MessageActionChatEditPhoto) + and x.action.photo.id != seen_id + ) + + if len(result.messages) < self.request.limit: + self.left = len(self.buffer) + elif result.messages: + self.request.add_offset = 0 + self.request.offset_id = result.messages[-1].id + + +class ChatMethods: + + # region Public methods + + def iter_participants( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + search: str = '', + filter: 'types.TypeChannelParticipantsFilter' = None, + aggressive: bool = False) -> _ParticipantsIter: + """ + Iterator over the participants belonging to the specified chat. + + The order is unspecified. + + Arguments + entity (`entity`): + The entity from which to retrieve the participants list. + + limit (`int`): + Limits amount of participants fetched. + + search (`str`, optional): + Look for participants with this string in name/username. + + filter (:tl:`ChannelParticipantsFilter`, optional): + The filter to be used, if you want e.g. only admins + Note that you might not have permissions for some filter. + This has no effect for normal chats or users. + + .. note:: + + The filter :tl:`ChannelParticipantsBanned` will return + *restricted* users. If you want *banned* users you should + use :tl:`ChannelParticipantsKicked` instead. + + aggressive (`bool`, optional): + Does nothing. This is kept for backwards-compatibility. + + There have been several changes to Telegram's API that limits + the amount of members that can be retrieved, and this was a + hack that no longer works. + + Yields + The :tl:`User` objects returned by :tl:`GetParticipantsRequest` + with an additional ``.participant`` attribute which is the + matched :tl:`ChannelParticipant` type for channels/megagroups + or :tl:`ChatParticipants` for normal chats. + + Example + .. code-block:: python + + # Show all user IDs in a chat + async for user in client.iter_participants(chat): + print(user.id) + + # Search by name + async for user in client.iter_participants(chat, search='name'): + print(user.username) + + # Filter by admins + from telethon.tl.types import ChannelParticipantsAdmins + async for user in client.iter_participants(chat, filter=ChannelParticipantsAdmins): + print(user.first_name) + """ + return _ParticipantsIter( + self, + limit, + entity=entity, + filter=filter, + search=search + ) + + async def get_participants( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + """ + Same as `iter_participants()`, but returns a + `TotalList ` instead. + + Example + .. code-block:: python + + users = await client.get_participants(chat) + print(users[0].first_name) + + for user in users: + if user.username is not None: + print(user.username) + """ + return await self.iter_participants(*args, **kwargs).collect() + + get_participants.__signature__ = inspect.signature(iter_participants) + + + def iter_admin_log( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + max_id: int = 0, + min_id: int = 0, + search: str = None, + admins: 'hints.EntitiesLike' = None, + join: bool = None, + leave: bool = None, + invite: bool = None, + restrict: bool = None, + unrestrict: bool = None, + ban: bool = None, + unban: bool = None, + promote: bool = None, + demote: bool = None, + info: bool = None, + settings: bool = None, + pinned: bool = None, + edit: bool = None, + delete: bool = None, + group_call: bool = None) -> _AdminLogIter: + """ + Iterator over the admin log for the specified channel. + + The default order is from the most recent event to to the oldest. + + Note that you must be an administrator of it to use this method. + + If none of the filters are present (i.e. they all are `None`), + *all* event types will be returned. If at least one of them is + `True`, only those that are true will be returned. + + Arguments + entity (`entity`): + The channel entity from which to get its admin log. + + limit (`int` | `None`, optional): + Number of events to be retrieved. + + The limit may also be `None`, which would eventually return + the whole history. + + max_id (`int`): + All the events with a higher (newer) ID or equal to this will + be excluded. + + min_id (`int`): + All the events with a lower (older) ID or equal to this will + be excluded. + + search (`str`): + The string to be used as a search query. + + admins (`entity` | `list`): + If present, the events will be filtered by these admins + (or single admin) and only those caused by them will be + returned. + + join (`bool`): + If `True`, events for when a user joined will be returned. + + leave (`bool`): + If `True`, events for when a user leaves will be returned. + + invite (`bool`): + If `True`, events for when a user joins through an invite + link will be returned. + + restrict (`bool`): + If `True`, events with partial restrictions will be + returned. This is what the API calls "ban". + + unrestrict (`bool`): + If `True`, events removing restrictions will be returned. + This is what the API calls "unban". + + ban (`bool`): + If `True`, events applying or removing all restrictions will + be returned. This is what the API calls "kick" (restricting + all permissions removed is a ban, which kicks the user). + + unban (`bool`): + If `True`, events removing all restrictions will be + returned. This is what the API calls "unkick". + + promote (`bool`): + If `True`, events with admin promotions will be returned. + + demote (`bool`): + If `True`, events with admin demotions will be returned. + + info (`bool`): + If `True`, events changing the group info will be returned. + + settings (`bool`): + If `True`, events changing the group settings will be + returned. + + pinned (`bool`): + If `True`, events of new pinned messages will be returned. + + edit (`bool`): + If `True`, events of message edits will be returned. + + delete (`bool`): + If `True`, events of message deletions will be returned. + + group_call (`bool`): + If `True`, events related to group calls will be returned. + + Yields + Instances of `AdminLogEvent `. + + Example + .. code-block:: python + + async for event in client.iter_admin_log(channel): + if event.changed_title: + print('The title changed from', event.old, 'to', event.new) + """ + return _AdminLogIter( + self, + limit, + entity=entity, + admins=admins, + search=search, + min_id=min_id, + max_id=max_id, + join=join, + leave=leave, + invite=invite, + restrict=restrict, + unrestrict=unrestrict, + ban=ban, + unban=unban, + promote=promote, + demote=demote, + info=info, + settings=settings, + pinned=pinned, + edit=edit, + delete=delete, + group_call=group_call + ) + + async def get_admin_log( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + """ + Same as `iter_admin_log()`, but returns a ``list`` instead. + + Example + .. code-block:: python + + # Get a list of deleted message events which said "heck" + events = await client.get_admin_log(channel, search='heck', delete=True) + + # Print the old message before it was deleted + print(events[0].old) + """ + return await self.iter_admin_log(*args, **kwargs).collect() + + get_admin_log.__signature__ = inspect.signature(iter_admin_log) + + def iter_profile_photos( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: int = None, + *, + offset: int = 0, + max_id: int = 0) -> _ProfilePhotoIter: + """ + Iterator over a user's profile photos or a chat's photos. + + The order is from the most recent photo to the oldest. + + Arguments + entity (`entity`): + The entity from which to get the profile or chat photos. + + limit (`int` | `None`, optional): + Number of photos to be retrieved. + + The limit may also be `None`, which would eventually all + the photos that are still available. + + offset (`int`): + How many photos should be skipped before returning the first one. + + max_id (`int`): + The maximum ID allowed when fetching photos. + + Yields + Instances of :tl:`Photo`. + + Example + .. code-block:: python + + # Download all the profile photos of some user + async for photo in client.iter_profile_photos(user): + await client.download_media(photo) + """ + return _ProfilePhotoIter( + self, + limit, + entity=entity, + offset=offset, + max_id=max_id + ) + + async def get_profile_photos( + self: 'TelegramClient', + *args, + **kwargs) -> 'hints.TotalList': + """ + Same as `iter_profile_photos()`, but returns a + `TotalList ` instead. + + Example + .. code-block:: python + + # Get the photos of a channel + photos = await client.get_profile_photos(channel) + + # Download the oldest photo + await client.download_media(photos[-1]) + """ + return await self.iter_profile_photos(*args, **kwargs).collect() + + get_profile_photos.__signature__ = inspect.signature(iter_profile_photos) + + def action( + self: 'TelegramClient', + entity: 'hints.EntityLike', + action: 'typing.Union[str, types.TypeSendMessageAction]', + *, + delay: float = 4, + auto_cancel: bool = True) -> 'typing.Union[_ChatAction, typing.Coroutine]': + """ + Returns a context-manager object to represent a "chat action". + + Chat actions indicate things like "user is typing", "user is + uploading a photo", etc. + + If the action is ``'cancel'``, you should just ``await`` the result, + since it makes no sense to use a context-manager for it. + + See the example below for intended usage. + + Arguments + entity (`entity`): + The entity where the action should be showed in. + + action (`str` | :tl:`SendMessageAction`): + The action to show. You can either pass a instance of + :tl:`SendMessageAction` or better, a string used while: + + * ``'typing'``: typing a text message. + * ``'contact'``: choosing a contact. + * ``'game'``: playing a game. + * ``'location'``: choosing a geo location. + * ``'sticker'``: choosing a sticker. + * ``'record-audio'``: recording a voice note. + You may use ``'record-voice'`` as alias. + * ``'record-round'``: recording a round video. + * ``'record-video'``: recording a normal video. + * ``'audio'``: sending an audio file (voice note or song). + You may use ``'voice'`` and ``'song'`` as aliases. + * ``'round'``: uploading a round video. + * ``'video'``: uploading a video file. + * ``'photo'``: uploading a photo. + * ``'document'``: uploading a document file. + You may use ``'file'`` as alias. + * ``'cancel'``: cancel any pending action in this chat. + + Invalid strings will raise a ``ValueError``. + + delay (`int` | `float`): + The delay, in seconds, to wait between sending actions. + For example, if the delay is 5 and it takes 7 seconds to + do something, three requests will be made at 0s, 5s, and + 7s to cancel the action. + + auto_cancel (`bool`): + Whether the action should be cancelled once the context + manager exists or not. The default is `True`, since + you don't want progress to be shown when it has already + completed. + + Returns + Either a context-manager object or a coroutine. + + Example + .. code-block:: python + + # Type for 2 seconds, then send a message + async with client.action(chat, 'typing'): + await asyncio.sleep(2) + await client.send_message(chat, 'Hello world! I type slow ^^') + + # Cancel any previous action + await client.action(chat, 'cancel') + + # Upload a document, showing its progress (most clients ignore this) + async with client.action(chat, 'document') as action: + await client.send_file(chat, zip_file, progress_callback=action.progress) + """ + if isinstance(action, str): + try: + action = _ChatAction._str_mapping[action.lower()] + except KeyError: + raise ValueError( + 'No such action "{}"'.format(action)) from None + elif not isinstance(action, types.TLObject) or action.SUBCLASS_OF_ID != 0x20b2cc21: + # 0x20b2cc21 = crc32(b'SendMessageAction') + if isinstance(action, type): + raise ValueError('You must pass an instance, not the class') + else: + raise ValueError('Cannot use {} as action'.format(action)) + + if isinstance(action, types.SendMessageCancelAction): + # ``SetTypingRequest.resolve`` will get input peer of ``entity``. + return self(functions.messages.SetTypingRequest( + entity, types.SendMessageCancelAction())) + + return _ChatAction( + self, entity, action, delay=delay, auto_cancel=auto_cancel) + + async def edit_admin( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike', + *, + change_info: bool = None, + post_messages: bool = None, + edit_messages: bool = None, + delete_messages: bool = None, + ban_users: bool = None, + invite_users: bool = None, + pin_messages: bool = None, + add_admins: bool = None, + manage_call: bool = None, + anonymous: bool = None, + is_admin: bool = None, + title: str = None) -> types.Updates: + """ + Edits admin permissions for someone in a chat. + + Raises an error if a wrong combination of rights are given + (e.g. you don't have enough permissions to grant one). + + Unless otherwise stated, permissions will work in channels and megagroups. + + Arguments + entity (`entity`): + The channel, megagroup or chat where the promotion should happen. + + user (`entity`): + The user to be promoted. + + change_info (`bool`, optional): + Whether the user will be able to change info. + + post_messages (`bool`, optional): + Whether the user will be able to post in the channel. + This will only work in broadcast channels. + + edit_messages (`bool`, optional): + Whether the user will be able to edit messages in the channel. + This will only work in broadcast channels. + + delete_messages (`bool`, optional): + Whether the user will be able to delete messages. + + ban_users (`bool`, optional): + Whether the user will be able to ban users. + + invite_users (`bool`, optional): + Whether the user will be able to invite users. Needs some testing. + + pin_messages (`bool`, optional): + Whether the user will be able to pin messages. + + add_admins (`bool`, optional): + Whether the user will be able to add admins. + + manage_call (`bool`, optional): + Whether the user will be able to manage group calls. + + anonymous (`bool`, optional): + Whether the user will remain anonymous when sending messages. + The sender of the anonymous messages becomes the group itself. + + .. note:: + + Users may be able to identify the anonymous admin by its + custom title, so additional care is needed when using both + ``anonymous`` and custom titles. For example, if multiple + anonymous admins share the same title, users won't be able + to distinguish them. + + is_admin (`bool`, optional): + Whether the user will be an admin in the chat. + This will only work in small group chats. + Whether the user will be an admin in the chat. This is the + only permission available in small group chats, and when + used in megagroups, all non-explicitly set permissions will + have this value. + + Essentially, only passing ``is_admin=True`` will grant all + permissions, but you can still disable those you need. + + title (`str`, optional): + The custom title (also known as "rank") to show for this admin. + This text will be shown instead of the "admin" badge. + This will only work in channels and megagroups. + + When left unspecified or empty, the default localized "admin" + badge will be shown. + + Returns + The resulting :tl:`Updates` object. + + Example + .. code-block:: python + + # Allowing `user` to pin messages in `chat` + await client.edit_admin(chat, user, pin_messages=True) + + # Granting all permissions except for `add_admins` + await client.edit_admin(chat, user, is_admin=True, add_admins=False) + """ + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + + perm_names = ( + 'change_info', 'post_messages', 'edit_messages', 'delete_messages', + 'ban_users', 'invite_users', 'pin_messages', 'add_admins', + 'anonymous', 'manage_call', + ) + + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHANNEL: + # If we try to set these permissions in a megagroup, we + # would get a RIGHT_FORBIDDEN. However, it makes sense + # that an admin can post messages, so we want to avoid the error + if post_messages or edit_messages: + # TODO get rid of this once sessions cache this information + if entity.channel_id not in self._megagroup_cache: + full_entity = await self.get_entity(entity) + self._megagroup_cache[entity.channel_id] = getattr(full_entity, 'megagroup', False) + + if self._megagroup_cache[entity.channel_id]: + post_messages = None + edit_messages = None + + perms = locals() + return await self(functions.channels.EditAdminRequest(entity, user, types.ChatAdminRights(**{ + # A permission is its explicit (not-None) value or `is_admin`. + # This essentially makes `is_admin` be the default value. + name: perms[name] if perms[name] is not None else is_admin + for name in perm_names + }), rank=title or '')) + + elif ty == helpers._EntityType.CHAT: + # If the user passed any permission in a small + # group chat, they must be a full admin to have it. + if is_admin is None: + is_admin = any(locals()[x] for x in perm_names) + + return await self(functions.messages.EditChatAdminRequest( + entity.chat_id, user, is_admin=is_admin)) + + else: + raise ValueError( + 'You can only edit permissions in groups and channels') + + async def edit_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' = None, + until_date: 'hints.DateLike' = None, + *, + view_messages: bool = True, + send_messages: bool = True, + send_media: bool = True, + send_stickers: bool = True, + send_gifs: bool = True, + send_games: bool = True, + send_inline: bool = True, + embed_link_previews: bool = True, + send_polls: bool = True, + change_info: bool = True, + invite_users: bool = True, + pin_messages: bool = True) -> types.Updates: + """ + Edits user restrictions in a chat. + + Set an argument to `False` to apply a restriction (i.e. remove + the permission), or omit them to use the default `True` (i.e. + don't apply a restriction). + + Raises an error if a wrong combination of rights are given + (e.g. you don't have enough permissions to revoke one). + + By default, each boolean argument is `True`, meaning that it + is true that the user has access to the default permission + and may be able to make use of it. + + If you set an argument to `False`, then a restriction is applied + regardless of the default permissions. + + It is important to note that `True` does *not* mean grant, only + "don't restrict", and this is where the default permissions come + in. A user may have not been revoked the ``pin_messages`` permission + (it is `True`) but they won't be able to use it if the default + permissions don't allow it either. + + Arguments + entity (`entity`): + The channel or megagroup where the restriction should happen. + + user (`entity`, optional): + If specified, the permission will be changed for the specific user. + If left as `None`, the default chat permissions will be updated. + + until_date (`DateLike`, optional): + When the user will be unbanned. + + If the due date or duration is longer than 366 days or shorter than + 30 seconds, the ban will be forever. Defaults to ``0`` (ban forever). + + view_messages (`bool`, optional): + Whether the user is able to view messages or not. + Forbidding someone from viewing messages equals to banning them. + This will only work if ``user`` is set. + + send_messages (`bool`, optional): + Whether the user is able to send messages or not. + + send_media (`bool`, optional): + Whether the user is able to send media or not. + + send_stickers (`bool`, optional): + Whether the user is able to send stickers or not. + + send_gifs (`bool`, optional): + Whether the user is able to send animated gifs or not. + + send_games (`bool`, optional): + Whether the user is able to send games or not. + + send_inline (`bool`, optional): + Whether the user is able to use inline bots or not. + + embed_link_previews (`bool`, optional): + Whether the user is able to enable the link preview in the + messages they send. Note that the user will still be able to + send messages with links if this permission is removed, but + these links won't display a link preview. + + send_polls (`bool`, optional): + Whether the user is able to send polls or not. + + change_info (`bool`, optional): + Whether the user is able to change info or not. + + invite_users (`bool`, optional): + Whether the user is able to invite other users or not. + + pin_messages (`bool`, optional): + Whether the user is able to pin messages or not. + + Returns + The resulting :tl:`Updates` object. + + Example + .. code-block:: python + + from datetime import timedelta + + # Banning `user` from `chat` for 1 minute + await client.edit_permissions(chat, user, timedelta(minutes=1), + view_messages=False) + + # Banning `user` from `chat` forever + await client.edit_permissions(chat, user, view_messages=False) + + # Kicking someone (ban + un-ban) + await client.edit_permissions(chat, user, view_messages=False) + await client.edit_permissions(chat, user) + """ + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty != helpers._EntityType.CHANNEL: + raise ValueError('You must pass either a channel or a supergroup') + + rights = types.ChatBannedRights( + until_date=until_date, + view_messages=not view_messages, + send_messages=not send_messages, + send_media=not send_media, + send_stickers=not send_stickers, + send_gifs=not send_gifs, + send_games=not send_games, + send_inline=not send_inline, + embed_links=not embed_link_previews, + send_polls=not send_polls, + change_info=not change_info, + invite_users=not invite_users, + pin_messages=not pin_messages + ) + + if user is None: + return await self(functions.messages.EditChatDefaultBannedRightsRequest( + peer=entity, + banned_rights=rights + )) + + user = await self.get_input_entity(user) + + return await self(functions.channels.EditBannedRequest( + channel=entity, + participant=user, + banned_rights=rights + )) + + async def kick_participant( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'typing.Optional[hints.EntityLike]' + ): + """ + Kicks a user from a chat. + + Kicking yourself (``'me'``) will result in leaving the chat. + + .. note:: + + Attempting to kick someone who was banned will remove their + restrictions (and thus unbanning them), since kicking is just + ban + unban. + + Arguments + entity (`entity`): + The channel or chat where the user should be kicked from. + + user (`entity`, optional): + The user to kick. + + Returns + Returns the service `Message ` + produced about a user being kicked, if any. + + Example + .. code-block:: python + + # Kick some user from some chat, and deleting the service message + msg = await client.kick_participant(chat, user) + await msg.delete() + + # Leaving chat + await client.kick_participant(chat, 'me') + """ + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHAT: + resp = await self(functions.messages.DeleteChatUserRequest(entity.chat_id, user)) + elif ty == helpers._EntityType.CHANNEL: + if isinstance(user, types.InputPeerSelf): + # Despite no longer being in the channel, the account still + # seems to get the service message. + resp = await self(functions.channels.LeaveChannelRequest(entity)) + else: + resp = await self(functions.channels.EditBannedRequest( + channel=entity, + participant=user, + banned_rights=types.ChatBannedRights( + until_date=None, view_messages=True) + )) + await asyncio.sleep(0.5) + await self(functions.channels.EditBannedRequest( + channel=entity, + participant=user, + banned_rights=types.ChatBannedRights(until_date=None) + )) + else: + raise ValueError('You must pass either a channel or a chat') + + return self._get_response_message(None, resp, entity) + + async def get_permissions( + self: 'TelegramClient', + entity: 'hints.EntityLike', + user: 'hints.EntityLike' = None + ) -> 'typing.Optional[custom.ParticipantPermissions]': + """ + Fetches the permissions of a user in a specific chat or channel or + get Default Restricted Rights of Chat or Channel. + + .. note:: + + This request has to fetch the entire chat for small group chats, + which can get somewhat expensive, so use of a cache is advised. + + Arguments + entity (`entity`): + The channel or chat the user is participant of. + + user (`entity`, optional): + Target user. + + Returns + A `ParticipantPermissions ` + instance. Refer to its documentation to see what properties are + available. + + Example + .. code-block:: python + + permissions = await client.get_permissions(chat, user) + if permissions.is_admin: + # do something + + # Get Banned Permissions of Chat + await client.get_permissions(chat) + """ + entity = await self.get_entity(entity) + + if not user: + if isinstance(entity, (types.Channel, types.Community)): + FullChat = await self(functions.channels.GetFullChannelRequest(entity)) + elif isinstance(entity, types.Chat): + FullChat = await self(functions.messages.GetFullChatRequest(entity.id)) + else: + return + return FullChat.chats[0].default_banned_rights + + entity = await self.get_input_entity(entity) + user = await self.get_input_entity(user) + if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + participant = await self(functions.channels.GetParticipantRequest( + entity, + user + )) + return custom.ParticipantPermissions(participant.participant, False) + elif helpers._entity_type(entity) == helpers._EntityType.CHAT: + chat = await self(functions.messages.GetFullChatRequest( + entity.chat_id + )) + if isinstance(user, types.InputPeerSelf): + user = await self.get_me(input_peer=True) + for participant in chat.full_chat.participants.participants: + if participant.user_id == user.user_id: + return custom.ParticipantPermissions(participant, True) + raise errors.UserNotParticipantError(None) + + raise ValueError('You must pass either a channel or a chat') + + async def get_stats( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, types.Message]' = None, + ): + """ + Retrieves statistics from the given megagroup or broadcast channel. + + Note that some restrictions apply before being able to fetch statistics, + in particular the channel must have enough members (for megagroups, this + requires `at least 500 members`_). + + Arguments + entity (`entity`): + The channel from which to get statistics. + + message (`int` | ``Message``, optional): + The message ID from which to get statistics, if your goal is + to obtain the statistics of a single message. + + Raises + If the given entity is not a channel (broadcast or megagroup), + a `TypeError` is raised. + + If there are not enough members (poorly named) errors such as + ``telethon.errors.ChatAdminRequiredError`` will appear. + + Returns + If both ``entity`` and ``message`` were provided, returns + :tl:`MessageStats`. Otherwise, either :tl:`BroadcastStats` or + :tl:`MegagroupStats`, depending on whether the input belonged to a + broadcast channel or megagroup. + + Example + .. code-block:: python + + # Some megagroup or channel username or ID to fetch + channel = -100123 + stats = await client.get_stats(channel) + print('Stats from', stats.period.min_date, 'to', stats.period.max_date, ':') + print(stats.stringify()) + + .. _`at least 500 members`: https://telegram.org/blog/profile-videos-people-nearby-and-more + """ + entity = await self.get_input_entity(entity) + if helpers._entity_type(entity) != helpers._EntityType.CHANNEL: + raise TypeError('You must pass a channel entity') + + message = utils.get_message_id(message) + if message is not None: + try: + req = functions.stats.GetMessageStatsRequest(entity, message) + return await self(req) + except errors.StatsMigrateError as e: + dc = e.dc + else: + # Don't bother fetching the Channel entity (costs a request), instead + # try to guess and if it fails we know it's the other one (best case + # no extra request, worst just one). + try: + req = functions.stats.GetBroadcastStatsRequest(entity) + return await self(req) + except errors.StatsMigrateError as e: + dc = e.dc + except errors.BroadcastRequiredError: + req = functions.stats.GetMegagroupStatsRequest(entity) + try: + return await self(req) + except errors.StatsMigrateError as e: + dc = e.dc + + sender = await self._borrow_exported_sender(dc) + try: + # req will be resolved to use the right types inside by now + return await sender.send(req) + finally: + await self._return_exported_sender(sender) + + # endregion diff --git a/module/scripts/lib/telethon/client/dialogs.py b/module/scripts/lib/telethon/client/dialogs.py new file mode 100644 index 0000000..b0db0a6 --- /dev/null +++ b/module/scripts/lib/telethon/client/dialogs.py @@ -0,0 +1,610 @@ +import asyncio +import inspect +import itertools +import typing + +from .. import helpers, utils, hints, errors +from ..requestiter import RequestIter +from ..tl import types, functions, custom + +_MAX_CHUNK_SIZE = 100 + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +def _dialog_message_key(peer, message_id): + """ + Get the key to get messages from a dialog. + + We cannot just use the message ID because channels share message IDs, + and the peer ID is required to distinguish between them. But it is not + necessary in small group chats and private chats. + """ + return (peer.channel_id if isinstance(peer, types.PeerChannel) else None), message_id + + +class _DialogsIter(RequestIter): + async def _init( + self, offset_date, offset_id, offset_peer, ignore_pinned, ignore_migrated, folder + ): + self.request = functions.messages.GetDialogsRequest( + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + limit=1, + hash=0, + exclude_pinned=ignore_pinned, + folder_id=folder + ) + + if self.limit <= 0: + # Special case, get a single dialog and determine count + dialogs = await self.client(self.request) + self.total = getattr(dialogs, 'count', len(dialogs.dialogs)) + raise StopAsyncIteration + + self.seen = set() + self.offset_date = offset_date + self.ignore_migrated = ignore_migrated + + async def _load_next_chunk(self): + self.request.limit = min(self.left, _MAX_CHUNK_SIZE) + r = await self.client(self.request) + + self.total = getattr(r, 'count', len(r.dialogs)) + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats) + if not isinstance(x, (types.UserEmpty, types.ChatEmpty))} + + self.client._mb_entity_cache.extend(r.users, r.chats) + + messages = {} + for m in r.messages: + m._finish_init(self.client, entities, None) + messages[_dialog_message_key(m.peer_id, m.id)] = m + + for d in r.dialogs: + # We check the offset date here because Telegram may ignore it + message = messages.get(_dialog_message_key(d.peer, d.top_message)) + if self.offset_date: + date = getattr(message, 'date', None) + if not date or date.timestamp() > self.offset_date.timestamp(): + continue + + peer_id = utils.get_peer_id(d.peer) + if peer_id not in self.seen: + self.seen.add(peer_id) + if peer_id not in entities: + # > In which case can a UserEmpty appear in the list of banned members? + # > In a very rare cases. This is possible but isn't an expected behavior. + # Real world example: https://t.me/TelethonChat/271471 + continue + + cd = custom.Dialog(self.client, d, entities, message) + if cd.dialog.pts: + self.client._message_box.try_set_channel_state( + utils.get_peer_id(d.peer, add_mark=False), cd.dialog.pts) + + if not self.ignore_migrated or getattr( + cd.entity, 'migrated_to', None) is None: + self.buffer.append(cd) + + if not self.buffer or len(r.dialogs) < self.request.limit\ + or not isinstance(r, types.messages.DialogsSlice): + # Buffer being empty means all returned dialogs were skipped (due to offsets). + # Less than we requested means we reached the end, or + # we didn't get a DialogsSlice which means we got all. + return True + + # We can't use `messages[-1]` as the offset ID / date. + # Why? Because pinned dialogs will mess with the order + # in this list. Instead, we find the last dialog which + # has a message, and use it as an offset. + last_message = next(filter(None, ( + messages.get(_dialog_message_key(d.peer, d.top_message)) + for d in reversed(r.dialogs) + )), None) + + self.request.exclude_pinned = True + self.request.offset_id = last_message.id if last_message else 0 + self.request.offset_date = last_message.date if last_message else None + self.request.offset_peer = self.buffer[-1].input_entity + + +class _DraftsIter(RequestIter): + async def _init(self, entities, **kwargs): + if not entities: + r = await self.client(functions.messages.GetAllDraftsRequest()) + items = r.updates + else: + peers = [] + for entity in entities: + peers.append(types.InputDialogPeer( + await self.client.get_input_entity(entity))) + + r = await self.client(functions.messages.GetPeerDialogsRequest(peers)) + items = r.dialogs + + # TODO Maybe there should be a helper method for this? + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + self.buffer.extend( + custom.Draft(self.client, entities[utils.get_peer_id(d.peer)], d.draft) + for d in items + ) + + async def _load_next_chunk(self): + return [] + + +class DialogMethods: + + # region Public methods + + def iter_dialogs( + self: 'TelegramClient', + limit: float = None, + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + offset_peer: 'hints.EntityLike' = types.InputPeerEmpty(), + ignore_pinned: bool = False, + ignore_migrated: bool = False, + folder: int = None, + archived: bool = None + ) -> _DialogsIter: + """ + Iterator over the dialogs (open conversations/subscribed channels). + + The order is the same as the one seen in official applications + (first pinned, them from those with the most recent message to + those with the oldest message). + + Arguments + limit (`int` | `None`): + How many dialogs to be retrieved as maximum. Can be set to + `None` to retrieve all dialogs. Note that this may take + whole minutes if you have hundreds of dialogs, as Telegram + will tell the library to slow down through a + ``FloodWaitError``. + + offset_date (`datetime`, optional): + The offset date to be used. + + offset_id (`int`, optional): + The message ID to be used as an offset. + + offset_peer (:tl:`InputPeer`, optional): + The peer to be used as an offset. + + ignore_pinned (`bool`, optional): + Whether pinned dialogs should be ignored or not. + When set to `True`, these won't be yielded at all. + + ignore_migrated (`bool`, optional): + Whether :tl:`Chat` that have ``migrated_to`` a :tl:`Channel` + should be included or not. By default all the chats in your + dialogs are returned, but setting this to `True` will ignore + (i.e. skip) them in the same way official applications do. + + folder (`int`, optional): + The folder from which the dialogs should be retrieved. + + If left unspecified, all dialogs (including those from + folders) will be returned. + + If set to ``0``, all dialogs that don't belong to any + folder will be returned. + + If set to a folder number like ``1``, only those from + said folder will be returned. + + By default Telegram assigns the folder ID ``1`` to + archived chats, so you should use that if you need + to fetch the archived dialogs. + + archived (`bool`, optional): + Alias for `folder`. If unspecified, all will be returned, + `False` implies ``folder=0`` and `True` implies ``folder=1``. + Yields + Instances of `Dialog `. + + Example + .. code-block:: python + + # Print all dialog IDs and the title, nicely formatted + async for dialog in client.iter_dialogs(): + print('{:>14}: {}'.format(dialog.id, dialog.title)) + """ + if archived is not None: + folder = 1 if archived else 0 + + return _DialogsIter( + self, + limit, + offset_date=offset_date, + offset_id=offset_id, + offset_peer=offset_peer, + ignore_pinned=ignore_pinned, + ignore_migrated=ignore_migrated, + folder=folder + ) + + async def get_dialogs(self: 'TelegramClient', *args, **kwargs) -> 'hints.TotalList': + """ + Same as `iter_dialogs()`, but returns a + `TotalList ` instead. + + Example + .. code-block:: python + + # Get all open conversation, print the title of the first + dialogs = await client.get_dialogs() + first = dialogs[0] + print(first.title) + + # Use the dialog somewhere else + await client.send_message(first, 'hi') + + # Getting only non-archived dialogs (both equivalent) + non_archived = await client.get_dialogs(folder=0) + non_archived = await client.get_dialogs(archived=False) + + # Getting only archived dialogs (both equivalent) + archived = await client.get_dialogs(folder=1) + archived = await client.get_dialogs(archived=True) + """ + return await self.iter_dialogs(*args, **kwargs).collect() + + get_dialogs.__signature__ = inspect.signature(iter_dialogs) + + def iter_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None + ) -> _DraftsIter: + """ + Iterator over draft messages. + + The order is unspecified. + + Arguments + entity (`hints.EntitiesLike`, optional): + The entity or entities for which to fetch the draft messages. + If left unspecified, all draft messages will be returned. + + Yields + Instances of `Draft `. + + Example + .. code-block:: python + + # Clear all drafts + async for draft in client.get_drafts(): + await draft.delete() + + # Getting the drafts with 'bot1' and 'bot2' + async for draft in client.iter_drafts(['bot1', 'bot2']): + print(draft.text) + """ + if entity and not utils.is_list_like(entity): + entity = (entity,) + + # TODO Passing a limit here makes no sense + return _DraftsIter(self, None, entities=entity) + + async def get_drafts( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None + ) -> 'hints.TotalList': + """ + Same as `iter_drafts()`, but returns a list instead. + + Example + .. code-block:: python + + # Get drafts, print the text of the first + drafts = await client.get_drafts() + print(drafts[0].text) + + # Get the draft in your chat + draft = await client.get_drafts('me') + print(drafts.text) + """ + items = await self.iter_drafts(entity).collect() + if not entity or utils.is_list_like(entity): + return items + else: + return items[0] + + async def edit_folder( + self: 'TelegramClient', + entity: 'hints.EntitiesLike' = None, + folder: typing.Union[int, typing.Sequence[int]] = None, + *, + unpack=None + ) -> types.Updates: + """ + Edits the folder used by one or more dialogs to archive them. + + Arguments + entity (entities): + The entity or list of entities to move to the desired + archive folder. + + folder (`int`): + The folder to which the dialog should be archived to. + + If you want to "archive" a dialog, use ``folder=1``. + + If you want to "un-archive" it, use ``folder=0``. + + You may also pass a list with the same length as + `entities` if you want to control where each entity + will go. + + unpack (`int`, optional): + If you want to unpack an archived folder, set this + parameter to the folder number that you want to + delete. + + When you unpack a folder, all the dialogs inside are + moved to the folder number 0. + + You can only use this parameter if the other two + are not set. + + Returns + The :tl:`Updates` object that the request produces. + + Example + .. code-block:: python + + # Archiving the first 5 dialogs + dialogs = await client.get_dialogs(5) + await client.edit_folder(dialogs, 1) + + # Un-archiving the third dialog (archiving to folder 0) + await client.edit_folder(dialog[2], 0) + + # Moving the first dialog to folder 0 and the second to 1 + dialogs = await client.get_dialogs(2) + await client.edit_folder(dialogs, [0, 1]) + + # Un-archiving all dialogs + await client.edit_folder(unpack=1) + """ + if (entity is None) == (unpack is None): + raise ValueError('You can only set either entities or unpack, not both') + + if unpack is not None: + return await self(functions.folders.DeleteFolderRequest( + folder_id=unpack + )) + + if not utils.is_list_like(entity): + entities = [await self.get_input_entity(entity)] + else: + entities = await asyncio.gather( + *(self.get_input_entity(x) for x in entity)) + + if folder is None: + raise ValueError('You must specify a folder') + elif not utils.is_list_like(folder): + folder = [folder] * len(entities) + elif len(entities) != len(folder): + raise ValueError('Number of folders does not match number of entities') + + return await self(functions.folders.EditPeerFoldersRequest([ + types.InputFolderPeer(x, folder_id=y) + for x, y in zip(entities, folder) + ])) + + async def delete_dialog( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + revoke: bool = False + ): + """ + Deletes a dialog (leaves a chat or channel). + + This method can be used as a user and as a bot. However, + bots will only be able to use it to leave groups and channels + (trying to delete a private conversation will do nothing). + + See also `Dialog.delete() `. + + Arguments + entity (entities): + The entity of the dialog to delete. If it's a chat or + channel, you will leave it. Note that the chat itself + is not deleted, only the dialog, because you left it. + + revoke (`bool`, optional): + On private chats, you may revoke the messages from + the other peer too. By default, it's `False`. Set + it to `True` to delete the history for both. + + This makes no difference for bot accounts, who can + only leave groups and channels. + + Returns + The :tl:`Updates` object that the request produces, + or nothing for private conversations. + + Example + .. code-block:: python + + # Deleting the first dialog + dialogs = await client.get_dialogs(5) + await client.delete_dialog(dialogs[0]) + + # Leaving a channel by username + await client.delete_dialog('username') + """ + # If we have enough information (`Dialog.delete` gives it to us), + # then we know we don't have to kick ourselves in deactivated chats. + if isinstance(entity, types.Chat): + deactivated = entity.deactivated + else: + deactivated = False + + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + if ty == helpers._EntityType.CHANNEL: + return await self(functions.channels.LeaveChannelRequest(entity)) + + if ty == helpers._EntityType.CHAT and not deactivated: + try: + result = await self(functions.messages.DeleteChatUserRequest( + entity.chat_id, types.InputUserSelf(), revoke_history=revoke + )) + except errors.PeerIdInvalidError: + # Happens if we didn't have the deactivated information + result = None + else: + result = None + + if not await self.is_bot(): + await self(functions.messages.DeleteHistoryRequest(entity, 0, revoke=revoke)) + + return result + + def conversation( + self: 'TelegramClient', + entity: 'hints.EntityLike', + *, + timeout: float = 60, + total_timeout: float = None, + max_messages: int = 100, + exclusive: bool = True, + replies_are_responses: bool = True) -> custom.Conversation: + """ + Creates a `Conversation ` + with the given entity. + + .. note:: + + This Conversation API has certain shortcomings, such as lacking + persistence, poor interaction with other event handlers, and + overcomplicated usage for anything beyond the simplest case. + + If you plan to interact with a bot without handlers, this works + fine, but when running a bot yourself, you may instead prefer + to follow the advice from https://stackoverflow.com/a/62246569/. + + This is not the same as just sending a message to create a "dialog" + with them, but rather a way to easily send messages and await for + responses or other reactions. Refer to its documentation for more. + + Arguments + entity (`entity`): + The entity with which a new conversation should be opened. + + timeout (`int` | `float`, optional): + The default timeout (in seconds) *per action* to be used. You + may also override this timeout on a per-method basis. By + default each action can take up to 60 seconds (the value of + this timeout). + + total_timeout (`int` | `float`, optional): + The total timeout (in seconds) to use for the whole + conversation. This takes priority over per-action + timeouts. After these many seconds pass, subsequent + actions will result in ``asyncio.TimeoutError``. + + max_messages (`int`, optional): + The maximum amount of messages this conversation will + remember. After these many messages arrive in the + specified chat, subsequent actions will result in + ``ValueError``. + + exclusive (`bool`, optional): + By default, conversations are exclusive within a single + chat. That means that while a conversation is open in a + chat, you can't open another one in the same chat, unless + you disable this flag. + + If you try opening an exclusive conversation for + a chat where it's already open, it will raise + ``AlreadyInConversationError``. + + replies_are_responses (`bool`, optional): + Whether replies should be treated as responses or not. + + If the setting is enabled, calls to `conv.get_response + ` + and a subsequent call to `conv.get_reply + ` + will return different messages, otherwise they may return + the same message. + + Consider the following scenario with one outgoing message, + 1, and two incoming messages, the second one replying:: + + Hello! <1 + 2> (reply to 1) Hi! + 3> (reply to 1) How are you? + + And the following code: + + .. code-block:: python + + async with client.conversation(chat) as conv: + msg1 = await conv.send_message('Hello!') + msg2 = await conv.get_response() + msg3 = await conv.get_reply() + + With the setting enabled, ``msg2`` will be ``'Hi!'`` and + ``msg3`` be ``'How are you?'`` since replies are also + responses, and a response was already returned. + + With the setting disabled, both ``msg2`` and ``msg3`` will + be ``'Hi!'`` since one is a response and also a reply. + + Returns + A `Conversation `. + + Example + .. code-block:: python + + # denotes outgoing messages you sent + # denotes incoming response messages + with bot.conversation(chat) as conv: + # Hi! + conv.send_message('Hi!') + + # Hello! + hello = conv.get_response() + + # Please tell me your name + conv.send_message('Please tell me your name') + + # ? + name = conv.get_response().raw_text + + while not any(x.isalpha() for x in name): + # Your name didn't have any letters! Try again + conv.send_message("Your name didn't have any letters! Try again") + + # Human + name = conv.get_response().raw_text + + # Thanks Human! + conv.send_message('Thanks {}!'.format(name)) + """ + return custom.Conversation( + self, + entity, + timeout=timeout, + total_timeout=total_timeout, + max_messages=max_messages, + exclusive=exclusive, + replies_are_responses=replies_are_responses + + ) + + # endregion diff --git a/module/scripts/lib/telethon/client/downloads.py b/module/scripts/lib/telethon/client/downloads.py new file mode 100644 index 0000000..4601709 --- /dev/null +++ b/module/scripts/lib/telethon/client/downloads.py @@ -0,0 +1,1092 @@ +import datetime +import io +import os +import pathlib +import typing +import inspect +import asyncio + +from ..crypto import AES + +from .. import utils, helpers, errors, hints +from ..requestiter import RequestIter +from ..tl import TLObject, types, functions + +try: + import aiohttp +except ImportError: + aiohttp = None + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + +# Chunk sizes for upload.getFile must be multiples of the smallest size +MIN_CHUNK_SIZE = 4096 +MAX_CHUNK_SIZE = 512 * 1024 + +# 2021-01-15, users reported that `errors.TimeoutError` can occur while downloading files. +TIMED_OUT_SLEEP = 1 + + +class _CdnRedirect(Exception): + def __init__(self, cdn_redirect=None): + self.cdn_redirect = cdn_redirect + + +class _DirectDownloadIter(RequestIter): + async def _init( + self, file, dc_id, offset, stride, chunk_size, request_size, file_size, msg_data, cdn_redirect=None): + self.request = functions.upload.GetFileRequest( + file, offset=offset, limit=request_size) + self._client = self.client + self._cdn_redirect = cdn_redirect + if cdn_redirect is not None: + self.request = functions.upload.GetCdnFileRequest(cdn_redirect.file_token, offset=offset, limit=request_size) + self._client = await self.client._get_cdn_client(cdn_redirect) + + self.total = file_size + self._stride = stride + self._chunk_size = chunk_size + self._last_part = None + self._msg_data = msg_data + self._timed_out = False + + self._exported = dc_id and self._client.session.dc_id != dc_id + if not self._exported: + # The used sender will also change if ``FileMigrateError`` occurs + self._sender = self.client._sender + else: + try: + self._sender = await self.client._borrow_exported_sender(dc_id) + except errors.DcIdInvalidError: + # Can't export a sender for the ID we are currently in + config = await self.client(functions.help.GetConfigRequest()) + for option in config.dc_options: + if option.ip_address == self.client.session.server_address: + await utils.maybe_async( + self.client.session.set_dc( + option.id, option.ip_address, option.port + ) + ) + await utils.maybe_async(self.client.session.save()) + break + + # TODO Figure out why the session may have the wrong DC ID + self._sender = self.client._sender + self._exported = False + + async def _load_next_chunk(self): + cur = await self._request() + self.buffer.append(cur) + if len(cur) < self.request.limit: + self.left = len(self.buffer) + await self.close() + else: + self.request.offset += self._stride + + async def _request(self): + try: + result = await self._client._call(self._sender, self.request) + self._timed_out = False + if isinstance(result, types.upload.FileCdnRedirect): + if self.client._mb_entity_cache.self_bot: + raise ValueError('FileCdnRedirect but the GetCdnFileRequest API access for bot users is restricted. Try to change api_id to avoid FileCdnRedirect') + raise _CdnRedirect(result) + if isinstance(result, types.upload.CdnFileReuploadNeeded): + await self.client._call(self.client._sender, functions.upload.ReuploadCdnFileRequest(file_token=self._cdn_redirect.file_token, request_token=result.request_token)) + result = await self._client._call(self._sender, self.request) + return result.bytes + else: + return result.bytes + + except errors.TimedOutError as e: + if self._timed_out: + self.client._log[__name__].warning('Got two timeouts in a row while downloading file') + raise + + self._timed_out = True + self.client._log[__name__].info('Got timeout while downloading file, retrying once') + await asyncio.sleep(TIMED_OUT_SLEEP) + return await self._request() + + except errors.FileMigrateError as e: + self.client._log[__name__].info('File lives in another DC') + self._sender = await self.client._borrow_exported_sender(e.new_dc) + self._exported = True + return await self._request() + + except (errors.FilerefUpgradeNeededError, errors.FileReferenceExpiredError) as e: + # Only implemented for documents which are the ones that may take that long to download + if not self._msg_data \ + or not isinstance(self.request.location, types.InputDocumentFileLocation) \ + or self.request.location.thumb_size != '': + raise + + self.client._log[__name__].info('File ref expired during download; refetching message') + chat, msg_id = self._msg_data + msg = await self.client.get_messages(chat, ids=msg_id) + + if not isinstance(msg.media, types.MessageMediaDocument): + raise + + document = msg.media.document + + # Message media may have been edited for something else + if document.id != self.request.location.id: + raise + + self.request.location.file_reference = document.file_reference + return await self._request() + + async def close(self): + if not self._sender: + return + + try: + if self._exported: + await self.client._return_exported_sender(self._sender) + elif self._sender != self.client._sender: + await self._sender.disconnect() + finally: + self._sender = None + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + await self.close() + + __enter__ = helpers._sync_enter + __exit__ = helpers._sync_exit + + +class _GenericDownloadIter(_DirectDownloadIter): + async def _load_next_chunk(self): + # 1. Fetch enough for one chunk + data = b'' + + # 1.1. ``bad`` is how much into the data we have we need to offset + bad = self.request.offset % self.request.limit + before = self.request.offset + + # 1.2. We have to fetch from a valid offset, so remove that bad part + self.request.offset -= bad + + done = False + while not done and len(data) - bad < self._chunk_size: + cur = await self._request() + self.request.offset += self.request.limit + + data += cur + done = len(cur) < self.request.limit + + # 1.3 Restore our last desired offset + self.request.offset = before + + # 2. Fill the buffer with the data we have + # 2.1. Slicing `bytes` is expensive, yield `memoryview` instead + mem = memoryview(data) + + # 2.2. The current chunk starts at ``bad`` offset into the data, + # and each new chunk is ``stride`` bytes apart of the other + for i in range(bad, len(data), self._stride): + self.buffer.append(mem[i:i + self._chunk_size]) + + # 2.3. We will yield this offset, so move to the next one + self.request.offset += self._stride + + # 2.4. If we are in the last chunk, we will return the last partial data + if done: + self.left = len(self.buffer) + await self.close() + return + + # 2.5. If we are not done, we can't return incomplete chunks. + if len(self.buffer[-1]) != self._chunk_size: + self._last_part = self.buffer.pop().tobytes() + + # 3. Be careful with the offsets. Re-fetching a bit of data + # is fine, since it greatly simplifies things. + # TODO Try to not re-fetch data + self.request.offset -= self._stride + + +class DownloadMethods: + + # region Public methods + + async def download_profile_photo( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'hints.FileLike' = None, + *, + download_big: bool = True) -> typing.Optional[str]: + """ + Downloads the profile photo from the given user, chat or channel. + + Arguments + entity (`entity`): + From who the photo will be downloaded. + + .. note:: + + This method expects the full entity (which has the data + to download the photo), not an input variant. + + It's possible that sometimes you can't fetch the entity + from its input (since you can get errors like + ``ChannelPrivateError``) but you already have it through + another call, like getting a forwarded message from it. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + If file is the type `bytes`, it will be downloaded in-memory + and returned as a bytestring (i.e. ``file=bytes``, without + parentheses or quotes). + + download_big (`bool`, optional): + Whether to use the big version of the available photos. + + Returns + `None` if no photo was provided, or if it was Empty. On success + the file path is returned since it may differ from the one given. + + Example + .. code-block:: python + + # Download your own profile photo + path = await client.download_profile_photo('me') + print(path) + """ + # hex(crc32(x.encode('ascii'))) for x in + # ('User', 'Chat', 'UserFull', 'ChatFull') + ENTITIES = (0x2da17977, 0xc5af5d94, 0x1f4661b9, 0xd49a2697) + # ('InputPeer', 'InputUser', 'InputChannel') + INPUTS = (0xc91c90b6, 0xe669bf46, 0x40f202fd) + if not isinstance(entity, TLObject) or entity.SUBCLASS_OF_ID in INPUTS: + entity = await self.get_entity(entity) + + thumb = -1 if download_big else 0 + + possible_names = [] + if entity.SUBCLASS_OF_ID not in ENTITIES: + photo = entity + else: + if not hasattr(entity, 'photo'): + # Special case: may be a ChatFull with photo:Photo + # This is different from a normal UserProfilePhoto and Chat + if not hasattr(entity, 'chat_photo'): + return None + + return await self._download_photo( + entity.chat_photo, file, date=None, + thumb=thumb, progress_callback=None + ) + + for attr in ('username', 'first_name', 'title'): + possible_names.append(getattr(entity, attr, None)) + + photo = entity.photo + + if isinstance(photo, (types.UserProfilePhoto, types.ChatPhoto)): + dc_id = photo.dc_id + loc = types.InputPeerPhotoFileLocation( + # min users can be used to download profile photos + # self.get_input_entity would otherwise not accept those + peer=utils.get_input_peer(entity, check_hash=False), + photo_id=photo.photo_id, + big=download_big + ) + else: + # It doesn't make any sense to check if `photo` can be used + # as input location, because then this method would be able + # to "download the profile photo of a message", i.e. its + # media which should be done with `download_media` instead. + return None + + file = self._get_proper_filename( + file, 'profile_photo', '.jpg', + possible_names=possible_names + ) + + try: + result = await self.download_file(loc, file, dc_id=dc_id) + return result if file is bytes else file + except errors.LocationInvalidError: + # See issue #500, Android app fails as of v4.6.0 (1155). + # The fix seems to be using the full channel chat photo. + ie = await self.get_input_entity(entity) + ty = helpers._entity_type(ie) + if ty == helpers._EntityType.CHANNEL: + full = await self(functions.channels.GetFullChannelRequest(ie)) + return await self._download_photo( + full.full_chat.chat_photo, file, + date=None, progress_callback=None, + thumb=thumb + ) + else: + # Until there's a report for chats, no need to. + return None + + async def download_media( + self: 'TelegramClient', + message: 'hints.MessageLike', + file: 'hints.FileLike' = None, + *, + thumb: 'typing.Union[int, types.TypePhotoSize]' = None, + progress_callback: 'hints.ProgressCallback' = None) -> typing.Optional[typing.Union[str, bytes]]: + """ + Downloads the given media from a message object. + + Note that if the download is too slow, you should consider installing + ``cryptg`` (through ``pip install cryptg``) so that decrypting the + received data is done in C instead of Python (much faster). + + See also `Message.download_media() `. + + Arguments + message (`Message ` | :tl:`Media`): + The media or message containing the media that will be downloaded. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + If file is the type `bytes`, it will be downloaded in-memory + and returned as a bytestring (i.e. ``file=bytes``, without + parentheses or quotes). + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(received bytes, total)``. + + thumb (`int` | :tl:`PhotoSize`, optional): + Which thumbnail size from the document or photo to download, + instead of downloading the document or photo itself. + + If it's specified but the file does not have a thumbnail, + this method will return `None`. + + The parameter should be an integer index between ``0`` and + ``len(sizes)``. ``0`` will download the smallest thumbnail, + and ``len(sizes) - 1`` will download the largest thumbnail. + You can also use negative indices, which work the same as + they do in Python's `list`. + + You can also pass the :tl:`PhotoSize` instance to use. + Alternatively, the thumb size type `str` may be used. + + In short, use ``thumb=0`` if you want the smallest thumbnail + and ``thumb=-1`` if you want the largest thumbnail. + + .. note:: + The largest thumbnail may be a video instead of a photo, + as they are available since layer 116 and are bigger than + any of the photos. + + Returns + `None` if no media was provided, or if it was Empty. On success + the file path is returned since it may differ from the one given. + + Example + .. code-block:: python + + path = await client.download_media(message) + await client.download_media(message, filename) + # or + path = await message.download_media() + await message.download_media(filename) + + # Downloading to memory + blob = await client.download_media(message, bytes) + + # Printing download progress + def callback(current, total): + print('Downloaded', current, 'out of', total, + 'bytes: {:.2%}'.format(current / total)) + + await client.download_media(message, progress_callback=callback) + """ + # Downloading large documents may be slow enough to require a new file reference + # to be obtained mid-download. Store (input chat, message id) so that the message + # can be re-fetched. + msg_data = None + + # TODO This won't work for messageService + if isinstance(message, types.Message): + date = message.date + media = message.media + msg_data = (message.input_chat, message.id) if message.input_chat else None + else: + date = datetime.datetime.now() + media = message + + if isinstance(media, str): + media = utils.resolve_bot_file_id(media) + + if isinstance(media, types.MessageService): + if isinstance(message.action, + types.MessageActionChatEditPhoto): + media = media.photo + + if isinstance(media, types.MessageMediaWebPage): + if isinstance(media.webpage, types.WebPage): + media = media.webpage.document or media.webpage.photo + + if isinstance(media, (types.MessageMediaPhoto, types.Photo)): + return await self._download_photo( + media, file, date, thumb, progress_callback + ) + elif isinstance(media, (types.MessageMediaDocument, types.Document)): + return await self._download_document( + media, file, date, thumb, progress_callback, msg_data + ) + elif isinstance(media, types.MessageMediaContact) and thumb is None: + return self._download_contact( + media, file + ) + elif isinstance(media, (types.WebDocument, types.WebDocumentNoProxy)) and thumb is None: + return await self._download_web_document( + media, file, progress_callback + ) + + async def download_file( + self: 'TelegramClient', + input_location: 'hints.FileLike', + file: 'hints.OutFileLike' = None, + *, + part_size_kb: float = None, + file_size: int = None, + progress_callback: 'hints.ProgressCallback' = None, + dc_id: int = None, + key: bytes = None, + iv: bytes = None) -> typing.Optional[bytes]: + """ + Low-level method to download files from their input location. + + .. note:: + + Generally, you should instead use `download_media`. + This method is intended to be a bit more low-level. + + Arguments + input_location (:tl:`InputFileLocation`): + The file location from which the file will be downloaded. + See `telethon.utils.get_input_location` source for a complete + list of supported types. + + file (`str` | `file`, optional): + The output file path, directory, or stream-like object. + If the path exists and is a file, it will be overwritten. + + If the file path is `None` or `bytes`, then the result + will be saved in memory and returned as `bytes`. + + part_size_kb (`int`, optional): + Chunk size when downloading files. The larger, the less + requests will be made (up to 512KB maximum). + + file_size (`int`, optional): + The file size that is about to be downloaded, if known. + Only used if ``progress_callback`` is specified. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(downloaded bytes, total)``. Note that the + ``total`` is the provided ``file_size``. + + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. + + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + + + Example + .. code-block:: python + + # Download a file and print its header + data = await client.download_file(input_file, bytes) + print(data[:16]) + """ + return await self._download_file( + input_location, + file, + part_size_kb=part_size_kb, + file_size=file_size, + progress_callback=progress_callback, + dc_id=dc_id, + key=key, + iv=iv, + ) + + async def _download_file( + self: 'TelegramClient', + input_location: 'hints.FileLike', + file: 'hints.OutFileLike' = None, + *, + part_size_kb: float = None, + file_size: int = None, + progress_callback: 'hints.ProgressCallback' = None, + dc_id: int = None, + key: bytes = None, + iv: bytes = None, + msg_data: tuple = None, + cdn_redirect: types.upload.FileCdnRedirect = None + ) -> typing.Optional[bytes]: + if not part_size_kb: + if not file_size: + part_size_kb = 64 # Reasonable default + else: + part_size_kb = utils.get_appropriated_part_size(file_size) + + part_size = int(part_size_kb * 1024) + if part_size % MIN_CHUNK_SIZE != 0: + raise ValueError( + 'The part size must be evenly divisible by 4096.') + + if isinstance(file, pathlib.Path): + file = str(file.absolute()) + + in_memory = file is None or file is bytes + if in_memory: + f = io.BytesIO() + elif isinstance(file, str): + # Ensure that we'll be able to download the media + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + + try: + async for chunk in self._iter_download( + input_location, request_size=part_size, dc_id=dc_id, msg_data=msg_data, cdn_redirect=cdn_redirect): + if iv and key: + chunk = AES.decrypt_ige(chunk, key, iv) + r = f.write(chunk) + if inspect.isawaitable(r): + await r + + if progress_callback: + r = progress_callback(f.tell(), file_size) + if inspect.isawaitable(r): + await r + + # Not all IO objects have flush (see #1227) + if callable(getattr(f, 'flush', None)): + f.flush() + + if in_memory: + return f.getvalue() + except _CdnRedirect as e: + self._log[__name__].info('FileCdnRedirect to CDN data center %s', e.cdn_redirect.dc_id) + return await self._download_file( + input_location=input_location, + file=file, + part_size_kb=part_size_kb, + file_size=file_size, + progress_callback=progress_callback, + dc_id=e.cdn_redirect.dc_id, + key=e.cdn_redirect.encryption_key, + iv=e.cdn_redirect.encryption_iv, + msg_data=msg_data, + cdn_redirect=e.cdn_redirect + ) + finally: + if isinstance(file, str) or in_memory: + f.close() + + def iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = None, + chunk_size: int = None, + request_size: int = MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None + ): + """ + Iterates over a file download, yielding chunks of the file. + + This method can be used to stream files in a more convenient + way, since it offers more control (pausing, resuming, etc.) + + .. note:: + + Using a value for `offset` or `stride` which is not a multiple + of the minimum allowed `request_size`, or if `chunk_size` is + different from `request_size`, the library will need to do a + bit more work to fetch the data in the way you intend it to. + + You normally shouldn't worry about this. + + Arguments + file (`hints.FileLike`): + The file of which contents you want to iterate over. + + offset (`int`, optional): + The offset in bytes into the file from where the + download should start. For example, if a file is + 1024KB long and you just want the last 512KB, you + would use ``offset=512 * 1024``. + + stride (`int`, optional): + The stride of each chunk (how much the offset should + advance between reading each chunk). This parameter + should only be used for more advanced use cases. + + It must be bigger than or equal to the `chunk_size`. + + limit (`int`, optional): + The limit for how many *chunks* will be yielded at most. + + chunk_size (`int`, optional): + The maximum size of the chunks that will be yielded. + Note that the last chunk may be less than this value. + By default, it equals to `request_size`. + + request_size (`int`, optional): + How many bytes will be requested to Telegram when more + data is required. By default, as many bytes as possible + are requested. If you would like to request data in + smaller sizes, adjust this parameter. + + Note that values outside the valid range will be clamped, + and the final value will also be a multiple of the minimum + allowed size. + + file_size (`int`, optional): + If the file size is known beforehand, you should set + this parameter to said value. Depending on the type of + the input file passed, this may be set automatically. + + dc_id (`int`, optional): + The data center the library should connect to in order + to download the file. You shouldn't worry about this. + + Yields + + `bytes` objects representing the chunks of the file if the + right conditions are met, or `memoryview` objects instead. + + Example + .. code-block:: python + + # Streaming `media` to an output file + # After the iteration ends, the sender is cleaned up + with open('photo.jpg', 'wb') as fd: + async for chunk in client.iter_download(media): + fd.write(chunk) + + # Fetching only the header of a file (32 bytes) + # You should manually close the iterator in this case. + # + # "stream" is a common name for asynchronous generators, + # and iter_download will yield `bytes` (chunks of the file). + stream = client.iter_download(media, request_size=32) + header = await stream.__anext__() # "manual" version of `async for` + await stream.close() + assert len(header) == 32 + """ + return self._iter_download( + file, + offset=offset, + stride=stride, + limit=limit, + chunk_size=chunk_size, + request_size=request_size, + file_size=file_size, + dc_id=dc_id, + ) + + def _iter_download( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + offset: int = 0, + stride: int = None, + limit: int = None, + chunk_size: int = None, + request_size: int = MAX_CHUNK_SIZE, + file_size: int = None, + dc_id: int = None, + msg_data: tuple = None, + cdn_redirect: types.upload.FileCdnRedirect = None + ): + info = utils._get_file_info(file) + if info.dc_id is not None: + dc_id = info.dc_id + + if file_size is None: + file_size = info.size + + file = info.location + + if chunk_size is None: + chunk_size = request_size + + if limit is None and file_size is not None: + limit = (file_size + chunk_size - 1) // chunk_size + + if stride is None: + stride = chunk_size + elif stride < chunk_size: + raise ValueError('stride must be >= chunk_size') + + request_size -= request_size % MIN_CHUNK_SIZE + if request_size < MIN_CHUNK_SIZE: + request_size = MIN_CHUNK_SIZE + elif request_size > MAX_CHUNK_SIZE: + request_size = MAX_CHUNK_SIZE + + if chunk_size == request_size \ + and offset % MIN_CHUNK_SIZE == 0 \ + and stride % MIN_CHUNK_SIZE == 0 \ + and (limit is None or offset % limit == 0): + cls = _DirectDownloadIter + self._log[__name__].info('Starting direct file download in chunks of ' + '%d at %d, stride %d', request_size, offset, stride) + else: + cls = _GenericDownloadIter + self._log[__name__].info('Starting indirect file download in chunks of ' + '%d at %d, stride %d', request_size, offset, stride) + + return cls( + self, + limit, + file=file, + dc_id=dc_id, + offset=offset, + stride=stride, + chunk_size=chunk_size, + request_size=request_size, + file_size=file_size, + msg_data=msg_data, + cdn_redirect=cdn_redirect + ) + + # endregion + + # region Private methods + + @staticmethod + def _get_thumb(thumbs, thumb): + if not thumbs: + return None + + # Seems Telegram has changed the order and put `PhotoStrippedSize` + # last while this is the smallest (layer 116). Ensure we have the + # sizes sorted correctly with a custom function. + def sort_thumbs(thumb): + if isinstance(thumb, types.PhotoStrippedSize): + return 1, len(thumb.bytes) + if isinstance(thumb, types.PhotoCachedSize): + return 1, len(thumb.bytes) + if isinstance(thumb, types.PhotoSize): + return 1, thumb.size + if isinstance(thumb, types.PhotoSizeProgressive): + return 1, max(thumb.sizes) + if isinstance(thumb, types.VideoSize): + return 2, thumb.size + + # Empty size or invalid should go last + return 0, 0 + + thumbs = list(sorted(thumbs, key=sort_thumbs)) + + for i in reversed(range(len(thumbs))): + # :tl:`PhotoPathSize` is used for animated stickers preview, and the thumb is actually + # a SVG path of the outline. Users expect thumbnails to be JPEG files, so pretend this + # thumb size doesn't actually exist (#1655). + if isinstance(thumbs[i], types.PhotoPathSize): + thumbs.pop(i) + + if thumb is None: + return thumbs[-1] + elif isinstance(thumb, int): + return thumbs[thumb] + elif isinstance(thumb, str): + return next((t for t in thumbs if getattr(t, 'type', None) == thumb), None) + elif isinstance(thumb, (types.PhotoSize, types.PhotoCachedSize, + types.PhotoStrippedSize, types.VideoSize)): + return thumb + else: + return None + + def _download_cached_photo_size(self: 'TelegramClient', size, file): + # No need to download anything, simply write the bytes + if isinstance(size, types.PhotoStrippedSize): + data = utils.stripped_photo_to_jpg(size.bytes) + else: + data = size.bytes + + if file is bytes: + return data + elif isinstance(file, str): + helpers.ensure_parent_dir_exists(file) + f = open(file, 'wb') + else: + f = file + + try: + f.write(data) + finally: + if isinstance(file, str): + f.close() + return file + + async def _download_photo(self: 'TelegramClient', photo, file, date, thumb, progress_callback): + """Specialized version of .download_media() for photos""" + # Determine the photo and its largest size + if isinstance(photo, types.MessageMediaPhoto): + photo = photo.photo + if not isinstance(photo, types.Photo): + return + + # Include video sizes here (but they may be None so provide an empty list) + size = self._get_thumb(photo.sizes + (photo.video_sizes or []), thumb) + if not size or isinstance(size, types.PhotoSizeEmpty): + return + + if isinstance(size, types.VideoSize): + file = self._get_proper_filename(file, 'video', '.mp4', date=date) + else: + file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + + if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)): + return self._download_cached_photo_size(size, file) + + if isinstance(size, types.PhotoSizeProgressive): + file_size = max(size.sizes) + else: + file_size = size.size + + result = await self.download_file( + types.InputPhotoFileLocation( + id=photo.id, + access_hash=photo.access_hash, + file_reference=photo.file_reference, + thumb_size=size.type + ), + file, + file_size=file_size, + progress_callback=progress_callback + ) + return result if file is bytes else file + + @staticmethod + def _get_kind_and_names(attributes): + """Gets kind and possible names for :tl:`DocumentAttribute`.""" + kind = 'document' + possible_names = [] + for attr in attributes: + if isinstance(attr, types.DocumentAttributeFilename): + possible_names.insert(0, attr.file_name) + + elif isinstance(attr, types.DocumentAttributeAudio): + kind = 'audio' + if attr.performer and attr.title: + possible_names.append('{} - {}'.format( + attr.performer, attr.title + )) + elif attr.performer: + possible_names.append(attr.performer) + elif attr.title: + possible_names.append(attr.title) + elif attr.voice: + kind = 'voice' + + return kind, possible_names + + async def _download_document( + self, document, file, date, thumb, progress_callback, msg_data): + """Specialized version of .download_media() for documents.""" + if isinstance(document, types.MessageMediaDocument): + document = document.document + if not isinstance(document, types.Document): + return + + if thumb is None: + kind, possible_names = self._get_kind_and_names(document.attributes) + file = self._get_proper_filename( + file, kind, utils.get_extension(document), + date=date, possible_names=possible_names + ) + size = None + else: + file = self._get_proper_filename(file, 'photo', '.jpg', date=date) + size = self._get_thumb(document.thumbs, thumb) + if not size or isinstance(size, types.PhotoSizeEmpty): + return + + if isinstance(size, (types.PhotoCachedSize, types.PhotoStrippedSize)): + return self._download_cached_photo_size(size, file) + + result = await self._download_file( + types.InputDocumentFileLocation( + id=document.id, + access_hash=document.access_hash, + file_reference=document.file_reference, + thumb_size=size.type if size else '' + ), + file, + file_size=size.size if size else document.size, + progress_callback=progress_callback, + msg_data=msg_data, + ) + + return result if file is bytes else file + + @classmethod + def _download_contact(cls, mm_contact, file): + """ + Specialized version of .download_media() for contacts. + Will make use of the vCard 4.0 format. + """ + first_name = mm_contact.first_name + last_name = mm_contact.last_name + phone_number = mm_contact.phone_number + + # Remove these pesky characters + first_name = first_name.replace(';', '') + last_name = (last_name or '').replace(';', '') + result = ( + 'BEGIN:VCARD\n' + 'VERSION:4.0\n' + 'N:{f};{l};;;\n' + 'FN:{f} {l}\n' + 'TEL;TYPE=cell;VALUE=uri:tel:+{p}\n' + 'END:VCARD\n' + ).format(f=first_name, l=last_name, p=phone_number).encode('utf-8') + + file = cls._get_proper_filename( + file, 'contact', '.vcard', + possible_names=[first_name, phone_number, last_name] + ) + if file is bytes: + return result + f = file if hasattr(file, 'write') else open(file, 'wb') + + try: + f.write(result) + finally: + # Only close the stream if we opened it + if f != file: + f.close() + + return file + + @classmethod + async def _download_web_document(cls, web, file, progress_callback): + """ + Specialized version of .download_media() for web documents. + """ + if not aiohttp: + raise ValueError( + 'Cannot download web documents without the aiohttp ' + 'dependency install it (pip install aiohttp)' + ) + + # TODO Better way to get opened handles of files and auto-close + kind, possible_names = cls._get_kind_and_names(web.attributes) + file = cls._get_proper_filename( + file, kind, utils.get_extension(web), + possible_names=possible_names + ) + if file is bytes: + f = io.BytesIO() + elif hasattr(file, 'write'): + f = file + else: + f = open(file, 'wb') + + try: + async with aiohttp.ClientSession() as session: + # TODO Use progress_callback; get content length from response + # https://github.com/telegramdesktop/tdesktop/blob/c7e773dd9aeba94e2be48c032edc9a78bb50234e/Telegram/SourceFiles/ui/images.cpp#L1318-L1319 + async with session.get(web.url) as response: + while True: + chunk = await response.content.read(128 * 1024) + if not chunk: + break + f.write(chunk) + finally: + if f != file: + f.close() + + return f.getvalue() if file is bytes else file + + @staticmethod + def _get_proper_filename(file, kind, extension, + date=None, possible_names=None): + """Gets a proper filename for 'file', if this is a path. + + 'kind' should be the kind of the output file (photo, document...) + 'extension' should be the extension to be added to the file if + the filename doesn't have any yet + 'date' should be when this file was originally sent, if known + 'possible_names' should be an ordered list of possible names + + If no modification is made to the path, any existing file + will be overwritten. + If any modification is made to the path, this method will + ensure that no existing file will be overwritten. + """ + if isinstance(file, pathlib.Path): + file = str(file.absolute()) + + if file is not None and not isinstance(file, str): + # Probably a stream-like object, we cannot set a filename here + return file + + if file is None: + file = '' + elif os.path.isfile(file): + # Make no modifications to valid existing paths + return file + + if os.path.isdir(file) or not file: + try: + isreserved = getattr(os.path, 'isreserved', lambda _: False) # Python 3.13 and above + name = None if possible_names is None else next( + x # basename to prevent path traversal (#4713) + for x in map(os.path.basename, filter(None, possible_names)) + if not isreserved(x) + ) + except StopIteration: + name = None + + if not name: + if not date: + date = datetime.datetime.now() + name = '{}_{}-{:02}-{:02}_{:02}-{:02}-{:02}'.format( + kind, + date.year, date.month, date.day, + date.hour, date.minute, date.second, + ) + file = os.path.join(file, name) + + directory, name = os.path.split(file) + name, ext = os.path.splitext(name) + if not ext: + ext = extension + + result = os.path.join(directory, name + ext) + if not os.path.isfile(result): + return result + + i = 1 + while True: + result = os.path.join(directory, '{} ({}){}'.format(name, i, ext)) + if not os.path.isfile(result): + return result + i += 1 + + # endregion diff --git a/module/scripts/lib/telethon/client/messageparse.py b/module/scripts/lib/telethon/client/messageparse.py new file mode 100644 index 0000000..4ddfa52 --- /dev/null +++ b/module/scripts/lib/telethon/client/messageparse.py @@ -0,0 +1,233 @@ +import itertools +import re +import typing + +from .. import helpers, utils +from ..tl import types + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +class MessageParseMethods: + + # region Public properties + + @property + def parse_mode(self: 'TelegramClient'): + """ + This property is the default parse mode used when sending messages. + Defaults to `telethon.extensions.markdown`. It will always + be either `None` or an object with ``parse`` and ``unparse`` + methods. + + When setting a different value it should be one of: + + * Object with ``parse`` and ``unparse`` methods. + * A ``callable`` to act as the parse method. + * A `str` indicating the ``parse_mode``. For Markdown ``'md'`` + or ``'markdown'`` may be used. For HTML, ``'htm'`` or ``'html'`` + may be used. + + The ``parse`` method should be a function accepting a single + parameter, the text to parse, and returning a tuple consisting + of ``(parsed message str, [MessageEntity instances])``. + + The ``unparse`` method should be the inverse of ``parse`` such + that ``assert text == unparse(*parse(text))``. + + See :tl:`MessageEntity` for allowed message entities. + + Example + .. code-block:: python + + # Disabling default formatting + client.parse_mode = None + + # Enabling HTML as the default format + client.parse_mode = 'html' + """ + return self._parse_mode + + @parse_mode.setter + def parse_mode(self: 'TelegramClient', mode: str): + self._parse_mode = utils.sanitize_parse_mode(mode) + + # endregion + + # region Private methods + + async def _replace_with_mention(self: 'TelegramClient', entities, i, user): + """ + Helper method to replace ``entities[i]`` to mention ``user``, + or do nothing if it can't be found. + """ + try: + entities[i] = types.InputMessageEntityMentionName( + entities[i].offset, entities[i].length, + await self.get_input_entity(user) + ) + return True + except (ValueError, TypeError): + return False + + async def _parse_message_text(self: 'TelegramClient', message, parse_mode): + """ + Returns a (parsed message, entities) tuple depending on ``parse_mode``. + """ + if parse_mode == (): + parse_mode = self._parse_mode + else: + parse_mode = utils.sanitize_parse_mode(parse_mode) + + if not parse_mode: + return message, [] + + original_message = message + message, msg_entities = parse_mode.parse(message) + if original_message and not message and not msg_entities: + raise ValueError("Failed to parse message") + + for i in reversed(range(len(msg_entities))): + e = msg_entities[i] + if not e.length: + # 0-length MessageEntity is no longer valid #3884. + # Because the user can provide their own parser (with reasonable 0-length + # entities), strip them here rather than fixing the built-in parsers. + del msg_entities[i] + elif isinstance(e, types.MessageEntityTextUrl): + m = re.match(r'^@|\+|tg://user\?id=(\d+)', e.url) + if m: + user = int(m.group(1)) if m.group(1) else e.url + is_mention = await self._replace_with_mention(msg_entities, i, user) + if not is_mention: + del msg_entities[i] + elif isinstance(e, (types.MessageEntityMentionName, + types.InputMessageEntityMentionName)): + is_mention = await self._replace_with_mention(msg_entities, i, e.user_id) + if not is_mention: + del msg_entities[i] + + return message, msg_entities + + def _get_response_message(self: 'TelegramClient', request, result, input_chat): + """ + Extracts the response message known a request and Update result. + The request may also be the ID of the message to match. + + If ``request is None`` this method returns ``{id: message}``. + + If ``request.random_id`` is a list, this method returns a list too. + """ + if isinstance(result, types.UpdateShort): + updates = [result.update] + entities = {} + elif isinstance(result, (types.Updates, types.UpdatesCombined)): + updates = result.updates + entities = {utils.get_peer_id(x): x + for x in + itertools.chain(result.users, result.chats)} + else: + return None + + random_to_id = {} + id_to_message = {} + for update in updates: + if isinstance(update, types.UpdateMessageID): + random_to_id[update.random_id] = update.id + + elif isinstance(update, ( + types.UpdateNewChannelMessage, types.UpdateNewMessage)): + update.message._finish_init(self, entities, input_chat) + + # Pinning a message with `updatePinnedMessage` seems to + # always produce a service message we can't map so return + # it directly. The same happens for kicking users. + # + # It could also be a list (e.g. when sending albums). + # + # TODO this method is getting messier and messier as time goes on + if hasattr(request, 'random_id') or utils.is_list_like(request): + id_to_message[update.message.id] = update.message + else: + return update.message + + elif (isinstance(update, types.UpdateEditMessage) + and helpers._entity_type(request.peer) != helpers._EntityType.CHANNEL): + update.message._finish_init(self, entities, input_chat) + + # Live locations use `sendMedia` but Telegram responds with + # `updateEditMessage`, which means we won't have `id` field. + if hasattr(request, 'random_id'): + id_to_message[update.message.id] = update.message + elif request.id == update.message.id: + return update.message + + elif (isinstance(update, types.UpdateEditChannelMessage) + and utils.get_peer_id(request.peer) == + utils.get_peer_id(update.message.peer_id)): + if request.id == update.message.id: + update.message._finish_init(self, entities, input_chat) + return update.message + + elif isinstance(update, types.UpdateNewScheduledMessage): + update.message._finish_init(self, entities, input_chat) + # Scheduled IDs may collide with normal IDs. However, for a + # single request there *shouldn't* be a mix between "some + # scheduled and some not". + id_to_message[update.message.id] = update.message + + elif isinstance(update, types.UpdateMessagePoll): + if request.media.poll.id == update.poll_id: + m = types.Message( + id=request.id, + peer_id=utils.get_peer(request.peer), + media=types.MessageMediaPoll( + poll=update.poll, + results=update.results + ) + ) + m._finish_init(self, entities, input_chat) + return m + + if request is None: + return id_to_message + + random_id = request if isinstance(request, (int, list)) else getattr(request, 'random_id', None) + if random_id is None: + # Can happen when pinning a message does not actually produce a service message. + self._log[__name__].warning( + 'No random_id in %s to map to, returning None message for %s', request, result) + return None + + if not utils.is_list_like(random_id): + msg = id_to_message.get(random_to_id.get(random_id)) + + if not msg: + self._log[__name__].warning( + 'Request %s had missing message mapping %s', request, result) + + return msg + + try: + return [id_to_message[random_to_id[rnd]] for rnd in random_id] + except KeyError: + # Sometimes forwards fail (`MESSAGE_ID_INVALID` if a message gets + # deleted or `WORKER_BUSY_TOO_LONG_RETRY` if there are issues at + # Telegram), in which case we get some "missing" message mappings. + # Log them with the hope that we can better work around them. + # + # This also happens when trying to forward messages that can't + # be forwarded because they don't exist (0, service, deleted) + # among others which could be (like deleted or existing). + self._log[__name__].warning( + 'Request %s had missing message mappings %s', request, result) + + return [ + id_to_message.get(random_to_id[rnd]) + if rnd in random_to_id + else None + for rnd in random_id + ] + + # endregion diff --git a/module/scripts/lib/telethon/client/messages.py b/module/scripts/lib/telethon/client/messages.py new file mode 100644 index 0000000..bbcc52a --- /dev/null +++ b/module/scripts/lib/telethon/client/messages.py @@ -0,0 +1,1526 @@ +import inspect +import itertools +import typing +import warnings + +from .. import helpers, utils, errors, hints +from ..requestiter import RequestIter +from ..tl import types, functions + +_MAX_CHUNK_SIZE = 100 + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +class _MessagesIter(RequestIter): + """ + Common factor for all requests that need to iterate over messages. + """ + async def _init( + self, entity, offset_id, min_id, max_id, + from_user, offset_date, add_offset, filter, search, reply_to, + scheduled + ): + # Note that entity being `None` will perform a global search. + if entity: + self.entity = await self.client.get_input_entity(entity) + else: + self.entity = None + if self.reverse: + raise ValueError('Cannot reverse global search') + + # Telegram doesn't like min_id/max_id. If these IDs are low enough + # (starting from last_id - 100), the request will return nothing. + # + # We can emulate their behaviour locally by setting offset = max_id + # and simply stopping once we hit a message with ID <= min_id. + if self.reverse: + offset_id = max(offset_id, min_id) + if offset_id and max_id: + if max_id - offset_id <= 1: + raise StopAsyncIteration + + if not max_id: + max_id = float('inf') + else: + offset_id = max(offset_id, max_id) + if offset_id and min_id: + if offset_id - min_id <= 1: + raise StopAsyncIteration + + if self.reverse: + if offset_id: + offset_id += 1 + elif not offset_date: + # offset_id has priority over offset_date, so don't + # set offset_id to 1 if we want to offset by date. + offset_id = 1 + + if from_user: + from_user = await self.client.get_input_entity(from_user) + self.from_id = await self.client.get_peer_id(from_user) + else: + self.from_id = None + + # `messages.searchGlobal` only works with text `search` or `filter` queries. + # If we want to perform global a search with `from_user` we have to perform + # a normal `messages.search`, *but* we can make the entity be `inputPeerEmpty`. + if not self.entity and from_user: + self.entity = types.InputPeerEmpty() + + if filter is None: + filter = types.InputMessagesFilterEmpty() + else: + filter = filter() if isinstance(filter, type) else filter + + if not self.entity: + self.request = functions.messages.SearchGlobalRequest( + q=search or '', + filter=filter, + min_date=None, + max_date=offset_date, + offset_rate=0, + offset_peer=types.InputPeerEmpty(), + offset_id=offset_id, + limit=1 + ) + elif scheduled: + self.request = functions.messages.GetScheduledHistoryRequest( + peer=entity, + hash=0 + ) + elif reply_to is not None: + self.request = functions.messages.GetRepliesRequest( + peer=self.entity, + msg_id=reply_to, + offset_id=offset_id, + offset_date=offset_date, + add_offset=add_offset, + limit=1, + max_id=0, + min_id=0, + hash=0 + ) + elif search is not None or not isinstance(filter, types.InputMessagesFilterEmpty) or from_user: + # Telegram completely ignores `from_id` in private chats + ty = helpers._entity_type(self.entity) + if ty == helpers._EntityType.USER: + # Don't bother sending `from_user` (it's ignored anyway), + # but keep `from_id` defined above to check it locally. + from_user = None + else: + # Do send `from_user` to do the filtering server-side, + # and set `from_id` to None to avoid checking it locally. + self.from_id = None + + self.request = functions.messages.SearchRequest( + peer=self.entity, + q=search or '', + filter=filter, + min_date=None, + max_date=offset_date, + offset_id=offset_id, + add_offset=add_offset, + limit=0, # Search actually returns 0 items if we ask it to + max_id=0, + min_id=0, + hash=0, + from_id=from_user + ) + + # Workaround issue #1124 until a better solution is found. + # Telegram seemingly ignores `max_date` if `filter` (and + # nothing else) is specified, so we have to rely on doing + # a first request to offset from the ID instead. + # + # Even better, using `filter` and `from_id` seems to always + # trigger `RPC_CALL_FAIL` which is "internal issues"... + if not isinstance(filter, types.InputMessagesFilterEmpty) \ + and offset_date and not search and not offset_id: + async for m in self.client.iter_messages( + self.entity, 1, offset_date=offset_date): + self.request.offset_id = m.id + 1 + else: + self.request = functions.messages.GetHistoryRequest( + peer=self.entity, + limit=1, + offset_date=offset_date, + offset_id=offset_id, + min_id=0, + max_id=0, + add_offset=add_offset, + hash=0 + ) + + if self.limit <= 0: + # No messages, but we still need to know the total message count + result = await self.client(self.request) + if isinstance(result, types.messages.MessagesNotModified): + self.total = result.count + else: + self.total = getattr(result, 'count', len(result.messages)) + raise StopAsyncIteration + + if self.wait_time is None: + self.wait_time = 1 if self.limit > 3000 else 0 + + # When going in reverse we need an offset of `-limit`, but we + # also want to respect what the user passed, so add them together. + if self.reverse and hasattr(self.request, 'add_offset'): + self.request.add_offset -= _MAX_CHUNK_SIZE + + self.add_offset = add_offset + self.max_id = max_id + self.min_id = min_id + self.last_id = 0 if self.reverse else float('inf') + + async def _load_next_chunk(self): + if hasattr(self.request, 'limit'): + self.request.limit = min(self.left, _MAX_CHUNK_SIZE) + if self.reverse and self.request.limit != _MAX_CHUNK_SIZE: + # Remember that we need -limit when going in reverse + self.request.add_offset = self.add_offset - self.request.limit + + r = await self.client(self.request) + self.total = getattr(r, 'count', len(r.messages)) + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + messages = reversed(r.messages) if self.reverse else r.messages + for message in messages: + if (isinstance(message, types.MessageEmpty) + or self.from_id and message.sender_id != self.from_id): + continue + + if not self._message_in_range(message): + return True + + # There has been reports that on bad connections this method + # was returning duplicated IDs sometimes. Using ``last_id`` + # is an attempt to avoid these duplicates, since the message + # IDs are returned in descending order (or asc if reverse). + self.last_id = message.id + message._finish_init(self.client, entities, self.entity) + self.buffer.append(message) + + # Not a slice (using offset would return the same, with e.g. SearchGlobal). + if isinstance(r, types.messages.Messages) or not hasattr(self.request, 'limit'): + return True + + # Some channels are "buggy" and may return less messages than + # requested (apparently, the messages excluded are, for example, + # "not displayable due to local laws"). + # + # This means it's not safe to rely on `len(r.messages) < req.limit` as + # the stop condition. Unfortunately more requests must be made. + # + # However we can still check if the highest ID is equal to or lower + # than the limit, in which case there won't be any more messages + # because the lowest message ID is 1. + # + # We also assume the API will always return, at least, one message if + # there is more to fetch. + if not r.messages or (not self.reverse and r.messages[0].id <= self.request.limit): + return True + + # Get the last message that's not empty (in some rare cases + # it can happen that the last message is :tl:`MessageEmpty`) + if self.buffer: + self._update_offset(self.buffer[-1], r) + else: + # There are some cases where all the messages we get start + # being empty. This can happen on migrated mega-groups if + # the history was cleared, and we're using search. Telegram + # acts incredibly weird sometimes. Messages are returned but + # only "empty", not their contents. If this is the case we + # should just give up since there won't be any new Message. + return True + + def _message_in_range(self, message): + """ + Determine whether the given message is in the range or + it should be ignored (and avoid loading more chunks). + """ + # No entity means message IDs between chats may vary + if self.entity: + if self.reverse: + if message.id <= self.last_id or message.id >= self.max_id: + return False + else: + if message.id >= self.last_id or message.id <= self.min_id: + return False + + return True + + def _update_offset(self, last_message, response): + """ + After making the request, update its offset with the last message. + """ + self.request.offset_id = last_message.id + if self.reverse: + # We want to skip the one we already have + self.request.offset_id += 1 + + if isinstance(self.request, functions.messages.SearchRequest): + # Unlike getHistory and searchGlobal that use *offset* date, + # this is *max* date. This means that doing a search in reverse + # will break it. Since it's not really needed once we're going + # (only for the first request), it's safe to just clear it off. + self.request.max_date = None + else: + # getHistory, searchGlobal and getReplies call it offset_date + self.request.offset_date = last_message.date + + if isinstance(self.request, functions.messages.SearchGlobalRequest): + if last_message.input_chat: + self.request.offset_peer = last_message.input_chat + else: + self.request.offset_peer = types.InputPeerEmpty() + + self.request.offset_rate = getattr(response, 'next_rate', 0) + + +class _IDsIter(RequestIter): + async def _init(self, entity, ids): + self.total = len(ids) + self._ids = list(reversed(ids)) if self.reverse else ids + self._offset = 0 + self._entity = (await self.client.get_input_entity(entity)) if entity else None + self._ty = helpers._entity_type(self._entity) if self._entity else None + + # 30s flood wait every 300 messages (3 requests of 100 each, 30 of 10, etc.) + if self.wait_time is None: + self.wait_time = 10 if self.limit > 300 else 0 + + async def _load_next_chunk(self): + ids = self._ids[self._offset:self._offset + _MAX_CHUNK_SIZE] + if not ids: + raise StopAsyncIteration + + self._offset += _MAX_CHUNK_SIZE + + from_id = None # By default, no need to validate from_id + if self._ty == helpers._EntityType.CHANNEL: + try: + r = await self.client( + functions.channels.GetMessagesRequest(self._entity, ids)) + except errors.MessageIdsEmptyError: + # All IDs were invalid, use a dummy result + r = types.messages.MessagesNotModified(len(ids)) + else: + r = await self.client(functions.messages.GetMessagesRequest(ids)) + if self._entity: + from_id = await self.client._get_peer(self._entity) + + if isinstance(r, types.messages.MessagesNotModified): + self.buffer.extend(None for _ in ids) + return + + entities = {utils.get_peer_id(x): x + for x in itertools.chain(r.users, r.chats)} + + # Telegram seems to return the messages in the order in which + # we asked them for, so we don't need to check it ourselves, + # unless some messages were invalid in which case Telegram + # may decide to not send them at all. + # + # The passed message IDs may not belong to the desired entity + # since the user can enter arbitrary numbers which can belong to + # arbitrary chats. Validate these unless ``from_id is None``. + for message in r.messages: + if isinstance(message, types.MessageEmpty) or ( + from_id and message.peer_id != from_id): + self.buffer.append(None) + else: + message._finish_init(self.client, entities, self._entity) + self.buffer.append(message) + + +class MessageMethods: + + # region Public methods + + # region Message retrieval + + def iter_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + limit: float = None, + *, + offset_date: 'hints.DateLike' = None, + offset_id: int = 0, + max_id: int = 0, + min_id: int = 0, + add_offset: int = 0, + search: str = None, + filter: 'typing.Union[types.TypeMessagesFilter, typing.Type[types.TypeMessagesFilter]]' = None, + from_user: 'hints.EntityLike' = None, + wait_time: float = None, + ids: 'typing.Union[int, typing.Sequence[int]]' = None, + reverse: bool = False, + reply_to: int = None, + scheduled: bool = False + ) -> 'typing.Union[_MessagesIter, _IDsIter]': + """ + Iterator over the messages for the given chat. + + The default order is from newest to oldest, but this + behaviour can be changed with the `reverse` parameter. + + If either `search`, `filter` or `from_user` are provided, + :tl:`messages.Search` will be used instead of :tl:`messages.getHistory`. + + .. note:: + + Telegram's flood wait limit for :tl:`GetHistoryRequest` seems to + be around 30 seconds per 10 requests, therefore a sleep of 1 + second is the default for this limit (or above). + + Arguments + entity (`entity`): + The entity from whom to retrieve the message history. + + It may be `None` to perform a global search, or + to get messages by their ID from no particular chat. + Note that some of the offsets will not work if this + is the case. + + Note that if you want to perform a global search, + you **must** set a non-empty `search` string, a `filter`. + or `from_user`. + + limit (`int` | `None`, optional): + Number of messages to be retrieved. Due to limitations with + the API retrieving more than 3000 messages will take longer + than half a minute (or even more based on previous calls). + + The limit may also be `None`, which would eventually return + the whole history. + + offset_date (`datetime`): + Offset date (messages *previous* to this date will be + retrieved). Exclusive. + + offset_id (`int`): + Offset message ID (only messages *previous* to the given + ID will be retrieved). Exclusive. + + max_id (`int`): + All the messages with a higher (newer) ID or equal to this will + be excluded. + + min_id (`int`): + All the messages with a lower (older) ID or equal to this will + be excluded. + + add_offset (`int`): + Additional message offset (all of the specified offsets + + this offset = older messages). + + search (`str`): + The string to be used as a search query. + + filter (:tl:`MessagesFilter` | `type`): + The filter to use when returning messages. For instance, + :tl:`InputMessagesFilterPhotos` would yield only messages + containing photos. + + from_user (`entity`): + Only messages from this entity will be returned. + + wait_time (`int`): + Wait time (in seconds) between different + :tl:`GetHistoryRequest`. Use this parameter to avoid hitting + the ``FloodWaitError`` as needed. If left to `None`, it will + default to 1 second only if the limit is higher than 3000. + + If the ``ids`` parameter is used, this time will default + to 10 seconds only if the amount of IDs is higher than 300. + + ids (`int`, `list`): + A single integer ID (or several IDs) for the message that + should be returned. This parameter takes precedence over + the rest (which will be ignored if this is set). This can + for instance be used to get the message with ID 123 from + a channel. Note that if the message doesn't exist, `None` + will appear in its place, so that zipping the list of IDs + with the messages can match one-to-one. + + .. note:: + + At the time of writing, Telegram will **not** return + :tl:`MessageEmpty` for :tl:`InputMessageReplyTo` IDs that + failed (i.e. the message is not replying to any, or is + replying to a deleted message). This means that it is + **not** possible to match messages one-by-one, so be + careful if you use non-integers in this parameter. + + reverse (`bool`, optional): + If set to `True`, the messages will be returned in reverse + order (from oldest to newest, instead of the default newest + to oldest). This also means that the meaning of `offset_id` + and `offset_date` parameters is reversed, although they will + still be exclusive. `min_id` becomes equivalent to `offset_id` + instead of being `max_id` as well since messages are returned + in ascending order. + + You cannot use this if both `entity` and `ids` are `None`. + + reply_to (`int`, optional): + If set to a message ID, the messages that reply to this ID + will be returned. This feature is also known as comments in + posts of broadcast channels, or viewing threads in groups. + + This feature can only be used in broadcast channels and their + linked megagroups. Using it in a chat or private conversation + will result in ``telethon.errors.PeerIdInvalidError`` to occur. + + When using this parameter, the ``filter`` and ``search`` + parameters have no effect, since Telegram's API doesn't + support searching messages in replies. + + .. note:: + + This feature is used to get replies to a message in the + *discussion* group. If the same broadcast channel sends + a message and replies to it itself, that reply will not + be included in the results. + + scheduled (`bool`, optional): + If set to `True`, messages which are scheduled will be returned. + All other parameter will be ignored for this, except `entity`. + + Yields + Instances of `Message `. + + Example + .. code-block:: python + + # From most-recent to oldest + async for message in client.iter_messages(chat): + print(message.id, message.text) + + # From oldest to most-recent + async for message in client.iter_messages(chat, reverse=True): + print(message.id, message.text) + + # Filter by sender + async for message in client.iter_messages(chat, from_user='me'): + print(message.text) + + # Server-side search with fuzzy text + async for message in client.iter_messages(chat, search='hello'): + print(message.id) + + # Filter by message type: + from telethon.tl.types import InputMessagesFilterPhotos + async for message in client.iter_messages(chat, filter=InputMessagesFilterPhotos): + print(message.photo) + + # Getting comments from a post in a channel: + async for message in client.iter_messages(channel, reply_to=123): + print(message.chat.title, message.text) + """ + if ids is not None: + if not utils.is_list_like(ids): + ids = [ids] + + return _IDsIter( + client=self, + reverse=reverse, + wait_time=wait_time, + limit=len(ids), + entity=entity, + ids=ids + ) + + return _MessagesIter( + client=self, + reverse=reverse, + wait_time=wait_time, + limit=limit, + entity=entity, + offset_id=offset_id, + min_id=min_id, + max_id=max_id, + from_user=from_user, + offset_date=offset_date, + add_offset=add_offset, + filter=filter, + search=search, + reply_to=reply_to, + scheduled=scheduled + ) + + async def get_messages( + self: 'TelegramClient', *args, **kwargs + ) -> typing.Union['hints.TotalList', typing.Optional['types.Message']]: + """ + Same as `iter_messages()`, but returns a + `TotalList ` instead. + + If the `limit` is not set, it will be 1 by default unless both + `min_id` **and** `max_id` are set (as *named* arguments), in + which case the entire range will be returned. + + This is so because any integer limit would be rather arbitrary and + it's common to only want to fetch one message, but if a range is + specified it makes sense that it should return the entirety of it. + + If `ids` is present in the *named* arguments and is not a list, + a single `Message ` will be + returned for convenience instead of a list. + + Example + .. code-block:: python + + # Get 0 photos and print the total to show how many photos there are + from telethon.tl.types import InputMessagesFilterPhotos + photos = await client.get_messages(chat, 0, filter=InputMessagesFilterPhotos) + print(photos.total) + + # Get all the photos + photos = await client.get_messages(chat, None, filter=InputMessagesFilterPhotos) + + # Get messages by ID: + message_1337 = await client.get_messages(chat, ids=1337) + """ + if len(args) == 1 and 'limit' not in kwargs: + if 'min_id' in kwargs and 'max_id' in kwargs: + kwargs['limit'] = None + else: + kwargs['limit'] = 1 + + it = self.iter_messages(*args, **kwargs) + + ids = kwargs.get('ids') + if ids and not utils.is_list_like(ids): + async for message in it: + return message + else: + # Iterator exhausted = empty, to handle InputMessageReplyTo + return None + + return await it.collect() + + get_messages.__signature__ = inspect.signature(iter_messages) + + # endregion + + # region Message sending/editing/deleting + + async def _get_comment_data( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[int, types.Message]' + ): + r = await self(functions.messages.GetDiscussionMessageRequest( + peer=entity, + msg_id=utils.get_message_id(message) + )) + m = min(r.messages, key=lambda msg: msg.id) + chat = next(c for c in r.chats if c.id == m.peer_id.channel_id) + return utils.get_input_peer(chat), m.id + + async def send_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'hints.MessageLike' = '', + *, + reply_to: 'typing.Union[int, types.Message]' = None, + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + parse_mode: typing.Optional[str] = (), + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + clear_draft: bool = False, + buttons: typing.Optional['hints.MarkupLike'] = None, + silent: bool = None, + background: bool = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, types.Message]' = None, + nosound_video: bool = None, + send_as: typing.Optional['hints.EntityLike'] = None, + message_effect_id: typing.Optional[int] = None + ) -> 'types.Message': + """ + Sends a message to the specified user, chat or channel. + + The default parse mode is the same as the official applications + (a custom flavour of markdown). ``**bold**, `code` or __italic__`` + are available. In addition you can send ``[links](https://example.com)`` + and ``[mentions](@username)`` (or using IDs like in the Bot API: + ``[mention](tg://user?id=123456789)``) and ``pre`` blocks with three + backticks. + + Sending a ``/start`` command with a parameter (like ``?start=data``) + is also done through this method. Simply send ``'/start data'`` to + the bot. + + See also `Message.respond() ` + and `Message.reply() `. + + Arguments + entity (`entity`): + To who will it be sent. + + message (`str` | `Message `): + The message to be sent, or another message object to resend. + + The maximum length for a message is 35,000 bytes or 4,096 + characters. Longer messages will not be sliced automatically, + and you should slice them manually if the text to send is + longer than said length. + + reply_to (`int` | `Message `, optional): + Whether to reply to a message or not. If an integer is provided, + it should be the ID of the message that it should reply to. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + link_preview (`bool`, optional): + Should the link preview be shown? + + file (`file`, optional): + Sends a message with a file attached (e.g. a photo, + video, audio or document). The ``message`` may be empty. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributeVideo` or by installing the + optional ``hachoir`` dependency. + + force_document (`bool`, optional): + Whether to send the given file as a document or not. + + clear_draft (`bool`, optional): + Whether the existing draft should be cleared or not. + + buttons (`list`, `custom.Button `, :tl:`KeyboardButton`, :tl:`KeyboardInlineButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + All the following limits apply together: + + * There can be 100 buttons at most (any more are ignored). + * There can be 8 buttons per row at most (more are ignored). + * The maximum callback data per button is 64 bytes. + * The maximum data that can be embedded in total is just + over 4KB, shared between inline callback data and text. + + silent (`bool`, optional): + Whether the message should notify people in a broadcast + channel or not. Defaults to `False`, which means it will + notify them. Set it to `True` to alter this behaviour. + + background (`bool`, optional): + Whether the message should be send in background. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the message won't send immediately, and instead + it will be scheduled to be automatically sent at a later + time. + + comment_to (`int` | `Message `, optional): + Similar to ``reply_to``, but replies in the linked group of a + broadcast channel instead (effectively leaving a "comment to" + the specified message). + + This parameter takes precedence over ``reply_to``. If there is + no linked chat, `telethon.errors.sgIdInvalidError` is raised. + + nosound_video (`bool`, optional): + Only applicable when sending a video file without an audio + track. If set to ``True``, the video will be displayed in + Telegram as a video. If set to ``False``, Telegram will attempt + to display the video as an animated gif. (It may still display + as a video due to other factors.) The value is ignored if set + on non-video files. This is set to ``True`` for albums, as gifs + cannot be sent in albums. + + send_as (`entity`): + Unique identifier (int) or username (str) of the chat or channel to send the message as. + You can use this to send the message on behalf of a chat or channel where you have appropriate permissions. + Use the GetSendAs to return the list of message sender identifiers, which can be used to send messages in the chat, + This setting applies to the current message and will remain effective for future messages unless explicitly changed. + To set this behavior permanently for all messages, use SaveDefaultSendAs. + + message_effect_id (`int`, optional): + Unique identifier of the message effect to be added to the message; for private chats only + + Returns + The sent `custom.Message `. + + Example + .. code-block:: python + + # Markdown is the default + await client.send_message('me', 'Hello **world**!') + + # Default to another parse mode + client.parse_mode = 'html' + + await client.send_message('me', 'Some bold and italic text') + await client.send_message('me', 'An URL') + # code and pre tags also work, but those break the documentation :) + await client.send_message('me', 'Mentions') + + # Explicit parse mode + # No parse mode by default + client.parse_mode = None + + # ...but here I want markdown + await client.send_message('me', 'Hello, **world**!', parse_mode='md') + + # ...and here I need HTML + await client.send_message('me', 'Hello, world!', parse_mode='html') + + # If you logged in as a bot account, you can send buttons + from telethon import events, Button + + @client.on(events.CallbackQuery) + async def callback(event): + await event.edit('Thank you for clicking {}!'.format(event.data)) + + # Single inline button + await client.send_message(chat, 'A single button, with "clk1" as data', + buttons=Button.inline('Click me', b'clk1')) + + # Matrix of inline buttons + await client.send_message(chat, 'Pick one from this grid', buttons=[ + [Button.inline('Left'), Button.inline('Right')], + [Button.url('Check this site!', 'https://example.com')] + ]) + + # Reply keyboard + await client.send_message(chat, 'Welcome', buttons=[ + Button.text('Thanks!', resize=True, single_use=True), + Button.request_phone('Send phone'), + Button.request_location('Send location') + ]) + + # Forcing replies or clearing buttons. + await client.send_message(chat, 'Reply to me', buttons=Button.force_reply()) + await client.send_message(chat, 'Bye Keyboard!', buttons=Button.clear()) + + # Scheduling a message to be sent after 5 minutes + from datetime import timedelta + await client.send_message(chat, 'Hi, future!', schedule=timedelta(minutes=5)) + """ + if file is not None: + if isinstance(message, types.Message): + formatting_entities = formatting_entities or message.entities + message = message.message + return await self.send_file( + entity, file, caption=message, reply_to=reply_to, + attributes=attributes, parse_mode=parse_mode, + force_document=force_document, thumb=thumb, + buttons=buttons, clear_draft=clear_draft, silent=silent, + schedule=schedule, supports_streaming=supports_streaming, + formatting_entities=formatting_entities, + comment_to=comment_to, background=background, + nosound_video=nosound_video, + send_as=send_as, message_effect_id=message_effect_id + ) + + entity = await self.get_input_entity(entity) + if comment_to is not None: + entity, reply_to = await self._get_comment_data(entity, comment_to) + else: + reply_to = utils.get_message_id(reply_to) + + if isinstance(message, types.Message): + if buttons is None: + markup = message.reply_markup + else: + markup = self.build_reply_markup(buttons) + + if silent is None: + silent = message.silent + + if (message.media and not isinstance( + message.media, types.MessageMediaWebPage)): + return await self.send_file( + entity, + message.media, + caption=message.message, + silent=silent, + background=background, + reply_to=reply_to, + buttons=markup, + formatting_entities=message.entities, + parse_mode=None, # explicitly disable parse_mode to force using even empty formatting_entities + schedule=schedule, + send_as=send_as, message_effect_id=message_effect_id + ) + + request = functions.messages.SendMessageRequest( + peer=entity, + message=message.message or '', + silent=silent, + background=background, + reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to), + reply_markup=markup, + entities=message.entities, + clear_draft=clear_draft, + no_webpage=not isinstance( + message.media, types.MessageMediaWebPage), + schedule_date=schedule, + send_as=await self.get_input_entity(send_as) if send_as else None, + effect=message_effect_id + ) + message = message.message + else: + if formatting_entities is None: + message, formatting_entities = await self._parse_message_text(message, parse_mode) + if not message: + raise ValueError( + 'The message cannot be empty unless a file is provided' + ) + + request = functions.messages.SendMessageRequest( + peer=entity, + message=message, + entities=formatting_entities, + no_webpage=not link_preview, + reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to), + clear_draft=clear_draft, + silent=silent, + background=background, + reply_markup=self.build_reply_markup(buttons), + schedule_date=schedule, + send_as=await self.get_input_entity(send_as) if send_as else None, + effect=message_effect_id + ) + + result = await self(request) + if isinstance(result, types.UpdateShortSentMessage): + message = types.Message( + id=result.id, + peer_id=await self._get_peer(entity), + message=message, + date=result.date, + out=result.out, + media=result.media, + entities=result.entities, + reply_markup=request.reply_markup, + ttl_period=result.ttl_period, + reply_to=request.reply_to + ) + message._finish_init(self, {}, entity) + return message + + return self._get_response_message(request, result, entity) + + async def forward_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + messages: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + from_peer: 'hints.EntityLike' = None, + *, + background: bool = None, + with_my_score: bool = None, + silent: bool = None, + as_album: bool = None, + schedule: 'hints.DateLike' = None, + drop_author: bool = None, + drop_media_captions: bool = None, + ) -> 'typing.Sequence[types.Message]': + """ + Forwards the given messages to the specified entity. + + If you want to "forward" a message without the forward header + (the "forwarded from" text), you should use `send_message` with + the original message instead. This will send a copy of it. + + See also `Message.forward_to() `. + + Arguments + entity (`entity`): + To which entity the message(s) will be forwarded. + + messages (`list` | `int` | `Message `): + The message(s) to forward, or their integer IDs. + + from_peer (`entity`): + If the given messages are integer IDs and not instances + of the ``Message`` class, this *must* be specified in + order for the forward to work. This parameter indicates + the entity from which the messages should be forwarded. + + silent (`bool`, optional): + Whether the message should notify people with sound or not. + Defaults to `False` (send with a notification sound unless + the person has the chat muted). Set it to `True` to alter + this behaviour. + + background (`bool`, optional): + Whether the message should be forwarded in background. + + with_my_score (`bool`, optional): + Whether forwarded should contain your game score. + + as_album (`bool`, optional): + This flag no longer has any effect. + + schedule (`hints.DateLike`, optional): + If set, the message(s) won't forward immediately, and + instead they will be scheduled to be automatically sent + at a later time. + + drop_author (`bool`, optional): + Whether to forward messages without quoting the original author. + + drop_media_captions (`bool`, optional): + Whether to strip captions from media. Setting this to `True` requires that `drop_author` also be set to `True`. + + Returns + The list of forwarded `Message `, + or a single one if a list wasn't provided as input. + + Note that if all messages are invalid (i.e. deleted) the call + will fail with ``MessageIdInvalidError``. If only some are + invalid, the list will have `None` instead of those messages. + + Example + .. code-block:: python + + # a single one + await client.forward_messages(chat, message) + # or + await client.forward_messages(chat, message_id, from_chat) + # or + await message.forward_to(chat) + + # multiple + await client.forward_messages(chat, messages) + # or + await client.forward_messages(chat, message_ids, from_chat) + + # Forwarding as a copy + await client.send_message(chat, message) + """ + if as_album is not None: + warnings.warn('the as_album argument is deprecated and no longer has any effect') + + single = not utils.is_list_like(messages) + if single: + messages = (messages,) + + entity = await self.get_input_entity(entity) + + if from_peer: + from_peer = await self.get_input_entity(from_peer) + from_peer_id = await self.get_peer_id(from_peer) + else: + from_peer_id = None + + def get_key(m): + if isinstance(m, int): + if from_peer_id is not None: + return from_peer_id + + raise ValueError('from_peer must be given if integer IDs are used') + elif isinstance(m, types.Message): + return m.chat_id + else: + raise TypeError('Cannot forward messages of type {}'.format(type(m))) + + sent = [] + for _chat_id, chunk in itertools.groupby(messages, key=get_key): + chunk = list(chunk) + if isinstance(chunk[0], int): + chat = from_peer + else: + chat = from_peer or await self.get_input_entity(chunk[0].peer_id) + chunk = [m.id for m in chunk] + + req = functions.messages.ForwardMessagesRequest( + from_peer=chat, + id=chunk, + to_peer=entity, + silent=silent, + background=background, + with_my_score=with_my_score, + schedule_date=schedule, + drop_author=drop_author, + drop_media_captions=drop_media_captions + ) + result = await self(req) + sent.extend(self._get_response_message(req, result, entity)) + + return sent[0] if single else sent + + async def edit_message( + self: 'TelegramClient', + entity: 'typing.Union[hints.EntityLike, types.Message]', + message: 'typing.Union[int, types.Message, types.InputMessageID, str]' = None, + text: str = None, + *, + parse_mode: typing.Optional[str] = (), + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + formatting_entities: typing.Optional[typing.List[types.TypeMessageEntity]] = None, + link_preview: bool = True, + file: 'hints.FileLike' = None, + thumb: 'hints.FileLike' = None, + force_document: bool = False, + buttons: typing.Optional['hints.MarkupLike'] = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None + ) -> 'types.Message': + """ + Edits the given message to change its text or media. + + See also `Message.edit() `. + + Arguments + entity (`entity` | `Message `): + From which chat to edit the message. This can also be + the message to be edited, and the entity will be inferred + from it, so the next parameter will be assumed to be the + message text. + + You may also pass a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64`, + which is the only way to edit messages that were sent + after the user selects an inline query result. + + message (`int` | `Message ` | :tl:`InputMessageID` | `str`): + The ID of the message (or `Message + ` itself) to be edited. + If the `entity` was a `Message + `, then this message + will be treated as the new text. + + text (`str`, optional): + The new text of the message. Does nothing if the `entity` + was a `Message `. + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + formatting_entities (`list`, optional): + A list of message formatting entities. When provided, the ``parse_mode`` is ignored. + + link_preview (`bool`, optional): + Should the link preview be shown? + + file (`str` | `bytes` | `file` | `media`, optional): + The file object that should replace the existing media + in the message. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributeVideo` or by installing the + optional ``hachoir`` dependency. + + force_document (`bool`, optional): + Whether to send the given file as a document or not. + + buttons (`list`, `custom.Button `, :tl:`KeyboardButton`, :tl:`KeyboardInlineButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the message won't be edited immediately, and instead + it will be scheduled to be automatically edited at a later + time. + + Note that this parameter will have no effect if you are + trying to edit a message that was sent via inline bots. + + Returns + The edited `Message `, + unless `entity` was a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64` in which + case this method returns a boolean. + + Raises + ``MessageAuthorRequiredError`` if you're not the author of the + message but tried editing it anyway. + + ``MessageNotModifiedError`` if the contents of the message were + not modified at all. + + ``MessageIdInvalidError`` if the ID of the message is invalid + (the ID itself may be correct, but the message with that ID + cannot be edited). For example, when trying to edit messages + with a reply markup (or clear markup) this error will be raised. + + Example + .. code-block:: python + + message = await client.send_message(chat, 'hello') + + await client.edit_message(chat, message, 'hello!') + # or + await client.edit_message(chat, message.id, 'hello!!') + # or + await client.edit_message(message, 'hello!!!') + """ + if isinstance(entity, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)): + text = text or message + message = entity + elif isinstance(entity, types.Message): + text = message # Shift the parameters to the right + message = entity + entity = entity.peer_id + + if formatting_entities is None: + text, formatting_entities = await self._parse_message_text(text, parse_mode) + file_handle, media, image = await self._file_to_media(file, + supports_streaming=supports_streaming, + thumb=thumb, + attributes=attributes, + force_document=force_document) + + if isinstance(entity, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)): + request = functions.messages.EditInlineBotMessageRequest( + id=entity, + message=text, + no_webpage=not link_preview, + entities=formatting_entities, + media=media, + reply_markup=self.build_reply_markup(buttons) + ) + # Invoke `messages.editInlineBotMessage` from the right datacenter. + # Otherwise, Telegram will error with `MESSAGE_ID_INVALID` and do nothing. + exported = self.session.dc_id != entity.dc_id + if exported: + try: + sender = await self._borrow_exported_sender(entity.dc_id) + return await self._call(sender, request) + finally: + await self._return_exported_sender(sender) + else: + return await self(request) + + entity = await self.get_input_entity(entity) + request = functions.messages.EditMessageRequest( + peer=entity, + id=utils.get_message_id(message), + message=text, + no_webpage=not link_preview, + entities=formatting_entities, + media=media, + reply_markup=self.build_reply_markup(buttons), + schedule_date=schedule + ) + msg = self._get_response_message(request, await self(request), entity) + return msg + + async def delete_messages( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message_ids: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]', + *, + revoke: bool = True) -> 'typing.Sequence[types.messages.AffectedMessages]': + """ + Deletes the given messages, optionally "for everyone". + + See also `Message.delete() `. + + .. warning:: + + This method does **not** validate that the message IDs belong + to the chat that you passed! It's possible for the method to + delete messages from different private chats and small group + chats at once, so make sure to pass the right IDs. + + Arguments + entity (`entity`): + From who the message will be deleted. This can actually + be `None` for normal chats, but **must** be present + for channels and megagroups. + + message_ids (`list` | `int` | `Message `): + The IDs (or ID) or messages to be deleted. + + revoke (`bool`, optional): + Whether the message should be deleted for everyone or not. + By default it has the opposite behaviour of official clients, + and it will delete the message for everyone. + + `Since 24 March 2019 + `_, you can + also revoke messages of any age (i.e. messages sent long in + the past) the *other* person sent in private conversations + (and of course your messages too). + + Disabling this has no effect on channels or megagroups, + since it will unconditionally delete the message for everyone. + + Returns + A list of :tl:`AffectedMessages`, each item being the result + for the delete calls of the messages in chunks of 100 each. + + Example + .. code-block:: python + + await client.delete_messages(chat, messages) + """ + if not utils.is_list_like(message_ids): + message_ids = (message_ids,) + + message_ids = ( + m.id if isinstance(m, ( + types.Message, types.MessageService, types.MessageEmpty)) + else int(m) for m in message_ids + ) + + if entity: + entity = await self.get_input_entity(entity) + ty = helpers._entity_type(entity) + else: + # no entity (None), set a value that's not a channel for private delete + ty = helpers._EntityType.USER + + if ty == helpers._EntityType.CHANNEL: + return await self([functions.channels.DeleteMessagesRequest( + entity, list(c)) for c in utils.chunks(message_ids)]) + else: + return await self([functions.messages.DeleteMessagesRequest( + list(c), revoke) for c in utils.chunks(message_ids)]) + + # endregion + + # region Miscellaneous + + async def send_read_acknowledge( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Union[hints.MessageIDLike, typing.Sequence[hints.MessageIDLike]]' = None, + *, + max_id: int = None, + clear_mentions: bool = False, + clear_reactions: bool = False) -> bool: + """ + Marks messages as read and optionally clears mentions. + + This effectively marks a message as read (or more than one) in the + given conversation. + + If neither message nor maximum ID are provided, all messages will be + marked as read by assuming that ``max_id = 0``. + + If a message or maximum ID is provided, all the messages up to and + including such ID will be marked as read (for all messages whose ID + ≤ max_id). + + See also `Message.mark_read() `. + + Arguments + entity (`entity`): + The chat where these messages are located. + + message (`list` | `Message `): + Either a list of messages or a single message. + + max_id (`int`): + Until which message should the read acknowledge be sent for. + This has priority over the ``message`` parameter. + + clear_mentions (`bool`): + Whether the mention badge should be cleared (so that + there are no more mentions) or not for the given entity. + + If no message is provided, this will be the only action + taken. + + clear_reactions (`bool`): + Whether the reactions badge should be cleared (so that + there are no more reaction notifications) or not for the given entity. + + If no message is provided, this will be the only action + taken. + + Example + .. code-block:: python + + # using a Message object + await client.send_read_acknowledge(chat, message) + # ...or using the int ID of a Message + await client.send_read_acknowledge(chat, message_id) + # ...or passing a list of messages to mark as read + await client.send_read_acknowledge(chat, messages) + """ + if max_id is None: + if not message: + max_id = 0 + else: + if utils.is_list_like(message): + max_id = max(msg.id for msg in message) + else: + max_id = message.id + + entity = await self.get_input_entity(entity) + if clear_mentions: + await self(functions.messages.ReadMentionsRequest(entity)) + if max_id is None and not clear_reactions: + return True + if clear_reactions: + await self(functions.messages.ReadReactionsRequest(entity)) + if max_id is None: + return True + + if max_id is not None: + if helpers._entity_type(entity) == helpers._EntityType.CHANNEL: + return await self(functions.channels.ReadHistoryRequest( + utils.get_input_channel(entity), max_id=max_id)) + else: + return await self(functions.messages.ReadHistoryRequest( + entity, max_id=max_id)) + + return False + + async def pin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]', + *, + notify: bool = False, + pm_oneside: bool = False + ): + """ + Pins a message in a chat. + + The default behaviour is to *not* notify members, unlike the + official applications. + + See also `Message.pin() `. + + Arguments + entity (`entity`): + The chat where the message should be pinned. + + message (`int` | `Message `): + The message or the message ID to pin. If it's + `None`, all messages will be unpinned instead. + + notify (`bool`, optional): + Whether the pin should notify people or not. + + pm_oneside (`bool`, optional): + Whether the message should be pinned for everyone or not. + By default it has the opposite behaviour of official clients, + and it will pin the message for both sides, in private chats. + + Example + .. code-block:: python + + # Send and pin a message to annoy everyone + message = await client.send_message(chat, 'Pinotifying is fun!') + await client.pin_message(chat, message, notify=True) + """ + return await self._pin(entity, message, unpin=False, notify=notify, pm_oneside=pm_oneside) + + async def unpin_message( + self: 'TelegramClient', + entity: 'hints.EntityLike', + message: 'typing.Optional[hints.MessageIDLike]' = None, + *, + notify: bool = False + ): + """ + Unpins a message in a chat. + + If no message ID is specified, all pinned messages will be unpinned. + + See also `Message.unpin() `. + + Arguments + entity (`entity`): + The chat where the message should be pinned. + + message (`int` | `Message `): + The message or the message ID to unpin. If it's + `None`, all messages will be unpinned instead. + + Example + .. code-block:: python + + # Unpin all messages from a chat + await client.unpin_message(chat) + """ + return await self._pin(entity, message, unpin=True, notify=notify) + + async def _pin(self, entity, message, *, unpin, notify=False, pm_oneside=False): + message = utils.get_message_id(message) or 0 + entity = await self.get_input_entity(entity) + if message <= 0: # old behaviour accepted negative IDs to unpin + await self(functions.messages.UnpinAllMessagesRequest(entity)) + return + + request = functions.messages.UpdatePinnedMessageRequest( + peer=entity, + id=message, + silent=not notify, + unpin=unpin, + pm_oneside=pm_oneside + ) + result = await self(request) + + # Unpinning does not produce a service message. + # Pinning a message that was already pinned also produces no service message. + # Pinning a message in your own chat does not produce a service message, + # but pinning on a private conversation with someone else does. + if unpin or not result.updates: + return + + # Pinning a message that doesn't exist would RPC-error earlier + return self._get_response_message(request, result, entity) + + # endregion + + # endregion diff --git a/module/scripts/lib/telethon/client/telegrambaseclient.py b/module/scripts/lib/telethon/client/telegrambaseclient.py new file mode 100644 index 0000000..0f8cf5d --- /dev/null +++ b/module/scripts/lib/telethon/client/telegrambaseclient.py @@ -0,0 +1,979 @@ +import abc +import inspect +import re +import asyncio +import collections +import logging +import platform +import time +import typing +import datetime +import pathlib +import warnings + +from .. import utils, version, helpers, __name__ as __base_name__ +from ..crypto import rsa +from ..extensions import markdown +from ..network import MTProtoSender, Connection, ConnectionTcpFull, TcpMTProxy +from ..sessions import Session, SQLiteSession, MemorySession +from ..tl import functions, types +from ..tl.alltlobjects import LAYER +from .._updates import MessageBox, EntityCache as MbEntityCache, SessionState, ChannelState, Entity, EntityType + +try: + import python_socks +except ImportError: + python_socks = None + +DEFAULT_DC_ID = 2 +DEFAULT_IPV4_IP = '149.154.167.51' +DEFAULT_IPV6_IP = '2001:67c:4e8:f002::a' +DEFAULT_PORT = 443 + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + +_base_log = logging.getLogger(__base_name__) + + +# In seconds, how long to wait before disconnecting a exported sender. +_DISCONNECT_EXPORTED_AFTER = 60 + + +class _ExportState: + def __init__(self): + # ``n`` is the amount of borrows a given sender has; + # once ``n`` reaches ``0``, disconnect the sender after a while. + self._n = 0 + self._zero_ts = 0 + self._connected = False + + def add_borrow(self): + self._n += 1 + self._connected = True + + def add_return(self): + self._n -= 1 + assert self._n >= 0, 'returned sender more than it was borrowed' + if self._n == 0: + self._zero_ts = time.time() + + def should_disconnect(self): + return (self._n == 0 + and self._connected + and (time.time() - self._zero_ts) > _DISCONNECT_EXPORTED_AFTER) + + def need_connect(self): + return not self._connected + + def mark_disconnected(self): + assert self.should_disconnect(), 'marked as disconnected when it was borrowed' + self._connected = False + + +# TODO How hard would it be to support both `trio` and `asyncio`? +class TelegramBaseClient(abc.ABC): + """ + This is the abstract base class for the client. It defines some + basic stuff like connecting, switching data center, etc, and + leaves the `__call__` unimplemented. + + Arguments + session (`str` | `telethon.sessions.abstract.Session`, `None`): + The file name of the session file to be used if a string is + given (it may be a full path), or the Session instance to be + used otherwise. If it's `None`, the session will not be saved, + and you should call :meth:`.log_out()` when you're done. + + Note that if you pass a string it will be a file in the current + working directory, although you can also pass absolute paths. + + The session file contains enough information for you to login + without re-sending the code, so if you have to enter the code + more than once, maybe you're changing the working directory, + renaming or removing the file, or using random names. + + api_id (`int` | `str`): + The API ID you obtained from https://my.telegram.org. + + api_hash (`str`): + The API hash you obtained from https://my.telegram.org. + + connection (`telethon.network.connection.common.Connection`, optional): + The connection instance to be used when creating a new connection + to the servers. It **must** be a type. + + Defaults to `telethon.network.connection.tcpfull.ConnectionTcpFull`. + + use_ipv6 (`bool`, optional): + Whether to connect to the servers through IPv6 or not. + By default this is `False` as IPv6 support is not + too widespread yet. + + proxy (`dict`, optional): + An iterable consisting of the proxy info. If `connection` is + one of `MTProxy`, then it should contain MTProxy credentials: + ``hostname, port, secret``. Otherwise, it's meant to store + function parameters for PySocks, like ``type, hostname, port``. + See https://github.com/romis2012/python-socks for more. + + local_addr (`str` | `tuple`, optional): + Local host address (and port, optionally) used to bind the socket to locally. + You only need to use this if you have multiple network cards and + want to use a specific one. + + timeout (`int` | `float`, optional): + The timeout in seconds to be used when connecting. + This is **not** the timeout to be used when ``await``'ing for + invoked requests, and you should use ``asyncio.wait`` or + ``asyncio.wait_for`` for that. + + request_retries (`int` | `None`, optional): + How many times a request should be retried. Request are retried + when Telegram is having internal issues (due to either + ``errors.ServerError`` or ``errors.RpcCallFailError``), + when there is a ``errors.FloodWaitError`` less than + `flood_sleep_threshold`, or when there's a migrate error. + + May take a negative or `None` value for infinite retries, but + this is not recommended, since some requests can always trigger + a call fail (such as searching for messages). + + connection_retries (`int` | `None`, optional): + How many times the reconnection should retry, either on the + initial connection or when Telegram disconnects us. May be + set to a negative or `None` value for infinite retries, but + this is not recommended, since the program can get stuck in an + infinite loop. + + retry_delay (`int` | `float`, optional): + The delay in seconds to sleep between automatic reconnections. + + auto_reconnect (`bool`, optional): + Whether reconnection should be retried `connection_retries` + times automatically if Telegram disconnects us or not. + + sequential_updates (`bool`, optional): + By default every incoming update will create a new task, so + you can handle several updates in parallel. Some scripts need + the order in which updates are processed to be sequential, and + this setting allows them to do so. + + If set to `True`, incoming updates will be put in a queue + and processed sequentially. This means your event handlers + should *not* perform long-running operations since new + updates are put inside of an unbounded queue. + + flood_sleep_threshold (`int` | `float`, optional): + The threshold below which the library should automatically + sleep on flood wait and slow mode wait errors (inclusive). For instance, if a + ``FloodWaitError`` for 17s occurs and `flood_sleep_threshold` + is 20s, the library will ``sleep`` automatically. If the error + was for 21s, it would ``raise FloodWaitError`` instead. Values + larger than a day (like ``float('inf')``) will be changed to a day. + + raise_last_call_error (`bool`, optional): + When API calls fail in a way that causes Telethon to retry + automatically, should the RPC error of the last attempt be raised + instead of a generic ValueError. This is mostly useful for + detecting when Telegram has internal issues. + + device_model (`str`, optional): + "Device model" to be sent when creating the initial connection. + Defaults to 'PC (n)bit' derived from ``platform.uname().machine``, or its direct value if unknown. + + system_version (`str`, optional): + "System version" to be sent when creating the initial connection. + Defaults to ``platform.uname().release`` stripped of everything ahead of -. + + app_version (`str`, optional): + "App version" to be sent when creating the initial connection. + Defaults to `telethon.version.__version__`. + + lang_code (`str`, optional): + "Language code" to be sent when creating the initial connection. + Defaults to ``'en'``. + + system_lang_code (`str`, optional): + "System lang code" to be sent when creating the initial connection. + Defaults to `lang_code`. + + loop (`asyncio.AbstractEventLoop`, optional): + Asyncio event loop to use. Defaults to `asyncio.get_running_loop()`. + This argument is ignored. + + base_logger (`str` | `logging.Logger`, optional): + Base logger name or instance to use. + If a `str` is given, it'll be passed to `logging.getLogger()`. If a + `logging.Logger` is given, it'll be used directly. If something + else or nothing is given, the default logger will be used. + + receive_updates (`bool`, optional): + Whether the client will receive updates or not. By default, updates + will be received from Telegram as they occur. + + Turning this off means that Telegram will not send updates at all + so event handlers, conversations, and QR login will not work. + However, certain scripts don't need updates, so this will reduce + the amount of bandwidth used. + + entity_cache_limit (`int`, optional): + How many users, chats and channels to keep in the in-memory cache + at most. This limit is checked against when processing updates. + + When this limit is reached or exceeded, all entities that are not + required for update handling will be flushed to the session file. + + Note that this implies that there is a lower bound to the amount + of entities that must be kept in memory. + + Setting this limit too low will cause the library to attempt to + flush entities to the session file even if no entities can be + removed from the in-memory cache, which will degrade performance. + """ + + # Current TelegramClient version + __version__ = version.__version__ + + # Cached server configuration (with .dc_options), can be "global" + _config = None + _cdn_config = None + + # region Initialization + + def __init__( + self: 'TelegramClient', + session: 'typing.Union[str, pathlib.Path, Session]', + api_id: int, + api_hash: str, + *, + connection: 'typing.Type[Connection]' = ConnectionTcpFull, + use_ipv6: bool = False, + proxy: typing.Union[tuple, dict] = None, + local_addr: typing.Union[str, tuple] = None, + timeout: int = 10, + request_retries: int = 5, + connection_retries: int = 5, + retry_delay: int = 1, + auto_reconnect: bool = True, + sequential_updates: bool = False, + flood_sleep_threshold: int = 60, + raise_last_call_error: bool = False, + device_model: str = None, + system_version: str = None, + app_version: str = None, + lang_code: str = 'en', + system_lang_code: str = 'en', + loop: asyncio.AbstractEventLoop = None, + base_logger: typing.Union[str, logging.Logger] = None, + receive_updates: bool = True, + catch_up: bool = False, + entity_cache_limit: int = 5000 + ): + if not api_id or not api_hash: + raise ValueError( + "Your API ID or Hash cannot be empty or None. " + "Refer to telethon.rtfd.io for more information.") + + self._use_ipv6 = use_ipv6 + + if isinstance(base_logger, str): + base_logger = logging.getLogger(base_logger) + elif not isinstance(base_logger, logging.Logger): + base_logger = _base_log + + class _Loggers(dict): + def __missing__(self, key): + if key.startswith("telethon."): + key = key.split('.', maxsplit=1)[1] + + return base_logger.getChild(key) + + self._log = _Loggers() + + # Determine what session object we have + if isinstance(session, (str, pathlib.Path)): + try: + session = SQLiteSession(str(session)) + except ImportError: + warnings.warn( + 'The sqlite3 module is not available under this ' + 'Python installation and no custom session ' + 'instance was given; using MemorySession.\n' + 'You will need to re-login every time unless ' + 'you use another session storage' + ) + session = MemorySession() + elif session is None: + session = MemorySession() + elif not isinstance(session, Session): + raise TypeError( + 'The given session must be a str or a Session instance.' + ) + + self.flood_sleep_threshold = flood_sleep_threshold + + # TODO Use AsyncClassWrapper(session) + # ChatGetter and SenderGetter can use the in-memory _mb_entity_cache + # to avoid network access and the need for await in session files. + # + # The session files only wants the entities to persist + # them to disk, and to save additional useful information. + # TODO Session should probably return all cached + # info of entities, not just the input versions + self.session = session + self.api_id = int(api_id) + self.api_hash = api_hash + + if local_addr is not None: + if use_ipv6 is False and ':' in local_addr: + raise TypeError( + 'A local IPv6 address must only be used with `use_ipv6=True`.' + ) + elif use_ipv6 is True and ':' not in local_addr: + raise TypeError( + '`use_ipv6=True` must only be used with a local IPv6 address.' + ) + + self._raise_last_call_error = raise_last_call_error + + self._request_retries = request_retries + self._connection_retries = connection_retries + self._retry_delay = retry_delay or 0 + self._proxy = proxy + self._local_addr = local_addr + self._timeout = timeout + self._auto_reconnect = auto_reconnect + + if proxy and not python_socks: + warnings.warn('proxy argument will be ignored because python-socks is not installed') + + assert isinstance(connection, type) + self._connection = connection + init_proxy = None if not issubclass(connection, TcpMTProxy) else \ + types.InputClientProxy(*connection.address_info(proxy)) + + # Used on connection. Capture the variables in a lambda since + # exporting clients need to create this InvokeWithLayerRequest. + system = platform.uname() + + if system.machine in ('x86_64', 'AMD64'): + default_device_model = 'PC 64bit' + elif system.machine in ('i386','i686','x86'): + default_device_model = 'PC 32bit' + else: + default_device_model = system.machine + default_system_version = re.sub(r'-.+','',system.release) + + self._init_request = functions.InitConnectionRequest( + api_id=self.api_id, + device_model=device_model or default_device_model or 'Unknown', + system_version=system_version or default_system_version or '1.0', + app_version=app_version or self.__version__, + lang_code=lang_code, + system_lang_code=system_lang_code, + lang_pack='', # "langPacks are for official apps only" + query=None, + proxy=init_proxy + ) + + # Remember flood-waited requests to avoid making them again + self._flood_waited_requests = {} + + # Cache ``{dc_id: (_ExportState, MTProtoSender)}`` for all borrowed senders + self._borrowed_senders = {} + self._borrow_sender_lock = asyncio.Lock() + self._exported_sessions = {} + + self._loop = None # only used as a sanity check + self._updates_error = None + self._updates_handle = None + self._keepalive_handle = None + self._last_request = time.time() + self._no_updates = not receive_updates + + # Used for non-sequential updates, in order to terminate all pending tasks on disconnect. + self._sequential_updates = sequential_updates + self._event_handler_tasks = set() + + self._authorized = None # None = unknown, False = no, True = yes + + # Some further state for subclasses + self._event_builders = [] + + # {chat_id: {Conversation}} + self._conversations = collections.defaultdict(set) + + # Hack to workaround the fact Telegram may send album updates as + # different Updates when being sent from a different data center. + # {grouped_id: AlbumHack} + # + # FIXME: We don't bother cleaning this up because it's not really + # worth it, albums are pretty rare and this only holds them + # for a second at most. + self._albums = {} + + # Default parse mode + self._parse_mode = markdown + + # Some fields to easy signing in. Let {phone: hash} be + # a dictionary because the user may change their mind. + self._phone_code_hash = {} + self._phone = None + self._tos = None + + # A place to store if channels are a megagroup or not (see `edit_admin`) + self._megagroup_cache = {} + + # This is backported from v2 in a very ad-hoc way just to get proper update handling + self._catch_up = catch_up + self._updates_queue = asyncio.Queue() + self._message_box = MessageBox(self._log['messagebox']) + self._mb_entity_cache = MbEntityCache() # required for proper update handling (to know when to getDifference) + self._entity_cache_limit = entity_cache_limit + + self._sender = MTProtoSender( + self.session.auth_key, + loggers=self._log, + retries=self._connection_retries, + delay=self._retry_delay, + auto_reconnect=self._auto_reconnect, + connect_timeout=self._timeout, + auth_key_callback=self._auth_key_callback, + updates_queue=self._updates_queue, + auto_reconnect_callback=self._handle_auto_reconnect + ) + + + # endregion + + # region Properties + + @property + def loop(self: 'TelegramClient') -> asyncio.AbstractEventLoop: + """ + Property with the ``asyncio`` event loop used by this client. + + Example + .. code-block:: python + + # Download media in the background + task = client.loop.create_task(message.download_media()) + + # Do some work + ... + + # Join the task (wait for it to complete) + await task + """ + return helpers.get_running_loop() + + @property + def disconnected(self: 'TelegramClient') -> asyncio.Future: + """ + Property with a ``Future`` that resolves upon disconnection. + + Example + .. code-block:: python + + # Wait for a disconnection to occur + try: + await client.disconnected + except OSError: + print('Error on disconnect') + """ + return self._sender.disconnected + + @property + def flood_sleep_threshold(self): + return self._flood_sleep_threshold + + @flood_sleep_threshold.setter + def flood_sleep_threshold(self, value): + # None -> 0, negative values don't really matter + self._flood_sleep_threshold = min(value or 0, 24 * 60 * 60) + + # endregion + + # region Connecting + + async def connect(self: 'TelegramClient') -> None: + """ + Connects to Telegram. + + .. note:: + + Connect means connect and nothing else, and only one low-level + request is made to notify Telegram about which layer we will be + using. + + Before Telegram sends you updates, you need to make a high-level + request, like `client.get_me() `, + as described in https://core.telegram.org/api/updates. + + Example + .. code-block:: python + + try: + await client.connect() + except OSError: + print('Failed to connect') + """ + if self.session is None: + raise ValueError('TelegramClient instance cannot be reused after logging out') + + if self._loop is None: + self._loop = helpers.get_running_loop() + elif self._loop != helpers.get_running_loop(): + raise RuntimeError('The asyncio event loop must not change after connection (see the FAQ for details)') + + # Current proxy implementation requires `sock_connect`, and some + # event loops lack this method. If the current loop is missing it, + # bail out early and suggest an alternative. + # + # TODO A better fix is obviously avoiding the use of `sock_connect` + # + # See https://github.com/LonamiWebs/Telethon/issues/1337 for details. + if not callable(getattr(self._loop, 'sock_connect', None)): + raise TypeError( + 'Event loop of type {} lacks `sock_connect`, which is needed to use proxies.\n\n' + 'Change the event loop in use to use proxies:\n' + '# https://github.com/LonamiWebs/Telethon/issues/1337\n' + 'import asyncio\n' + 'asyncio.set_event_loop(asyncio.SelectorEventLoop())'.format( + self._loop.__class__.__name__ + ) + ) + + # ':' in session.server_address is True if it's an IPv6 address + if (not self.session.server_address or + (':' in self.session.server_address) != self._use_ipv6): + await utils.maybe_async( + self.session.set_dc( + DEFAULT_DC_ID, + DEFAULT_IPV6_IP if self._use_ipv6 else DEFAULT_IPV4_IP, + DEFAULT_PORT + ) + ) + await utils.maybe_async(self.session.save()) + + if not await self._sender.connect(self._connection( + self.session.server_address, + self.session.port, + self.session.dc_id, + loggers=self._log, + proxy=self._proxy, + local_addr=self._local_addr + )): + # We don't want to init or modify anything if we were already connected + return + + self.session.auth_key = self._sender.auth_key + await utils.maybe_async(self.session.save()) + + try: + # See comment when saving entities to understand this hack + self_entity = await utils.maybe_async(self.session.get_input_entity(0)) + self_id = self_entity.access_hash + self_user = await utils.maybe_async(self.session.get_input_entity(self_id)) + self._mb_entity_cache.set_self_user(self_id, None, self_user.access_hash) + except ValueError: + pass + + if self._catch_up: + ss = SessionState(0, 0, False, 0, 0, 0, 0, None) + cs = [] + + update_states = await utils.maybe_async(self.session.get_update_states()) + for entity_id, state in update_states: + if entity_id == 0: + # TODO current session doesn't store self-user info but adding that is breaking on downstream session impls + ss = SessionState(0, 0, False, state.pts, state.qts, int(state.date.timestamp()), state.seq, None) + else: + cs.append(ChannelState(entity_id, state.pts)) + + self._message_box.load(ss, cs) + for state in cs: + try: + entity = await utils.maybe_async(self.session.get_input_entity(state.channel_id)) + except ValueError: + self._log[__name__].warning( + 'No access_hash in cache for channel %s, will not catch up', state.channel_id) + else: + self._mb_entity_cache.put(Entity(EntityType.CHANNEL, entity.channel_id, entity.access_hash)) + + self._init_request.query = functions.help.GetConfigRequest() + + req = self._init_request + if self._no_updates: + req = functions.InvokeWithoutUpdatesRequest(req) + + await self._sender.send(functions.InvokeWithLayerRequest(LAYER, req)) + + if self._message_box.is_empty(): + me = await self.get_me() + if me: + await self._on_login(me) # also calls GetState to initialize the MessageBox + + self._updates_handle = self.loop.create_task(self._update_loop()) + self._keepalive_handle = self.loop.create_task(self._keepalive_loop()) + + def is_connected(self: 'TelegramClient') -> bool: + """ + Returns `True` if the user has connected. + + This method is **not** asynchronous (don't use ``await`` on it). + + Example + .. code-block:: python + + while client.is_connected(): + await asyncio.sleep(1) + """ + sender = getattr(self, '_sender', None) + return sender and sender.is_connected() + + def disconnect(self: 'TelegramClient'): + """ + Disconnects from Telegram. + + If the event loop is already running, this method returns a + coroutine that you should await on your own code; otherwise + the loop is ran until said coroutine completes. + + Event handlers which are currently running will be cancelled before + this function returns (in order to properly clean-up their tasks). + In particular, this means that using ``disconnect`` in a handler + will cause code after the ``disconnect`` to never run. If this is + needed, consider spawning a separate task to do the remaining work. + + Example + .. code-block:: python + + # You don't need to use this if you used "with client" + await client.disconnect() + """ + if self.loop.is_running(): + # Disconnect may be called from an event handler, which would + # cancel itself during itself and never actually complete the + # disconnection. Shield the task to prevent disconnect itself + # from being cancelled. See issue #3942 for more details. + return asyncio.shield(self.loop.create_task(self._disconnect_coro())) + else: + try: + self.loop.run_until_complete(self._disconnect_coro()) + except RuntimeError: + # Python 3.5.x complains when called from + # `__aexit__` and there were pending updates with: + # "Event loop stopped before Future completed." + # + # However, it doesn't really make a lot of sense. + pass + + def set_proxy(self: 'TelegramClient', proxy: typing.Union[tuple, dict]): + """ + Changes the proxy which will be used on next (re)connection. + + Method has no immediate effects if the client is currently connected. + + The new proxy will take it's effect on the next reconnection attempt: + - on a call `await client.connect()` (after complete disconnect) + - on auto-reconnect attempt (e.g, after previous connection was lost) + """ + init_proxy = None if not issubclass(self._connection, TcpMTProxy) else \ + types.InputClientProxy(*self._connection.address_info(proxy)) + + self._init_request.proxy = init_proxy + self._proxy = proxy + + # While `await client.connect()` passes new proxy on each new call, + # auto-reconnect attempts use already set up `_connection` inside + # the `_sender`, so the only way to change proxy between those + # is to directly inject parameters. + + connection = getattr(self._sender, "_connection", None) + if connection: + if isinstance(connection, TcpMTProxy): + connection._ip = proxy[0] + connection._port = proxy[1] + else: + connection._proxy = proxy + + async def _save_states_and_entities(self: 'TelegramClient'): + # As a hack to not need to change the session files, save ourselves with ``id=0`` and ``access_hash`` of our ``id``. + # This way it is possible to determine our own ID by querying for 0. However, whether we're a bot is not saved. + # Piggy-back on an arbitrary TL type with users and chats so the session can understand to read the entities. + # It doesn't matter if we put users in the list of chats. + if self._mb_entity_cache.self_id: + await utils.maybe_async( + self.session.process_entities( + types.contacts.ResolvedPeer(None, [types.InputPeerUser(0, self._mb_entity_cache.self_id)], []) + ) + ) + + ss, cs = self._message_box.session_state() + await utils.maybe_async(self.session.set_update_state(0, types.updates.State(**ss, unread_count=0))) + now = datetime.datetime.now() # any datetime works; channels don't need it + for channel_id, pts in cs.items(): + await utils.maybe_async( + self.session.set_update_state( + channel_id, types.updates.State(pts, 0, now, 0, unread_count=0) + ) + ) + + async def _disconnect_coro(self: 'TelegramClient'): + if self.session is None: + return # already logged out and disconnected + + await self._disconnect() + + # Also clean-up all exported senders because we're done with them + async with self._borrow_sender_lock: + for state, sender in self._borrowed_senders.values(): + # Note that we're not checking for `state.should_disconnect()`. + # If the user wants to disconnect the client, ALL connections + # to Telegram (including exported senders) should be closed. + # + # Disconnect should never raise, so there's no try/except. + await sender.disconnect() + # Can't use `mark_disconnected` because it may be borrowed. + state._connected = False + + # If any was borrowed + self._borrowed_senders.clear() + + # trio's nurseries would handle this for us, but this is asyncio. + # All tasks spawned in the background should properly be terminated. + if self._event_handler_tasks: + for task in self._event_handler_tasks: + task.cancel() + + await asyncio.wait(self._event_handler_tasks) + self._event_handler_tasks.clear() + + await self._save_states_and_entities() + + await utils.maybe_async(self.session.close()) + + async def _disconnect(self: 'TelegramClient'): + """ + Disconnect only, without closing the session. Used in reconnections + to different data centers, where we don't want to close the session + file; user disconnects however should close it since it means that + their job with the client is complete and we should clean it up all. + """ + await self._sender.disconnect() + await helpers._cancel(self._log[__name__], + updates_handle=self._updates_handle, + keepalive_handle=self._keepalive_handle) + + async def _switch_dc(self: 'TelegramClient', new_dc): + """ + Permanently switches the current connection to the new data center. + """ + self._log[__name__].info('Reconnecting to new data center %s', new_dc) + dc = await self._get_dc(new_dc) + + await utils.maybe_async(self.session.set_dc(dc.id, dc.ip_address, dc.port)) + # auth_key's are associated with a server, which has now changed + # so it's not valid anymore. Set to None to force recreating it. + self._sender.auth_key.key = None + self.session.auth_key = None + await utils.maybe_async(self.session.save()) + await self._disconnect() + return await self.connect() + + async def _auth_key_callback(self: 'TelegramClient', auth_key): + """ + Callback from the sender whenever it needed to generate a + new authorization key. This means we are not authorized. + """ + self.session.auth_key = auth_key + await utils.maybe_async(self.session.save()) + + # endregion + + # region Working with different connections/Data Centers + + async def _get_dc(self: 'TelegramClient', dc_id, cdn=False): + """Gets the Data Center (DC) associated to 'dc_id'""" + cls = self.__class__ + if not cls._config: + cls._config = await self(functions.help.GetConfigRequest()) + + if cdn and not self._cdn_config: + cls._cdn_config = await self(functions.help.GetCdnConfigRequest()) + for pk in cls._cdn_config.public_keys: + if pk.dc_id == dc_id: + rsa.add_key(pk.public_key, old=False) + + try: + return next( + dc for dc in cls._config.dc_options + if dc.id == dc_id + and bool(dc.ipv6) == self._use_ipv6 and bool(dc.cdn) == cdn + ) + except StopIteration: + self._log[__name__].warning( + 'Failed to get DC %s (cdn = %s) with use_ipv6 = %s; retrying ignoring IPv6 check', + dc_id, cdn, self._use_ipv6 + ) + try: + return next( + dc for dc in cls._config.dc_options + if dc.id == dc_id and bool(dc.cdn) == cdn + ) + except StopIteration: + raise ValueError(f'Failed to get DC {dc_id} (cdn = {cdn})') + + async def _create_exported_sender(self: 'TelegramClient', dc_id): + """ + Creates a new exported `MTProtoSender` for the given `dc_id` and + returns it. This method should be used by `_borrow_exported_sender`. + """ + # Thanks badoualy/kotlogram on /telegram/api/DefaultTelegramClient.kt + # for clearly showing how to export the authorization + dc = await self._get_dc(dc_id) + # Can't reuse self._sender._connection as it has its own seqno. + # + # If one were to do that, Telegram would reset the connection + # with no further clues. + sender = MTProtoSender(None, loggers=self._log) + await sender.connect(self._connection( + dc.ip_address, + dc.port, + dc.id, + loggers=self._log, + proxy=self._proxy, + local_addr=self._local_addr + )) + self._log[__name__].info('Exporting auth for new borrowed sender in %s', dc) + auth = await self(functions.auth.ExportAuthorizationRequest(dc_id)) + self._init_request.query = functions.auth.ImportAuthorizationRequest(id=auth.id, bytes=auth.bytes) + req = functions.InvokeWithLayerRequest(LAYER, self._init_request) + await sender.send(req) + return sender + + async def _borrow_exported_sender(self: 'TelegramClient', dc_id): + """ + Borrows a connected `MTProtoSender` for the given `dc_id`. + If it's not cached, creates a new one if it doesn't exist yet, + and imports a freshly exported authorization key for it to be usable. + + Once its job is over it should be `_return_exported_sender`. + """ + async with self._borrow_sender_lock: + self._log[__name__].debug('Borrowing sender for dc_id %d', dc_id) + state, sender = self._borrowed_senders.get(dc_id, (None, None)) + + if state is None: + state = _ExportState() + sender = await self._create_exported_sender(dc_id) + sender.dc_id = dc_id + self._borrowed_senders[dc_id] = (state, sender) + + elif state.need_connect(): + dc = await self._get_dc(dc_id) + await sender.connect(self._connection( + dc.ip_address, + dc.port, + dc.id, + loggers=self._log, + proxy=self._proxy, + local_addr=self._local_addr + )) + + state.add_borrow() + return sender + + async def _return_exported_sender(self: 'TelegramClient', sender): + """ + Returns a borrowed exported sender. If all borrows have + been returned, the sender is cleanly disconnected. + """ + async with self._borrow_sender_lock: + self._log[__name__].debug('Returning borrowed sender for dc_id %d', sender.dc_id) + state, _ = self._borrowed_senders[sender.dc_id] + state.add_return() + + async def _clean_exported_senders(self: 'TelegramClient'): + """ + Cleans-up all unused exported senders by disconnecting them. + """ + async with self._borrow_sender_lock: + for dc_id, (state, sender) in self._borrowed_senders.items(): + if state.should_disconnect(): + self._log[__name__].info( + 'Disconnecting borrowed sender for DC %d', dc_id) + + # Disconnect should never raise + await sender.disconnect() + state.mark_disconnected() + + async def _get_cdn_client(self: 'TelegramClient', cdn_redirect): + """Similar to ._borrow_exported_client, but for CDNs""" + session = self._exported_sessions.get(cdn_redirect.dc_id) + if not session: + dc = await self._get_dc(cdn_redirect.dc_id, cdn=True) + session = await utils.maybe_async(self.session.clone()) + await utils.maybe_async(session.set_dc(dc.id, dc.ip_address, dc.port)) + self._exported_sessions[cdn_redirect.dc_id] = session + + self._log[__name__].info('Creating new CDN client') + client = self.__class__( + session, self.api_id, self.api_hash, + proxy=self._proxy, + timeout=self._timeout, + loop=self.loop + ) + + session.auth_key = self._sender.auth_key + await client._sender.connect(self._connection( + session.server_address, + session.port, + session.dc_id, + loggers=self._log, + proxy=self._proxy, + local_addr=self._local_addr + )) + return client + + # endregion + + # region Invoking Telegram requests + + @abc.abstractmethod + def __call__(self: 'TelegramClient', request, ordered=False): + """ + Invokes (sends) one or more MTProtoRequests and returns (receives) + their result. + + Args: + request (`TLObject` | `list`): + The request or requests to be invoked. + + ordered (`bool`, optional): + Whether the requests (if more than one was given) should be + executed sequentially on the server. They run in arbitrary + order by default. + + flood_sleep_threshold (`int` | `None`, optional): + The flood sleep threshold to use for this request. This overrides + the default value stored in + `client.flood_sleep_threshold ` + + Returns: + The result of the request (often a `TLObject`) or a list of + results if more than one request was given. + """ + raise NotImplementedError + + @abc.abstractmethod + def _update_loop(self: 'TelegramClient'): + raise NotImplementedError + + @abc.abstractmethod + async def _handle_auto_reconnect(self: 'TelegramClient'): + raise NotImplementedError + + # endregion diff --git a/module/scripts/lib/telethon/client/telegramclient.py b/module/scripts/lib/telethon/client/telegramclient.py new file mode 100644 index 0000000..144a6b2 --- /dev/null +++ b/module/scripts/lib/telethon/client/telegramclient.py @@ -0,0 +1,13 @@ +from . import ( + AccountMethods, AuthMethods, DownloadMethods, DialogMethods, ChatMethods, + BotMethods, MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, + MessageParseMethods, UserMethods, TelegramBaseClient +) + + +class TelegramClient( + AccountMethods, AuthMethods, DownloadMethods, DialogMethods, ChatMethods, + BotMethods, MessageMethods, UploadMethods, ButtonMethods, UpdateMethods, + MessageParseMethods, UserMethods, TelegramBaseClient +): + pass diff --git a/module/scripts/lib/telethon/client/updates.py b/module/scripts/lib/telethon/client/updates.py new file mode 100644 index 0000000..ead9f94 --- /dev/null +++ b/module/scripts/lib/telethon/client/updates.py @@ -0,0 +1,733 @@ +import asyncio +import inspect +import itertools +import random +import sys +import time +import traceback +import typing +import logging +import warnings +from collections import deque + +try: + import sqlite3 + + OperationalError = sqlite3.OperationalError +except ImportError as e: + sqlite3 = None + + class OperationalError(Exception): + pass # won't be created and thus never caught + +from .. import events, utils, errors +from ..events.common import EventBuilder, EventCommon +from ..tl import types, functions +from .._updates import GapError, PrematureEndReason +from ..helpers import get_running_loop +from ..version import __version__ + + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +Callback = typing.Callable[[typing.Any], typing.Any] + +class UpdateMethods: + + # region Public methods + + async def _run_until_disconnected(self: 'TelegramClient'): + try: + # Make a high-level request to notify that we want updates + await self(functions.updates.GetStateRequest()) + result = await self.disconnected + if self._updates_error is not None: + raise self._updates_error + return result + except KeyboardInterrupt: + pass + finally: + await self.disconnect() + + async def set_receive_updates(self: 'TelegramClient', receive_updates): + """ + Change the value of `receive_updates`. + + This is an `async` method, because in order for Telegram to start + sending updates again, a request must be made. + """ + self._no_updates = not receive_updates + if receive_updates: + await self(functions.updates.GetStateRequest()) + + def run_until_disconnected(self: 'TelegramClient'): + """ + Runs the event loop until the library is disconnected. + + It also notifies Telegram that we want to receive updates + as described in https://core.telegram.org/api/updates. + If an unexpected error occurs during update handling, + the client will disconnect and said error will be raised. + + Manual disconnections can be made by calling `disconnect() + ` + or sending a ``KeyboardInterrupt`` (e.g. by pressing ``Ctrl+C`` on + the console window running the script). + + If a disconnection error occurs (i.e. the library fails to reconnect + automatically), said error will be raised through here, so you have a + chance to ``except`` it on your own code. + + If the loop is already running, this method returns a coroutine + that you should await on your own code. + + .. note:: + + If you want to handle ``KeyboardInterrupt`` in your code, + simply run the event loop in your code too in any way, such as + ``loop.run_forever()`` or ``await client.disconnected`` (e.g. + ``loop.run_until_complete(client.disconnected)``). + + Example + .. code-block:: python + + # Blocks the current task here until a disconnection occurs. + # + # You will still receive updates, since this prevents the + # script from exiting. + await client.run_until_disconnected() + """ + if self.loop.is_running(): + return self._run_until_disconnected() + try: + return self.loop.run_until_complete(self._run_until_disconnected()) + except KeyboardInterrupt: + pass + finally: + # No loop.run_until_complete; it's already syncified + self.disconnect() + + def on(self: 'TelegramClient', event: EventBuilder): + """ + Decorator used to `add_event_handler` more conveniently. + + + Arguments + event (`_EventBuilder` | `type`): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + # Here we use client.on + @client.on(events.NewMessage) + async def handler(event): + ... + """ + def decorator(f): + self.add_event_handler(f, event) + return f + + return decorator + + def add_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None): + """ + Registers a new event handler callback. + + The callback will be called when the specified event occurs. + + Arguments + callback (`callable`): + The callable function accepting one parameter to be used. + + Note that if you have used `telethon.events.register` in + the callback, ``event`` will be ignored, and instead the + events you previously registered will be used. + + event (`_EventBuilder` | `type`, optional): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + + If left unspecified, `telethon.events.raw.Raw` (the + :tl:`Update` objects with no further processing) will + be passed instead. + + Example + .. code-block:: python + + from telethon import TelegramClient, events + client = TelegramClient(...) + + async def handler(event): + ... + + client.add_event_handler(handler, events.NewMessage) + """ + builders = events._get_handlers(callback) + if builders is not None: + for event in builders: + self._event_builders.append((event, callback)) + return + + if isinstance(event, type): + event = event() + elif not event: + event = events.Raw() + + self._event_builders.append((event, callback)) + + def remove_event_handler( + self: 'TelegramClient', + callback: Callback, + event: EventBuilder = None) -> int: + """ + Inverse operation of `add_event_handler()`. + + If no event is given, all events for this callback are removed. + Returns how many callbacks were removed. + + Example + .. code-block:: python + + @client.on(events.Raw) + @client.on(events.NewMessage) + async def handler(event): + ... + + # Removes only the "Raw" handling + # "handler" will still receive "events.NewMessage" + client.remove_event_handler(handler, events.Raw) + + # "handler" will stop receiving anything + client.remove_event_handler(handler) + """ + found = 0 + if event and not isinstance(event, type): + event = type(event) + + i = len(self._event_builders) + while i: + i -= 1 + ev, cb = self._event_builders[i] + if cb == callback and (not event or isinstance(ev, event)): + del self._event_builders[i] + found += 1 + + return found + + def list_event_handlers(self: 'TelegramClient')\ + -> 'typing.Sequence[typing.Tuple[Callback, EventBuilder]]': + """ + Lists all registered event handlers. + + Returns + A list of pairs consisting of ``(callback, event)``. + + Example + .. code-block:: python + + @client.on(events.NewMessage(pattern='hello')) + async def on_greeting(event): + '''Greets someone''' + await event.reply('Hi') + + for callback, event in client.list_event_handlers(): + print(id(callback), type(event)) + """ + return [(callback, event) for event, callback in self._event_builders] + + async def catch_up(self: 'TelegramClient'): + """ + "Catches up" on the missed updates while the client was offline. + You should call this method after registering the event handlers + so that the updates it loads can by processed by your script. + + This can also be used to forcibly fetch new updates if there are any. + + Example + .. code-block:: python + + await client.catch_up() + """ + await self._updates_queue.put(types.UpdatesTooLong()) + + # endregion + + # region Private methods + + async def _update_loop(self: 'TelegramClient'): + # If the MessageBox is not empty, the account had to be logged-in to fill in its state. + # This flag is used to propagate the "you got logged-out" error up (but getting logged-out + # can only happen if it was once logged-in). + was_once_logged_in = self._authorized is True or not self._message_box.is_empty() + + self._updates_error = None + try: + if self._catch_up: + # User wants to catch up as soon as the client is up and running, + # so this is the best place to do it. + await self.catch_up() + + updates_to_dispatch = deque() + + while self.is_connected(): + if updates_to_dispatch: + if self._sequential_updates: + await self._dispatch_update(updates_to_dispatch.popleft()) + else: + while updates_to_dispatch: + # TODO if _dispatch_update fails for whatever reason, it's not logged! this should be fixed + task = self.loop.create_task(self._dispatch_update(updates_to_dispatch.popleft())) + self._event_handler_tasks.add(task) + task.add_done_callback(self._event_handler_tasks.discard) + + continue + + if len(self._mb_entity_cache) >= self._entity_cache_limit: + self._log[__name__].info( + 'In-memory entity cache limit reached (%s/%s), flushing to session', + len(self._mb_entity_cache), + self._entity_cache_limit + ) + await self._save_states_and_entities() + self._mb_entity_cache.retain(lambda id: id == self._mb_entity_cache.self_id or id in self._message_box.map) + if len(self._mb_entity_cache) >= self._entity_cache_limit: + warnings.warn('in-memory entities exceed entity_cache_limit after flushing; consider setting a larger limit') + + self._log[__name__].info( + 'In-memory entity cache at %s/%s after flushing to session', + len(self._mb_entity_cache), + self._entity_cache_limit + ) + + + get_diff = self._message_box.get_difference() + if get_diff: + self._log[__name__].debug('Getting difference for account updates') + try: + diff = await self(get_diff) + except ( + errors.ServerError, + errors.TimedOutError, + errors.FloodWaitError, + ValueError + ) as e: + # Telegram is having issues + self._log[__name__].info('Cannot get difference since Telegram is having issues: %s', type(e).__name__) + self._message_box.end_difference() + continue + except (errors.UnauthorizedError, errors.AuthKeyError) as e: + # Not logged in or broken authorization key, can't get difference + self._log[__name__].info('Cannot get difference since the account is not logged in: %s', type(e).__name__) + self._message_box.end_difference() + if was_once_logged_in: + self._updates_error = e + await self.disconnect() + break + continue + except (errors.TypeNotFoundError, OperationalError) as e: + # User is likely doing weird things with their account or session and Telegram gets confused as to what layer they use + self._log[__name__].warning('Cannot get difference since the account is likely misusing the session: %s', e) + self._message_box.end_difference() + self._updates_error = e + await self.disconnect() + break + except errors.RPCError as e: + # Fallback; treat as transient error (the amount of "fatal errors" reported seem to indicate this is most likely what we need to do) + self._log[__name__].warning( + "Cannot get difference due to unexpected error (this may be a bug " + f"in Telethon v{__version__} in that it could be handled better, but it's unlikely): %s", + e + ) + self._message_box.end_difference() + continue + except OSError as e: + # Network is likely down, but it's unclear for how long. + # If disconnect is called this task will be cancelled along with the sleep. + # If disconnect is not called, getting difference should be retried after a few seconds. + self._log[__name__].info('Cannot get difference since the network is down: %s: %s', type(e).__name__, e) + await asyncio.sleep(5) + continue + updates, users, chats = self._message_box.apply_difference(diff, self._mb_entity_cache) + if updates: + self._log[__name__].info('Got difference for account updates') + + _preprocess_updates = await self._preprocess_updates(updates, users, chats) + updates_to_dispatch.extend(_preprocess_updates) + continue + + get_diff = self._message_box.get_channel_difference(self._mb_entity_cache) + if get_diff: + self._log[__name__].debug('Getting difference for channel %s updates', get_diff.channel.channel_id) + try: + diff = await self(get_diff) + except (errors.UnauthorizedError, errors.AuthKeyError) as e: + # Not logged in or broken authorization key, can't get difference + self._log[__name__].warning( + 'Cannot get difference for channel %s since the account is not logged in: %s', + get_diff.channel.channel_id, type(e).__name__ + ) + self._message_box.end_channel_difference( + get_diff, + PrematureEndReason.TEMPORARY_SERVER_ISSUES, + self._mb_entity_cache + ) + if was_once_logged_in: + self._updates_error = e + await self.disconnect() + break + continue + except (errors.TypeNotFoundError, OperationalError) as e: + self._log[__name__].warning( + 'Cannot get difference for channel %s since the account is likely misusing the session: %s', + get_diff.channel.channel_id, e + ) + self._message_box.end_channel_difference( + get_diff, + PrematureEndReason.TEMPORARY_SERVER_ISSUES, + self._mb_entity_cache + ) + self._updates_error = e + await self.disconnect() + break + except ( + errors.PersistentTimestampOutdatedError, + errors.PersistentTimestampInvalidError, + errors.ServerError, + errors.TimedOutError, + errors.FloodWaitError, + ValueError + ) as e: + # According to Telegram's docs: + # "Channel internal replication issues, try again later (treat this like an RPC_CALL_FAIL)." + # We can treat this as "empty difference" and not update the local pts. + # Then this same call will be retried when another gap is detected or timeout expires. + # + # Another option would be to literally treat this like an RPC_CALL_FAIL and retry after a few + # seconds, but if Telegram is having issues it's probably best to wait for it to send another + # update (hinting it may be okay now) and retry then. + # + # This is a bit hacky because MessageBox doesn't really have a way to "not update" the pts. + # Instead we manually extract the previously-known pts and use that. + # + # For PersistentTimestampInvalidError: + # Somehow our pts is either too new or the server does not know about this. + # We treat this as PersistentTimestampOutdatedError for now. + # TODO investigate why/when this happens and if this is the proper solution + self._log[__name__].warning( + 'Getting difference for channel updates %s caused %s;' + ' ending getting difference prematurely until server issues are resolved', + get_diff.channel.channel_id, type(e).__name__ + ) + self._message_box.end_channel_difference( + get_diff, + PrematureEndReason.TEMPORARY_SERVER_ISSUES, + self._mb_entity_cache + ) + continue + except (errors.ChannelPrivateError, errors.ChannelInvalidError): + # Timeout triggered a get difference, but we have been banned in the channel since then. + # Because we can no longer fetch updates from this channel, we should stop keeping track + # of it entirely. + self._log[__name__].info( + 'Account is now banned in %d so we can no longer fetch updates from it', + get_diff.channel.channel_id + ) + self._message_box.end_channel_difference( + get_diff, + PrematureEndReason.BANNED, + self._mb_entity_cache + ) + continue + except errors.RPCError as e: + # Fallback; treat as transient error (the amount of "fatal errors" reported seem to indicate this is most likely what we need to do) + self._log[__name__].warning( + "Cannot get difference for channel %d due to unexpected error (this may be a bug " + f"in Telethon v{__version__} in that it could be handled better, but it's unlikely): %s", + get_diff.channel.channel_id, e + ) + self._message_box.end_channel_difference( + get_diff, + PrematureEndReason.TEMPORARY_SERVER_ISSUES, + self._mb_entity_cache + ) + continue + except OSError as e: + self._log[__name__].info( + 'Cannot get difference for channel %d since the network is down: %s: %s', + get_diff.channel.channel_id, type(e).__name__, e + ) + await asyncio.sleep(5) + continue + + updates, users, chats = self._message_box.apply_channel_difference(get_diff, diff, self._mb_entity_cache) + if updates: + self._log[__name__].info('Got difference for channel %d updates', get_diff.channel.channel_id) + + _preprocess_updates = await self._preprocess_updates(updates, users, chats) + updates_to_dispatch.extend(_preprocess_updates) + continue + + deadline = self._message_box.check_deadlines() + deadline_delay = deadline - get_running_loop().time() + if deadline_delay > 0: + # Don't bother sleeping and timing out if the delay is already 0 (pollutes the logs). + try: + updates = await asyncio.wait_for(self._updates_queue.get(), deadline_delay) + except asyncio.TimeoutError: + self._log[__name__].debug('Timeout waiting for updates expired') + continue + else: + continue + + processed = [] + try: + users, chats = self._message_box.process_updates(updates, self._mb_entity_cache, processed) + except GapError: + continue # get(_channel)_difference will start returning requests + + _preprocess_updates = await self._preprocess_updates(processed, users, chats) + updates_to_dispatch.extend(_preprocess_updates) + except asyncio.CancelledError: + pass + except Exception as e: + self._log[__name__].exception(f'Fatal error handling updates (this is a bug in Telethon v{__version__}, please report it)') + self._updates_error = e + await self.disconnect() + + async def _preprocess_updates(self, updates, users, chats): + self._mb_entity_cache.extend(users, chats) + await utils.maybe_async(self.session.process_entities(types.contacts.ResolvedPeer(None, users, chats))) + entities = {utils.get_peer_id(x): x + for x in itertools.chain(users, chats)} + for u in updates: + u._entities = entities + return updates + + async def _keepalive_loop(self: 'TelegramClient'): + # Pings' ID don't really need to be secure, just "random" + rnd = lambda: random.randrange(-2**63, 2**63) + while self.is_connected(): + try: + await asyncio.wait_for( + self.disconnected, timeout=60 + ) + continue # We actually just want to act upon timeout + except asyncio.TimeoutError: + pass + except asyncio.CancelledError: + return + except Exception: + continue # Any disconnected exception should be ignored + + # Check if we have any exported senders to clean-up periodically + await self._clean_exported_senders() + + # Don't bother sending pings until the low-level connection is + # ready, otherwise a lot of pings will be batched to be sent upon + # reconnect, when we really don't care about that. + if not self._sender._transport_connected(): + continue + + # We also don't really care about their result. + # Just send them periodically. + try: + self._sender._keepalive_ping(rnd()) + except (ConnectionError, asyncio.CancelledError): + return + + # Entities and cached files are not saved when they are + # inserted because this is a rather expensive operation + # (default's sqlite3 takes ~0.1s to commit changes). Do + # it every minute instead. No-op if there's nothing new. + await self._save_states_and_entities() + + await utils.maybe_async(self.session.save()) + + async def _dispatch_update(self: 'TelegramClient', update): + # TODO only used for AlbumHack, and MessageBox is not really designed for this + others = None + + if not self._mb_entity_cache.self_id: + # Some updates require our own ID, so we must make sure + # that the event builder has offline access to it. Calling + # `get_me()` will cache it under `self._mb_entity_cache`. + # + # It will return `None` if we haven't logged in yet which is + # fine, we will just retry next time anyway. + try: + await self.get_me(input_peer=True) + except OSError: + pass # might not have connection + + built = EventBuilderDict(self, update, others) + for conv_set in self._conversations.values(): + for conv in conv_set: + ev = built[events.NewMessage] + if ev: + conv._on_new_message(ev) + + ev = built[events.MessageEdited] + if ev: + conv._on_edit(ev) + + ev = built[events.MessageRead] + if ev: + conv._on_read(ev) + + if conv._custom: + await conv._check_custom(built) + + for builder, callback in self._event_builders: + event = built[type(builder)] + if not event: + continue + + if not builder.resolved: + await builder.resolve(self) + + filter = builder.filter(event) + if inspect.isawaitable(filter): + filter = await filter + if not filter: + continue + + try: + await callback(event) + except errors.AlreadyInConversationError: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" already has an open conversation, ' + 'ignoring new one', name) + except events.StopPropagation: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" stopped chain of propagation ' + 'for event %s.', name, type(event).__name__ + ) + break + except Exception as e: + if not isinstance(e, asyncio.CancelledError) or self.is_connected(): + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].exception('Unhandled exception on %s', name) + + async def _dispatch_event(self: 'TelegramClient', event): + """ + Dispatches a single, out-of-order event. Used by `AlbumHack`. + """ + # We're duplicating a most logic from `_dispatch_update`, but all in + # the name of speed; we don't want to make it worse for all updates + # just because albums may need it. + for builder, callback in self._event_builders: + if isinstance(builder, events.Raw): + continue + if not isinstance(event, builder.Event): + continue + + if not builder.resolved: + await builder.resolve(self) + + filter = builder.filter(event) + if inspect.isawaitable(filter): + filter = await filter + if not filter: + continue + + try: + await callback(event) + except errors.AlreadyInConversationError: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" already has an open conversation, ' + 'ignoring new one', name) + except events.StopPropagation: + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].debug( + 'Event handler "%s" stopped chain of propagation ' + 'for event %s.', name, type(event).__name__ + ) + break + except Exception as e: + if not isinstance(e, asyncio.CancelledError) or self.is_connected(): + name = getattr(callback, '__name__', repr(callback)) + self._log[__name__].exception('Unhandled exception on %s', name) + + async def _handle_auto_reconnect(self: 'TelegramClient'): + # TODO Catch-up + # For now we make a high-level request to let Telegram + # know we are still interested in receiving more updates. + try: + await self.get_me() + except Exception as e: + self._log[__name__].warning('Error executing high-level request ' + 'after reconnect: %s: %s', type(e), e) + + return + try: + self._log[__name__].info( + 'Asking for the current state after reconnect...') + + # TODO consider: + # If there aren't many updates while the client is disconnected + # (I tried with up to 20), Telegram seems to send them without + # asking for them (via updates.getDifference). + # + # On disconnection, the library should probably set a "need + # difference" or "catching up" flag so that any new updates are + # ignored, and then the library should call updates.getDifference + # itself to fetch them. + # + # In any case (either there are too many updates and Telegram + # didn't send them, or there isn't a lot and Telegram sent them + # but we dropped them), we fetch the new difference to get all + # missed updates. I feel like this would be the best solution. + + # If a disconnection occurs, the old known state will be + # the latest one we were aware of, so we can catch up since + # the most recent state we were aware of. + await self.catch_up() + + self._log[__name__].info('Successfully fetched missed updates') + except errors.RPCError as e: + self._log[__name__].warning('Failed to get missed updates after ' + 'reconnect: %r', e) + except Exception: + self._log[__name__].exception( + 'Unhandled exception while getting update difference after reconnect') + + # endregion + + +class EventBuilderDict: + """ + Helper "dictionary" to return events from types and cache them. + """ + def __init__(self, client: 'TelegramClient', update, others): + self.client = client + self.update = update + self.others = others + + def __getitem__(self, builder): + try: + return self.__dict__[builder] + except KeyError: + event = self.__dict__[builder] = builder.build( + self.update, self.others, self.client._self_id) + + if isinstance(event, EventCommon): + event.original_update = self.update + event._entities = self.update._entities + event._set_client(self.client) + elif event: + event._client = self.client + + return event diff --git a/module/scripts/lib/telethon/client/uploads.py b/module/scripts/lib/telethon/client/uploads.py new file mode 100644 index 0000000..007f613 --- /dev/null +++ b/module/scripts/lib/telethon/client/uploads.py @@ -0,0 +1,870 @@ +import hashlib +import io +import itertools +import os +import pathlib +import re +import typing +from io import BytesIO + +from ..crypto import AES + +from .. import utils, helpers, hints +from ..tl import types, functions, custom + +try: + import PIL + import PIL.Image +except ImportError: + PIL = None + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +class _CacheType: + """Like functools.partial but pretends to be the wrapped class.""" + def __init__(self, cls): + self._cls = cls + + def __call__(self, *args, **kwargs): + return self._cls(*args, file_reference=b'', **kwargs) + + def __eq__(self, other): + return self._cls == other + + +def _resize_photo_if_needed( + file, is_image, width=2560, height=2560, background=(255, 255, 255)): + + # https://github.com/telegramdesktop/tdesktop/blob/12905f0dcb9d513378e7db11989455a1b764ef75/Telegram/SourceFiles/boxes/photo_crop_box.cpp#L254 + if (not is_image + or PIL is None + or (isinstance(file, io.IOBase) and not file.seekable())): + return file + + if isinstance(file, bytes): + file = io.BytesIO(file) + + if isinstance(file, io.IOBase): + # Pillow seeks to 0 unconditionally later anyway + old_pos = file.tell() + file.seek(0, io.SEEK_END) + before = file.tell() + elif isinstance(file, str) and os.path.exists(file): + # Check if file exists as a path and if so, get its size on disk + before = os.path.getsize(file) + else: + # Would be weird... + before = None + + try: + # Don't use a `with` block for `image`, or `file` would be closed. + # See https://github.com/LonamiWebs/Telethon/issues/1121 for more. + image = PIL.Image.open(file) + try: + kwargs = {'exif': image.info['exif']} + except KeyError: + kwargs = {} + + if image.mode == 'RGB': + # Check if image is within acceptable bounds, if so, check if the image is at or below 10 MB, or assume it isn't if size is None or 0 + if image.width <= width and image.height <= height and (before <= 10000000 if before else False): + return file + + # If the image is already RGB, don't convert it + # certain modes such as 'P' have no alpha index but can't be saved as JPEG directly + image.thumbnail((width, height), PIL.Image.LANCZOS) + result = image + else: + # We could save the resized image with the original format, but + # JPEG often compresses better -> smaller size -> faster upload + # We need to mask away the alpha channel ([3]), since otherwise + # IOError is raised when trying to save alpha channels in JPEG. + image.thumbnail((width, height), PIL.Image.LANCZOS) + result = PIL.Image.new('RGB', image.size, background) + mask = None + + if image.has_transparency_data: + if image.mode == 'RGBA': + mask = image.getchannel('A') + else: + mask = image.convert('RGBA').getchannel('A') + + result.paste(image, mask=mask) + + buffer = io.BytesIO() + result.save(buffer, 'JPEG', progressive=True, **kwargs) + buffer.seek(0) + buffer.name = 'a.jpg' + return buffer + except IOError: + return file + finally: + # The original position might matter + if isinstance(file, io.IOBase): + file.seek(old_pos) + + +class UploadMethods: + + # region Public methods + + async def send_file( + self: 'TelegramClient', + entity: 'hints.EntityLike', + file: 'typing.Union[hints.FileLike, typing.Sequence[hints.FileLike]]', + *, + caption: typing.Union[str, typing.Sequence[str]] = None, + force_document: bool = False, + mime_type: str = None, + file_size: int = None, + clear_draft: bool = False, + progress_callback: 'hints.ProgressCallback' = None, + reply_to: 'hints.MessageIDLike' = None, + attributes: 'typing.Sequence[types.TypeDocumentAttribute]' = None, + thumb: 'hints.FileLike' = None, + allow_cache: bool = True, + parse_mode: typing.Optional[str] = (), + formatting_entities: typing.Optional[ + typing.Union[ + typing.List[types.TypeMessageEntity], typing.List[typing.List[types.TypeMessageEntity]] + ] + ] = None, + voice_note: bool = False, + video_note: bool = False, + buttons: typing.Optional['hints.MarkupLike'] = None, + silent: bool = None, + background: bool = None, + supports_streaming: bool = False, + schedule: 'hints.DateLike' = None, + comment_to: 'typing.Union[int, types.Message]' = None, + ttl: int = None, + nosound_video: bool = None, + send_as: typing.Optional['hints.EntityLike'] = None, + message_effect_id: typing.Optional[int] = None, + **kwargs) -> typing.Union[typing.List[typing.Any], typing.Any]: + """ + Sends message with the given file to the specified entity. + + .. note:: + + If the ``hachoir3`` package (``hachoir`` module) is installed, + it will be used to determine metadata from audio and video files. + + If the ``pillow`` package is installed and you are sending a photo, + it will be resized to fit within the maximum dimensions allowed + by Telegram to avoid ``errors.PhotoInvalidDimensionsError``. This + cannot be done if you are sending :tl:`InputFile`, however. + + Arguments + entity (`entity`): + Who will receive the file. + + file (`str` | `bytes` | `file` | `media`): + The file to send, which can be one of: + + * A local file path to an in-disk file. The file name + will be the path's base name. + + * A `bytes` byte array with the file's data to send + (for example, by using ``text.encode('utf-8')``). + A default file name will be used. + + * A bytes `io.IOBase` stream over the file to send + (for example, by using ``open(file, 'rb')``). + Its ``.name`` property will be used for the file name, + or a default if it doesn't have one. + + * An external URL to a file over the internet. This will + send the file as "external" media, and Telegram is the + one that will fetch the media and send it. + + * A Bot API-like ``file_id``. You can convert previously + sent media to file IDs for later reusing with + `telethon.utils.pack_bot_file_id`. + + * A handle to an existing file (for example, if you sent a + message with media before, you can use its ``message.media`` + as a file here). + + * A handle to an uploaded file (from `upload_file`). + + * A :tl:`InputMedia` instance. For example, if you want to + send a dice use :tl:`InputMediaDice`, or if you want to + send a contact use :tl:`InputMediaContact`. + + To send an album, you should provide a list in this parameter. + + If a list or similar is provided, the files in it will be + sent as an album in the order in which they appear, sliced + in chunks of 10 if more than 10 are given. + + caption (`str`, optional): + Optional caption for the sent media message. When sending an + album, the caption may be a list of strings, which will be + assigned to the files pairwise. + + force_document (`bool`, optional): + If left to `False` and the file is a path that ends with + the extension of an image file or a video file, it will be + sent as such. Otherwise always as a document. + + mime_type (`str`, optional): + Custom mime type to use for the file to be sent (for example, + ``audio/mpeg``, ``audio/x-vorbis+ogg``, etc.). + It can change the type of files displayed. + If not set to any value, the mime type will be determined + automatically based on the file's extension. + + file_size (`int`, optional): + The size of the file to be uploaded if it needs to be uploaded, + which will be determined automatically if not specified. + + If the file size can't be determined beforehand, the entire + file will be read in-memory to find out how large it is. + + clear_draft (`bool`, optional): + Whether the existing draft should be cleared or not. + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + reply_to (`int` | `Message `): + Same as `reply_to` from `send_message`. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + thumb (`str` | `bytes` | `file`, optional): + Optional JPEG thumbnail (for documents). **Telegram will + ignore this parameter** unless you pass a ``.jpg`` file! + + The file must also be small in dimensions and in disk size. + Successful thumbnails were files below 20kB and 320x320px. + Width/height and dimensions/size ratios may be important. + For Telegram to accept a thumbnail, you must provide the + dimensions of the underlying media through ``attributes=`` + with :tl:`DocumentAttributeVideo` or by installing the + optional ``hachoir`` dependency. + + + allow_cache (`bool`, optional): + This parameter currently does nothing, but is kept for + backward-compatibility (and it may get its use back in + the future). + + parse_mode (`object`, optional): + See the `TelegramClient.parse_mode + ` + property for allowed values. Markdown parsing will be used by + default. + + formatting_entities (`list`, optional): + Optional formatting entities for the sent media message. When sending an album, + `formatting_entities` can be a list of lists, where each inner list contains + `types.TypeMessageEntity`. Each inner list will be assigned to the corresponding + file in a pairwise manner with the caption. If provided, the ``parse_mode`` + parameter will be ignored. + + voice_note (`bool`, optional): + If `True` the audio will be sent as a voice note. + + video_note (`bool`, optional): + If `True` the video will be sent as a video note, + also known as a round video message. + + buttons (`list`, `custom.Button `, :tl:`KeyboardButton`, :tl:`KeyboardInlineButton`): + The matrix (list of lists), row list or button to be shown + after sending the message. This parameter will only work if + you have signed in as a bot. You can also pass your own + :tl:`ReplyMarkup` here. + + silent (`bool`, optional): + Whether the message should notify people with sound or not. + Defaults to `False` (send with a notification sound unless + the person has the chat muted). Set it to `True` to alter + this behaviour. + + background (`bool`, optional): + Whether the message should be send in background. + + supports_streaming (`bool`, optional): + Whether the sent video supports streaming or not. Note that + Telegram only recognizes as streamable some formats like MP4, + and others like AVI or MKV will not work. You should convert + these to MP4 before sending if you want them to be streamable. + Unsupported formats will result in ``VideoContentTypeError``. + + schedule (`hints.DateLike`, optional): + If set, the file won't send immediately, and instead + it will be scheduled to be automatically sent at a later + time. + + comment_to (`int` | `Message `, optional): + Similar to ``reply_to``, but replies in the linked group of a + broadcast channel instead (effectively leaving a "comment to" + the specified message). + + This parameter takes precedence over ``reply_to``. If there is + no linked chat, `telethon.errors.sgIdInvalidError` is raised. + + ttl (`int`. optional): + The Time-To-Live of the file (also known as "self-destruct timer" + or "self-destructing media"). If set, files can only be viewed for + a short period of time before they disappear from the message + history automatically. + + The value must be at least 1 second, and at most 60 seconds, + otherwise Telegram will ignore this parameter. + + Not all types of media can be used with this parameter, such + as text documents, which will fail with ``TtlMediaInvalidError``. + + nosound_video (`bool`, optional): + Only applicable when sending a video file without an audio + track. If set to ``True``, the video will be displayed in + Telegram as a video. If set to ``False``, Telegram will attempt + to display the video as an animated gif. (It may still display + as a video due to other factors.) The value is ignored if set + on non-video files. This is set to ``True`` for albums, as gifs + cannot be sent in albums. + + send_as (`entity`): + Unique identifier (int) or username (str) of the chat or channel to send the message as. + You can use this to send the message on behalf of a chat or channel where you have appropriate permissions. + Use the GetSendAs to return the list of message sender identifiers, which can be used to send messages in the chat, + This setting applies to the current message and will remain effective for future messages unless explicitly changed. + To set this behavior permanently for all messages, use SaveDefaultSendAs. + + message_effect_id (`int`, optional): + Unique identifier of the message effect to be added to the message; for private chats only + + Returns + The `Message ` (or messages) + containing the sent file, or messages if a list of them was passed. + + Example + .. code-block:: python + + # Normal files like photos + await client.send_file(chat, '/my/photos/me.jpg', caption="It's me!") + # or + await client.send_message(chat, "It's me!", file='/my/photos/me.jpg') + + # Voice notes or round videos + await client.send_file(chat, '/my/songs/song.mp3', voice_note=True) + await client.send_file(chat, '/my/videos/video.mp4', video_note=True) + + # Custom thumbnails + await client.send_file(chat, '/my/documents/doc.txt', thumb='photo.jpg') + + # Only documents + await client.send_file(chat, '/my/photos/photo.png', force_document=True) + + # Albums + await client.send_file(chat, [ + '/my/photos/holiday1.jpg', + '/my/photos/holiday2.jpg', + '/my/drawings/portrait.png' + ]) + + # Printing upload progress + def callback(current, total): + print('Uploaded', current, 'out of', total, + 'bytes: {:.2%}'.format(current / total)) + + await client.send_file(chat, file, progress_callback=callback) + + # Dices, including dart and other future emoji + from telethon.tl import types + await client.send_file(chat, types.InputMediaDice('')) + await client.send_file(chat, types.InputMediaDice('🎯')) + + # Contacts + await client.send_file(chat, types.InputMediaContact( + phone_number='+34 123 456 789', + first_name='Example', + last_name='', + vcard='' + )) + """ + # TODO Properly implement allow_cache to reuse the sha256 of the file + # i.e. `None` was used + if not file: + raise TypeError('Cannot use {!r} as file'.format(file)) + + if not caption: + caption = '' + + if not formatting_entities: + formatting_entities = [] + + entity = await self.get_input_entity(entity) + if comment_to is not None: + entity, reply_to = await self._get_comment_data(entity, comment_to) + else: + reply_to = utils.get_message_id(reply_to) + + # First check if the user passed an iterable, in which case + # we may want to send grouped. + if utils.is_list_like(file): + sent_count = 0 + used_callback = None if not progress_callback else ( + lambda s, t: progress_callback(sent_count + s, len(file)) + ) + + if utils.is_list_like(caption): + captions = caption + else: + captions = [caption] + + # Check that formatting_entities list is valid + if all(utils.is_list_like(obj) for obj in formatting_entities): + formatting_entities = formatting_entities + elif utils.is_list_like(formatting_entities): + formatting_entities = [formatting_entities] + else: + raise TypeError('The formatting_entities argument must be a list or a sequence of lists') + + # Check that all entities in all lists are of the correct type + if not all(isinstance(ent, types.TypeMessageEntity) for sublist in formatting_entities for ent in sublist): + raise TypeError('All entities must be instances of ') + + result = [] + while file: + result += await self._send_album( + entity, file[:10], caption=captions[:10], formatting_entities=formatting_entities[:10], + progress_callback=used_callback, reply_to=reply_to, + parse_mode=parse_mode, silent=silent, schedule=schedule, + supports_streaming=supports_streaming, clear_draft=clear_draft, + force_document=force_document, background=background, + send_as=send_as, message_effect_id=message_effect_id + ) + file = file[10:] + captions = captions[10:] + formatting_entities = formatting_entities[10:] + sent_count += 10 + + return result + + if formatting_entities: + msg_entities = formatting_entities + else: + caption, msg_entities =\ + await self._parse_message_text(caption, parse_mode) + + file_handle, media, image = await self._file_to_media( + file, force_document=force_document, + mime_type=mime_type, + file_size=file_size, + progress_callback=progress_callback, + attributes=attributes, allow_cache=allow_cache, thumb=thumb, + voice_note=voice_note, video_note=video_note, + supports_streaming=supports_streaming, ttl=ttl, + nosound_video=nosound_video, + ) + + # e.g. invalid cast from :tl:`MessageMediaWebPage` + if not media: + raise TypeError('Cannot use {!r} as file'.format(file)) + + markup = self.build_reply_markup(buttons) + reply_to = None if reply_to is None else types.InputReplyToMessage(reply_to) + request = functions.messages.SendMediaRequest( + entity, media, reply_to=reply_to, message=caption, + entities=msg_entities, reply_markup=markup, silent=silent, + schedule_date=schedule, clear_draft=clear_draft, + background=background, + send_as=await self.get_input_entity(send_as) if send_as else None, + effect=message_effect_id + ) + return self._get_response_message(request, await self(request), entity) + + async def _send_album(self: 'TelegramClient', entity, files, caption='', + formatting_entities=None, + progress_callback=None, reply_to=None, + parse_mode=(), silent=None, schedule=None, + supports_streaming=None, clear_draft=None, + force_document=False, background=None, ttl=None, + send_as: typing.Optional['hints.EntityLike'] = None, + message_effect_id: typing.Optional[int] = None): + """Specialized version of .send_file for albums""" + # We don't care if the user wants to avoid cache, we will use it + # anyway. Why? The cached version will be exactly the same thing + # we need to produce right now to send albums (uploadMedia), and + # cache only makes a difference for documents where the user may + # want the attributes used on them to change. + # + # In theory documents can be sent inside the albums, but they appear + # as different messages (not inside the album), and the logic to set + # the attributes/avoid cache is already written in .send_file(). + entity = await self.get_input_entity(entity) + if not utils.is_list_like(caption): + caption = (caption,) + if not all(isinstance(obj, list) for obj in formatting_entities): + formatting_entities = (formatting_entities,) + + captions = [] + # If the formatting_entities argument is provided, we don't use parse_mode + if formatting_entities: + # Pop from the end (so reverse) + capt_with_ent = itertools.zip_longest(reversed(caption), reversed(formatting_entities), fillvalue=None) + for msg_caption, msg_entities in capt_with_ent: + captions.append((msg_caption, msg_entities)) + else: + for c in reversed(caption): # Pop from the end (so reverse) + captions.append(await self._parse_message_text(c or '', parse_mode)) + + reply_to = utils.get_message_id(reply_to) + + used_callback = None if not progress_callback else ( + # use an integer when sent matches total, to easily determine a file has been fully sent + lambda s, t: progress_callback(sent_count + 1 if s == t else sent_count + s / t, len(files)) + ) + + # Need to upload the media first, but only if they're not cached yet + media = [] + for sent_count, file in enumerate(files): + # Albums want :tl:`InputMedia` which, in theory, includes + # :tl:`InputMediaUploadedPhoto`. However, using that will + # make it `raise MediaInvalidError`, so we need to upload + # it as media and then convert that to :tl:`InputMediaPhoto`. + fh, fm, _ = await self._file_to_media( + file, supports_streaming=supports_streaming, + force_document=force_document, ttl=ttl, + progress_callback=used_callback, nosound_video=True) + if isinstance(fm, (types.InputMediaUploadedPhoto, types.InputMediaPhotoExternal)): + r = await self(functions.messages.UploadMediaRequest( + entity, media=fm + )) + + fm = utils.get_input_media(r.photo) + elif isinstance(fm, (types.InputMediaUploadedDocument, types.InputMediaDocumentExternal)): + r = await self(functions.messages.UploadMediaRequest( + entity, media=fm + )) + + fm = utils.get_input_media( + r.document, supports_streaming=supports_streaming) + + if captions: + caption, msg_entities = captions.pop() + else: + caption, msg_entities = '', None + media.append(types.InputSingleMedia( + fm, + message=caption, + entities=msg_entities + # random_id is autogenerated + )) + + # Now we can construct the multi-media request + request = functions.messages.SendMultiMediaRequest( + entity, reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to), multi_media=media, + silent=silent, schedule_date=schedule, clear_draft=clear_draft, + background=background, + send_as=await self.get_input_entity(send_as) if send_as else None, + effect=message_effect_id + ) + result = await self(request) + + random_ids = [m.random_id for m in media] + return self._get_response_message(random_ids, result, entity) + + async def upload_file( + self: 'TelegramClient', + file: 'hints.FileLike', + *, + part_size_kb: float = None, + file_size: int = None, + file_name: str = None, + use_cache: type = None, + key: bytes = None, + iv: bytes = None, + progress_callback: 'hints.ProgressCallback' = None) -> 'types.TypeInputFile': + """ + Uploads a file to Telegram's servers, without sending it. + + .. note:: + + Generally, you want to use `send_file` instead. + + This method returns a handle (an instance of :tl:`InputFile` or + :tl:`InputFileBig`, as required) which can be later used before + it expires (they are usable during less than a day). + + Uploading a file will simply return a "handle" to the file stored + remotely in the Telegram servers, which can be later used on. This + will **not** upload the file to your own chat or any chat at all. + + Arguments + file (`str` | `bytes` | `file`): + The path of the file, byte array, or stream that will be sent. + Note that if a byte array or a stream is given, a filename + or its type won't be inferred, and it will be sent as an + "unnamed application/octet-stream". + + part_size_kb (`int`, optional): + Chunk size when uploading files. The larger, the less + requests will be made (up to 512KB maximum). + + file_size (`int`, optional): + The size of the file to be uploaded, which will be determined + automatically if not specified. + + If the file size can't be determined beforehand, the entire + file will be read in-memory to find out how large it is. + + file_name (`str`, optional): + The file name which will be used on the resulting InputFile. + If not specified, the name will be taken from the ``file`` + and if this is not a `str`, it will be ``"unnamed"``. + + use_cache (`type`, optional): + This parameter currently does nothing, but is kept for + backward-compatibility (and it may get its use back in + the future). + + key ('bytes', optional): + In case of an encrypted upload (secret chats) a key is supplied + + iv ('bytes', optional): + In case of an encrypted upload (secret chats) an iv is supplied + + progress_callback (`callable`, optional): + A callback function accepting two parameters: + ``(sent bytes, total)``. + + When sending an album, the callback will receive a number + between 0 and the amount of files as the "sent" parameter, + and the amount of files as the "total". Note that the first + parameter will be a floating point number to indicate progress + within a file (e.g. ``2.5`` means it has sent 50% of the third + file, because it's between 2 and 3). + + Returns + :tl:`InputFileBig` if the file size is larger than 10MB, + `InputSizedFile ` + (subclass of :tl:`InputFile`) otherwise. + + Example + .. code-block:: python + + # Photos as photo and document + file = await client.upload_file('photo.jpg') + await client.send_file(chat, file) # sends as photo + await client.send_file(chat, file, force_document=True) # sends as document + + file.name = 'not a photo.jpg' + await client.send_file(chat, file, force_document=True) # document, new name + + # As song or as voice note + file = await client.upload_file('song.ogg') + await client.send_file(chat, file) # sends as song + await client.send_file(chat, file, voice_note=True) # sends as voice note + """ + if isinstance(file, (types.InputFile, types.InputFileBig)): + return file # Already uploaded + + pos = 0 + async with helpers._FileStream(file, file_size=file_size) as stream: + # Opening the stream will determine the correct file size + file_size = stream.file_size + + if not part_size_kb: + part_size_kb = utils.get_appropriated_part_size(file_size) + + if part_size_kb > 512: + raise ValueError('The part size must be less or equal to 512KB') + + part_size = int(part_size_kb * 1024) + if part_size % 1024 != 0: + raise ValueError( + 'The part size must be evenly divisible by 1024') + + # Set a default file name if None was specified + file_id = helpers.generate_random_long() + if not file_name: + file_name = stream.name or str(file_id) + + # If the file name lacks extension, add it if possible. + # Else Telegram complains with `PHOTO_EXT_INVALID_ERROR` + # even if the uploaded image is indeed a photo. + if not os.path.splitext(file_name)[-1]: + file_name += utils._get_extension(stream) + + # Determine whether the file is too big (over 10MB) or not + # Telegram does make a distinction between smaller or larger files + is_big = file_size > 10 * 1024 * 1024 + hash_md5 = hashlib.md5() + + part_count = (file_size + part_size - 1) // part_size + self._log[__name__].info('Uploading file of %d bytes in %d chunks of %d', + file_size, part_count, part_size) + + pos = 0 + for part_index in range(part_count): + # Read the file by in chunks of size part_size + part = await helpers._maybe_await(stream.read(part_size)) + + if not isinstance(part, bytes): + raise TypeError( + 'file descriptor returned {}, not bytes (you must ' + 'open the file in bytes mode)'.format(type(part))) + + # `file_size` could be wrong in which case `part` may not be + # `part_size` before reaching the end. + if len(part) != part_size and part_index < part_count - 1: + raise ValueError( + 'read less than {} before reaching the end; either ' + '`file_size` or `read` are wrong'.format(part_size)) + + pos += len(part) + + # Encryption part if needed + if key and iv: + part = AES.encrypt_ige(part, key, iv) + + if not is_big: + # Bit odd that MD5 is only needed for small files and not + # big ones with more chance for corruption, but that's + # what Telegram wants. + hash_md5.update(part) + + # The SavePartRequest is different depending on whether + # the file is too large or not (over or less than 10MB) + if is_big: + request = functions.upload.SaveBigFilePartRequest( + file_id, part_index, part_count, part) + else: + request = functions.upload.SaveFilePartRequest( + file_id, part_index, part) + + result = await self(request) + if result: + self._log[__name__].debug('Uploaded %d/%d', + part_index + 1, part_count) + if progress_callback: + await helpers._maybe_await(progress_callback(pos, file_size)) + else: + raise RuntimeError( + 'Failed to upload file part {}.'.format(part_index)) + + if is_big: + return types.InputFileBig(file_id, part_count, file_name) + else: + return custom.InputSizedFile( + file_id, part_count, file_name, md5=hash_md5, size=file_size + ) + + # endregion + + async def _file_to_media( + self, file, force_document=False, file_size=None, + progress_callback=None, attributes=None, thumb=None, + allow_cache=True, voice_note=False, video_note=False, + supports_streaming=False, mime_type=None, as_image=None, + ttl=None, nosound_video=None): + if not file: + return None, None, None + + if isinstance(file, pathlib.Path): + file = str(file.absolute()) + + is_image = utils.is_image(file) + if as_image is None: + as_image = is_image and not force_document + + # `aiofiles` do not base `io.IOBase` but do have `read`, so we + # just check for the read attribute to see if it's file-like. + if not isinstance(file, (str, bytes, types.InputFile, types.InputFileBig)) \ + and not hasattr(file, 'read'): + # The user may pass a Message containing media (or the media, + # or anything similar) that should be treated as a file. Try + # getting the input media for whatever they passed and send it. + # + # We pass all attributes since these will be used if the user + # passed :tl:`InputFile`, and all information may be relevant. + try: + return (None, utils.get_input_media( + file, + is_photo=as_image, + attributes=attributes, + force_document=force_document, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + ttl=ttl + ), as_image) + except TypeError: + # Can't turn whatever was given into media + return None, None, as_image + + media = None + file_handle = None + + if isinstance(file, (types.InputFile, types.InputFileBig)): + file_handle = file + elif not isinstance(file, str) or os.path.isfile(file): + file_handle = await self.upload_file( + _resize_photo_if_needed(file, as_image), + file_size=file_size, + progress_callback=progress_callback + ) + elif re.match('https?://', file): + if as_image: + media = types.InputMediaPhotoExternal(file, ttl_seconds=ttl) + else: + media = types.InputMediaDocumentExternal(file, ttl_seconds=ttl) + else: + bot_file = utils.resolve_bot_file_id(file) + if bot_file: + media = utils.get_input_media(bot_file, ttl=ttl) + + if media: + pass # Already have media, don't check the rest + elif not file_handle: + raise ValueError( + 'Failed to convert {} to media. Not an existing file, ' + 'an HTTP URL or a valid bot-API-like file ID'.format(file) + ) + elif as_image: + media = types.InputMediaUploadedPhoto(file_handle, ttl_seconds=ttl) + else: + attributes, mime_type = utils.get_attributes( + file, + mime_type=mime_type, + attributes=attributes, + force_document=force_document and not is_image, + voice_note=voice_note, + video_note=video_note, + supports_streaming=supports_streaming, + thumb=thumb + ) + + if not thumb: + thumb = None + else: + if isinstance(thumb, pathlib.Path): + thumb = str(thumb.absolute()) + thumb = await self.upload_file(thumb, file_size=file_size) + + # setting `nosound_video` to `True` doesn't affect videos with sound + # instead it prevents sending silent videos as GIFs + nosound_video = nosound_video if mime_type.split("/")[0] == 'video' else None + + media = types.InputMediaUploadedDocument( + file=file_handle, + mime_type=mime_type, + attributes=attributes, + thumb=thumb, + force_file=force_document and not is_image, + ttl_seconds=ttl, + nosound_video=nosound_video + ) + return file_handle, media, as_image + + # endregion diff --git a/module/scripts/lib/telethon/client/users.py b/module/scripts/lib/telethon/client/users.py new file mode 100644 index 0000000..994856b --- /dev/null +++ b/module/scripts/lib/telethon/client/users.py @@ -0,0 +1,620 @@ +import asyncio +import datetime +import itertools +import time +import typing + +from .. import errors, helpers, utils, hints +from ..errors import MultiError, RPCError +from ..helpers import retry_range +from ..tl import TLRequest, types, functions + +_NOT_A_REQUEST = lambda: TypeError('You can only invoke requests, not types!') + +if typing.TYPE_CHECKING: + from .telegramclient import TelegramClient + + +def _fmt_flood(delay, request, *, early=False, td=datetime.timedelta): + return ( + 'Sleeping%s for %ds (%s) on %s flood wait', + ' early' if early else '', + delay, + td(seconds=delay), + request.__class__.__name__ + ) + + +class UserMethods: + async def __call__(self: 'TelegramClient', request, ordered=False, flood_sleep_threshold=None): + return await self._call(self._sender, request, ordered=ordered) + + async def _call(self: 'TelegramClient', sender, request, ordered=False, flood_sleep_threshold=None): + if self._loop is not None and self._loop != helpers.get_running_loop(): + raise RuntimeError('The asyncio event loop must not change after connection (see the FAQ for details)') + # if the loop is None it will fail with a connection error later on + + if flood_sleep_threshold is None: + flood_sleep_threshold = self.flood_sleep_threshold + requests = list(request) if utils.is_list_like(request) else [request] + request = list(request) if utils.is_list_like(request) else request + for i, r in enumerate(requests): + if not isinstance(r, TLRequest): + raise _NOT_A_REQUEST() + await r.resolve(self, utils) + + # Avoid making the request if it's already in a flood wait + if r.CONSTRUCTOR_ID in self._flood_waited_requests: + due = self._flood_waited_requests[r.CONSTRUCTOR_ID] + diff = round(due - time.time()) + if diff <= 3: # Flood waits below 3 seconds are "ignored" + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + elif diff <= flood_sleep_threshold: + self._log[__name__].info(*_fmt_flood(diff, r, early=True)) + await asyncio.sleep(diff) + self._flood_waited_requests.pop(r.CONSTRUCTOR_ID, None) + else: + raise errors.FloodWaitError(request=r, capture=diff) + + if self._no_updates: + if utils.is_list_like(request): + request[i] = functions.InvokeWithoutUpdatesRequest(r) + else: + # This should only run once as requests should be a list of 1 item + request = functions.InvokeWithoutUpdatesRequest(r) + + request_index = 0 + last_error = None + self._last_request = time.time() + + for attempt in retry_range(self._request_retries): + try: + future = sender.send(request, ordered=ordered) + if isinstance(future, list): + results = [] + exceptions = [] + for f in future: + try: + result = await f + except RPCError as e: + exceptions.append(e) + results.append(None) + continue + await utils.maybe_async(self.session.process_entities(result)) + exceptions.append(None) + results.append(result) + request_index += 1 + if any(x is not None for x in exceptions): + raise MultiError(exceptions, results, requests) + else: + return results + else: + result = await future + await utils.maybe_async(self.session.process_entities(result)) + return result + except (errors.ServerError, errors.RpcCallFailError, + errors.RpcMcgetFailError, errors.InterdcCallErrorError, + errors.TimedOutError, + errors.InterdcCallRichErrorError) as e: + last_error = e + self._log[__name__].warning( + 'Telegram is having internal issues %s: %s', + e.__class__.__name__, e) + + await asyncio.sleep(2) + except (errors.FloodWaitError, errors.FloodPremiumWaitError, + errors.SlowModeWaitError, errors.FloodTestPhoneWaitError) as e: + last_error = e + if utils.is_list_like(request): + request = request[request_index] + + # SLOW_MODE_WAIT is chat-specific, not request-specific + if not isinstance(e, errors.SlowModeWaitError): + self._flood_waited_requests\ + [request.CONSTRUCTOR_ID] = time.time() + e.seconds + + # In test servers, FLOOD_WAIT_0 has been observed, and sleeping for + # such a short amount will cause retries very fast leading to issues. + if e.seconds == 0: + e.seconds = 1 + + if e.seconds <= self.flood_sleep_threshold: + self._log[__name__].info(*_fmt_flood(e.seconds, request)) + await asyncio.sleep(e.seconds) + else: + raise + except (errors.PhoneMigrateError, errors.NetworkMigrateError, + errors.UserMigrateError) as e: + last_error = e + self._log[__name__].info('Phone migrated to %d', e.new_dc) + should_raise = isinstance(e, ( + errors.PhoneMigrateError, errors.NetworkMigrateError + )) + if should_raise and await self.is_user_authorized(): + raise + await self._switch_dc(e.new_dc) + + if self._raise_last_call_error and last_error is not None: + raise last_error + raise ValueError('Request was unsuccessful {} time(s)' + .format(attempt)) + + # region Public methods + + async def get_me(self: 'TelegramClient', input_peer: bool = False) \ + -> 'typing.Union[types.User, types.InputPeerUser]': + """ + Gets "me", the current :tl:`User` who is logged in. + + If the user has not logged in yet, this method returns `None`. + + Arguments + input_peer (`bool`, optional): + Whether to return the :tl:`InputPeerUser` version or the normal + :tl:`User`. This can be useful if you just need to know the ID + of yourself. + + Returns + Your own :tl:`User`. + + Example + .. code-block:: python + + me = await client.get_me() + print(me.username) + """ + if input_peer and self._mb_entity_cache.self_id: + return self._mb_entity_cache.get(self._mb_entity_cache.self_id)._as_input_peer() + + try: + me = (await self( + functions.users.GetUsersRequest([types.InputUserSelf()])))[0] + + if not self._mb_entity_cache.self_id: + self._mb_entity_cache.set_self_user(me.id, me.bot, me.access_hash) + + return utils.get_input_peer(me, allow_self=False) if input_peer else me + except errors.UnauthorizedError: + return None + + @property + def _self_id(self: 'TelegramClient') -> typing.Optional[int]: + """ + Returns the ID of the logged-in user, if known. + + This property is used in every update, and some like `updateLoginToken` + occur prior to login, so it gracefully handles when no ID is known yet. + """ + return self._mb_entity_cache.self_id + + async def is_bot(self: 'TelegramClient') -> bool: + """ + Return `True` if the signed-in user is a bot, `False` otherwise. + + Example + .. code-block:: python + + if await client.is_bot(): + print('Beep') + else: + print('Hello') + """ + if self._mb_entity_cache.self_bot is None: + await self.get_me(input_peer=True) + + return self._mb_entity_cache.self_bot + + async def is_user_authorized(self: 'TelegramClient') -> bool: + """ + Returns `True` if the user is authorized (logged in). + + Example + .. code-block:: python + + if not await client.is_user_authorized(): + await client.send_code_request(phone) + code = input('enter code: ') + await client.sign_in(phone, code) + """ + if self._authorized is None: + try: + # Any request that requires authorization will work + await self(functions.updates.GetStateRequest()) + self._authorized = True + except errors.RPCError: + self._authorized = False + + return self._authorized + + async def get_entity( + self: 'TelegramClient', + entity: 'hints.EntitiesLike') -> typing.Union['hints.Entity', typing.List['hints.Entity']]: + """ + Turns the given entity into a valid Telegram :tl:`User`, :tl:`Chat` + or :tl:`Channel`. You can also pass a list or iterable of entities, + and they will be efficiently fetched from the network. + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username is given, **the username will be resolved** making + an API call every time. Resolving usernames is an expensive + operation and will start hitting flood waits around 50 usernames + in a short period of time. + + If you want to get the entity for a *cached* username, you should + first `get_input_entity(username) ` which will + use the cache), and then use `get_entity` with the result of the + previous call. + + Similar limits apply to invite links, and you should use their + ID instead. + + Using phone numbers (from people in your contact list), exact + names, integer IDs or :tl:`Peer` rely on a `get_input_entity` + first, which in turn needs the entity to be in cache, unless + a :tl:`InputPeer` was passed. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``ValueError`` will be raised. + + Returns + :tl:`User`, :tl:`Chat` or :tl:`Channel` corresponding to the + input entity. A list will be returned if more than one was given. + + Example + .. code-block:: python + + from telethon import utils + + me = await client.get_entity('me') + print(utils.get_display_name(me)) + + chat = await client.get_input_entity('username') + async for message in client.iter_messages(chat): + ... + + # Note that you could have used the username directly, but it's + # good to use get_input_entity if you will reuse it a lot. + async for message in client.iter_messages('username'): + ... + + # Note that for this to work the phone number must be in your contacts + some_id = await client.get_peer_id('+34123456789') + """ + single = not utils.is_list_like(entity) + if single: + entity = (entity,) + + # Group input entities by string (resolve username), + # input users (get users), input chat (get chats) and + # input channels (get channels) to get the most entities + # in the less amount of calls possible. + inputs = [] + for x in entity: + if isinstance(x, str): + inputs.append(x) + else: + inputs.append(await self.get_input_entity(x)) + + lists = { + helpers._EntityType.USER: [], + helpers._EntityType.CHAT: [], + helpers._EntityType.CHANNEL: [], + } + for x in inputs: + try: + lists[helpers._entity_type(x)].append(x) + except TypeError: + pass + + users = lists[helpers._EntityType.USER] + chats = lists[helpers._EntityType.CHAT] + channels = lists[helpers._EntityType.CHANNEL] + if users: + # GetUsersRequest has a limit of 200 per call + tmp = [] + while users: + curr, users = users[:200], users[200:] + tmp.extend(await self(functions.users.GetUsersRequest(curr))) + users = tmp + if chats: # TODO Handle chats slice? + chats = (await self( + functions.messages.GetChatsRequest([x.chat_id for x in chats]))).chats + if channels: + channels = (await self( + functions.channels.GetChannelsRequest(channels))).chats + + # Merge users, chats and channels into a single dictionary + id_entity = { + # `get_input_entity` might've guessed the type from a non-marked ID, + # so the only way to match that with the input is by not using marks here. + utils.get_peer_id(x, add_mark=False): x + for x in itertools.chain(users, chats, channels) + } + + # We could check saved usernames and put them into the users, + # chats and channels list from before. While this would reduce + # the amount of ResolveUsername calls, it would fail to catch + # username changes. + result = [] + for x in inputs: + if isinstance(x, str): + result.append(await self._get_entity_from_string(x)) + elif not isinstance(x, types.InputPeerSelf): + result.append(id_entity[utils.get_peer_id(x, add_mark=False)]) + else: + result.append(next( + u for u in id_entity.values() + if isinstance(u, types.User) and u.is_self + )) + + return result[0] if single else result + + async def get_input_entity( + self: 'TelegramClient', + peer: 'hints.EntityLike') -> 'types.TypeInputPeer': + """ + Turns the given entity into its input entity version. + + Most requests use this kind of :tl:`InputPeer`, so this is the most + suitable call to make for those cases. **Generally you should let the + library do its job** and don't worry about getting the input entity + first, but if you're going to use an entity often, consider making the + call: + + Arguments + entity (`str` | `int` | :tl:`Peer` | :tl:`InputPeer`): + If a username or invite link is given, **the library will + use the cache**. This means that it's possible to be using + a username that *changed* or an old invite link (this only + happens if an invite link for a small group chat is used + after it was upgraded to a mega-group). + + If the username or ID from the invite link is not found in + the cache, it will be fetched. The same rules apply to phone + numbers (``'+34 123456789'``) from people in your contact list. + + If an exact name is given, it must be in the cache too. This + is not reliable as different people can share the same name + and which entity is returned is arbitrary, and should be used + only for quick tests. + + If a positive integer ID is given, the entity will be searched + in cached users, chats or channels, without making any call. + + If a negative integer ID is given, the entity will be searched + exactly as either a chat (prefixed with ``-``) or as a channel + (prefixed with ``-100``). + + If a :tl:`Peer` is given, it will be searched exactly in the + cache as either a user, chat or channel. + + If the given object can be turned into an input entity directly, + said operation will be done. + + Unsupported types will raise ``TypeError``. + + If the entity can't be found, ``ValueError`` will be raised. + + Returns + :tl:`InputPeerUser`, :tl:`InputPeerChat` or :tl:`InputPeerChannel` + or :tl:`InputPeerSelf` if the parameter is ``'me'`` or ``'self'``. + + If you need to get the ID of yourself, you should use + `get_me` with ``input_peer=True``) instead. + + Example + .. code-block:: python + + # If you're going to use "username" often in your code + # (make a lot of calls), consider getting its input entity + # once, and then using the "user" everywhere instead. + user = await client.get_input_entity('username') + + # The same applies to IDs, chats or channels. + chat = await client.get_input_entity(-123456789) + """ + # Short-circuit if the input parameter directly maps to an InputPeer + try: + return utils.get_input_peer(peer) + except TypeError: + pass + + # Next in priority is having a peer (or its ID) cached in-memory + try: + # 0x2d45687 == crc32(b'Peer') + if isinstance(peer, int) or peer.SUBCLASS_OF_ID == 0x2d45687: + return self._mb_entity_cache.get(utils.get_peer_id(peer, add_mark=False))._as_input_peer() + except AttributeError: + pass + + # Then come known strings that take precedence + if peer in ('me', 'self'): + return types.InputPeerSelf() + + # No InputPeer, cached peer, or known string. Fetch from disk cache + try: + input_entity = await utils.maybe_async(self.session.get_input_entity(peer)) + return input_entity + except ValueError: + pass + + # Only network left to try + if isinstance(peer, str): + return utils.get_input_peer( + await self._get_entity_from_string(peer)) + + # If we're a bot and the user has messaged us privately users.getUsers + # will work with access_hash = 0. Similar for channels.getChannels. + # If we're not a bot but the user is in our contacts, it seems to work + # regardless. These are the only two special-cased requests. + peer = utils.get_peer(peer) + if isinstance(peer, types.PeerUser): + users = await self(functions.users.GetUsersRequest([ + types.InputUser(peer.user_id, access_hash=0)])) + if users and not isinstance(users[0], types.UserEmpty): + # If the user passed a valid ID they expect to work for + # channels but would be valid for users, we get UserEmpty. + # Avoid returning the invalid empty input peer for that. + # + # We *could* try to guess if it's a channel first, and if + # it's not, work as a chat and try to validate it through + # another request, but that becomes too much work. + return utils.get_input_peer(users[0]) + elif isinstance(peer, types.PeerChat): + return types.InputPeerChat(peer.chat_id) + elif isinstance(peer, types.PeerChannel): + try: + channels = await self(functions.channels.GetChannelsRequest([ + types.InputChannel(peer.channel_id, access_hash=0)])) + return utils.get_input_peer(channels.chats[0]) + except errors.ChannelInvalidError: + pass + + raise ValueError( + 'Could not find the input entity for {} ({}). Please read https://' + 'docs.telethon.dev/en/stable/concepts/entities.html to' + ' find out more details.' + .format(peer, type(peer).__name__) + ) + + async def _get_peer(self: 'TelegramClient', peer: 'hints.EntityLike'): + i, cls = utils.resolve_id(await self.get_peer_id(peer)) + return cls(i) + + async def get_peer_id( + self: 'TelegramClient', + peer: 'hints.EntityLike', + add_mark: bool = True) -> int: + """ + Gets the ID for the given entity. + + This method needs to be ``async`` because `peer` supports usernames, + invite-links, phone numbers (from people in your contact list), etc. + + If ``add_mark is False``, then a positive ID will be returned + instead. By default, bot-API style IDs (signed) are returned. + + Example + .. code-block:: python + + print(await client.get_peer_id('me')) + """ + if isinstance(peer, int): + return utils.get_peer_id(peer, add_mark=add_mark) + + try: + if peer.SUBCLASS_OF_ID not in (0x2d45687, 0xc91c90b6): + # 0x2d45687, 0xc91c90b6 == crc32(b'Peer') and b'InputPeer' + peer = await self.get_input_entity(peer) + except AttributeError: + peer = await self.get_input_entity(peer) + + if isinstance(peer, types.InputPeerSelf): + peer = await self.get_me(input_peer=True) + + return utils.get_peer_id(peer, add_mark=add_mark) + + # endregion + + # region Private methods + + async def _get_entity_from_string(self: 'TelegramClient', string): + """ + Gets a full entity from the given string, which may be a phone or + a username, and processes all the found entities on the session. + The string may also be a user link, or a channel/chat invite link. + + This method has the side effect of adding the found users to the + session database, so it can be queried later without API calls, + if this option is enabled on the session. + + Returns the found entity, or raises TypeError if not found. + """ + phone = utils.parse_phone(string) + if phone: + try: + for user in (await self( + functions.contacts.GetContactsRequest(0))).users: + if user.phone == phone: + return user + except errors.BotMethodInvalidError: + raise ValueError('Cannot get entity by phone number as a ' + 'bot (try using integer IDs, not strings)') + elif string.lower() in ('me', 'self'): + return await self.get_me() + else: + username, is_join_chat = utils.parse_username(string) + if is_join_chat: + invite = await self( + functions.messages.CheckChatInviteRequest(username)) + + if isinstance(invite, types.ChatInvite): + raise ValueError( + 'Cannot get entity from a channel (or group) ' + 'that you are not part of. Join the group and retry' + ) + elif isinstance(invite, types.ChatInviteAlready): + return invite.chat + elif username: + try: + result = await self( + functions.contacts.ResolveUsernameRequest(username)) + except errors.UsernameNotOccupiedError as e: + raise ValueError('No user has "{}" as username' + .format(username)) from e + + try: + pid = utils.get_peer_id(result.peer, add_mark=False) + if isinstance(result.peer, types.PeerUser): + return next(x for x in result.users if x.id == pid) + else: + return next(x for x in result.chats if x.id == pid) + except StopIteration: + pass + try: + # Nobody with this username, maybe it's an exact name/title + input_entity = await utils.maybe_async(self.session.get_input_entity(string)) + return await self.get_entity(input_entity) + except ValueError: + pass + + raise ValueError( + 'Cannot find any entity corresponding to "{}"'.format(string) + ) + + async def _get_input_dialog(self: 'TelegramClient', dialog): + """ + Returns a :tl:`InputDialogPeer`. This is a bit tricky because + it may or not need access to the client to convert what's given + into an input entity. + """ + try: + if dialog.SUBCLASS_OF_ID == 0xa21c9795: # crc32(b'InputDialogPeer') + dialog.peer = await self.get_input_entity(dialog.peer) + return dialog + elif dialog.SUBCLASS_OF_ID == 0xc91c90b6: # crc32(b'InputPeer') + return types.InputDialogPeer(dialog) + except AttributeError: + pass + + return types.InputDialogPeer(await self.get_input_entity(dialog)) + + async def _get_input_notify(self: 'TelegramClient', notify): + """ + Returns a :tl:`InputNotifyPeer`. This is a bit tricky because + it may or not need access to the client to convert what's given + into an input entity. + """ + try: + if notify.SUBCLASS_OF_ID == 0x58981615: + if isinstance(notify, types.InputNotifyPeer): + notify.peer = await self.get_input_entity(notify.peer) + return notify + except AttributeError: + pass + + return types.InputNotifyPeer(await self.get_input_entity(notify)) + + # endregion diff --git a/module/scripts/lib/telethon/crypto/__init__.py b/module/scripts/lib/telethon/crypto/__init__.py new file mode 100644 index 0000000..69be1da --- /dev/null +++ b/module/scripts/lib/telethon/crypto/__init__.py @@ -0,0 +1,10 @@ +""" +This module contains several utilities regarding cryptographic purposes, +such as the AES IGE mode used by Telegram, the authorization key bound with +their data centers, and so on. +""" +from .aes import AES +from .aesctr import AESModeCTR +from .authkey import AuthKey +from .factorization import Factorization +from .cdndecrypter import CdnDecrypter diff --git a/module/scripts/lib/telethon/crypto/aes.py b/module/scripts/lib/telethon/crypto/aes.py new file mode 100644 index 0000000..3cfcc1a --- /dev/null +++ b/module/scripts/lib/telethon/crypto/aes.py @@ -0,0 +1,111 @@ +""" +AES IGE implementation in Python. + +If available, cryptg will be used instead, otherwise +if available, libssl will be used instead, otherwise +the Python implementation will be used. +""" +import os +import pyaes +import logging +from . import libssl + + +__log__ = logging.getLogger(__name__) + + +try: + import cryptg + __log__.info('cryptg detected, it will be used for encryption') +except ImportError: + cryptg = None + if libssl.encrypt_ige and libssl.decrypt_ige: + __log__.info('libssl detected, it will be used for encryption') + else: + __log__.info('cryptg module not installed and libssl not found, ' + 'falling back to (slower) Python encryption') + + +class AES: + """ + Class that servers as an interface to encrypt and decrypt + text through the AES IGE mode. + """ + @staticmethod + def decrypt_ige(cipher_text, key, iv): + """ + Decrypts the given text in 16-bytes blocks by using the + given key and 32-bytes initialization vector. + """ + if cryptg: + return cryptg.decrypt_ige(cipher_text, key, iv) + if libssl.decrypt_ige: + return libssl.decrypt_ige(cipher_text, key, iv) + + iv1 = iv[:len(iv) // 2] + iv2 = iv[len(iv) // 2:] + + aes = pyaes.AES(key) + + plain_text = [] + blocks_count = len(cipher_text) // 16 + + cipher_text_block = [0] * 16 + for block_index in range(blocks_count): + for i in range(16): + cipher_text_block[i] = \ + cipher_text[block_index * 16 + i] ^ iv2[i] + + plain_text_block = aes.decrypt(cipher_text_block) + + for i in range(16): + plain_text_block[i] ^= iv1[i] + + iv1 = cipher_text[block_index * 16:block_index * 16 + 16] + iv2 = plain_text_block + + plain_text.extend(plain_text_block) + + return bytes(plain_text) + + @staticmethod + def encrypt_ige(plain_text, key, iv): + """ + Encrypts the given text in 16-bytes blocks by using the + given key and 32-bytes initialization vector. + """ + padding = len(plain_text) % 16 + if padding: + plain_text += os.urandom(16 - padding) + + if cryptg: + return cryptg.encrypt_ige(plain_text, key, iv) + if libssl.encrypt_ige: + return libssl.encrypt_ige(plain_text, key, iv) + + iv1 = iv[:len(iv) // 2] + iv2 = iv[len(iv) // 2:] + + aes = pyaes.AES(key) + + cipher_text = [] + blocks_count = len(plain_text) // 16 + + for block_index in range(blocks_count): + plain_text_block = list( + plain_text[block_index * 16:block_index * 16 + 16] + ) + for i in range(16): + plain_text_block[i] ^= iv1[i] + + cipher_text_block = aes.encrypt(plain_text_block) + + for i in range(16): + cipher_text_block[i] ^= iv2[i] + + iv1 = cipher_text_block + iv2 = plain_text[block_index * 16:block_index * 16 + 16] + + cipher_text.extend(cipher_text_block) + + return bytes(cipher_text) diff --git a/module/scripts/lib/telethon/crypto/aesctr.py b/module/scripts/lib/telethon/crypto/aesctr.py new file mode 100644 index 0000000..3442290 --- /dev/null +++ b/module/scripts/lib/telethon/crypto/aesctr.py @@ -0,0 +1,42 @@ +""" +This module holds the AESModeCTR wrapper class. +""" +import pyaes + + +class AESModeCTR: + """Wrapper around pyaes.AESModeOfOperationCTR mode with custom IV""" + # TODO Maybe make a pull request to pyaes to support iv on CTR + + def __init__(self, key, iv): + """ + Initializes the AES CTR mode with the given key/iv pair. + + :param key: the key to be used as bytes. + :param iv: the bytes initialization vector. Must have a length of 16. + """ + # TODO Use libssl if available + assert isinstance(key, bytes) + self._aes = pyaes.AESModeOfOperationCTR(key) + + assert isinstance(iv, bytes) + assert len(iv) == 16 + self._aes._counter._counter = list(iv) + + def encrypt(self, data): + """ + Encrypts the given plain text through AES CTR. + + :param data: the plain text to be encrypted. + :return: the encrypted cipher text. + """ + return self._aes.encrypt(data) + + def decrypt(self, data): + """ + Decrypts the given cipher text through AES CTR + + :param data: the cipher text to be decrypted. + :return: the decrypted plain text. + """ + return self._aes.decrypt(data) diff --git a/module/scripts/lib/telethon/crypto/authkey.py b/module/scripts/lib/telethon/crypto/authkey.py new file mode 100644 index 0000000..8475ec1 --- /dev/null +++ b/module/scripts/lib/telethon/crypto/authkey.py @@ -0,0 +1,63 @@ +""" +This module holds the AuthKey class. +""" +import struct +from hashlib import sha1 + +from ..extensions import BinaryReader + + +class AuthKey: + """ + Represents an authorization key, used to encrypt and decrypt + messages sent to Telegram's data centers. + """ + def __init__(self, data): + """ + Initializes a new authorization key. + + :param data: the data in bytes that represent this auth key. + """ + self.key = data + + @property + def key(self): + return self._key + + @key.setter + def key(self, value): + if not value: + self._key = self.aux_hash = self.key_id = None + return + + if isinstance(value, type(self)): + self._key, self.aux_hash, self.key_id = \ + value._key, value.aux_hash, value.key_id + return + + self._key = value + with BinaryReader(sha1(self._key).digest()) as reader: + self.aux_hash = reader.read_long(signed=False) + reader.read(4) + self.key_id = reader.read_long(signed=False) + + # TODO This doesn't really fit here, it's only used in authentication + def calc_new_nonce_hash(self, new_nonce, number): + """ + Calculates the new nonce hash based on the current attributes. + + :param new_nonce: the new nonce to be hashed. + :param number: number to prepend before the hash. + :return: the hash for the given new nonce. + """ + new_nonce = new_nonce.to_bytes(32, 'little', signed=True) + data = new_nonce + struct.pack(' 1: + break + + p, q = g, pq // g + return (p, q) if p < q else (q, p) + + @staticmethod + def gcd(a, b): + """ + Calculates the Greatest Common Divisor. + + :param a: the first number. + :param b: the second number. + :return: GCD(a, b) + """ + while b: + a, b = b, a % b + + return a diff --git a/module/scripts/lib/telethon/crypto/libssl.py b/module/scripts/lib/telethon/crypto/libssl.py new file mode 100644 index 0000000..e0fea00 --- /dev/null +++ b/module/scripts/lib/telethon/crypto/libssl.py @@ -0,0 +1,140 @@ +""" +Helper module around the system's libssl library if available for IGE mode. +""" +import ctypes +import ctypes.util +import platform +import sys +try: + import ctypes.macholib.dyld +except ImportError: + pass +import logging +import os + +__log__ = logging.getLogger(__name__) + + +def _find_ssl_lib(): + lib = ctypes.util.find_library('ssl') + # macOS 10.15 segfaults on unversioned crypto libraries. + # We therefore pin the current stable version here + # Credit for fix goes to Sarah Harvey (@worldwise001) + # https://www.shh.sh/2020/01/04/python-abort-trap-6.html + if sys.platform == 'darwin': + release, _version_info, _machine = platform.mac_ver() + ver, major, *_ = release.split('.') + # macOS 10.14 "mojave" is the last known major release + # to support unversioned libssl.dylib. Anything above + # needs specific versions + if int(ver) > 10 or int(ver) == 10 and int(major) > 14: + lib = ( + ctypes.util.find_library('libssl.46') or + ctypes.util.find_library('libssl.44') or + ctypes.util.find_library('libssl.42') + ) + if not lib: + raise OSError('no library called "ssl" found') + + # First, let ctypes try to handle it itself. + try: + libssl = ctypes.cdll.LoadLibrary(lib) + except OSError: + pass + else: + return libssl + + # This is a best-effort attempt at finding the full real path of lib. + # + # Unfortunately ctypes doesn't tell us *where* it finds the library, + # so we have to do that ourselves. + try: + # This is not documented, so it could fail. Be on the safe side. + paths = ctypes.macholib.dyld.DEFAULT_LIBRARY_FALLBACK + except AttributeError: + paths = [ + os.path.expanduser("~/lib"), + "/usr/local/lib", + "/lib", + "/usr/lib", + ] + + for path in paths: + if os.path.isdir(path): + for root, _, files in os.walk(path): + if lib in files: + # Manually follow symbolic links on *nix systems. + # Fix for https://github.com/LonamiWebs/Telethon/issues/1167 + lib = os.path.realpath(os.path.join(root, lib)) + return ctypes.cdll.LoadLibrary(lib) + else: + raise OSError('no absolute path for "%s" and cannot load by name' % lib) + + +try: + _libssl = _find_ssl_lib() +except OSError as e: + # See https://github.com/LonamiWebs/Telethon/issues/1167 + # Sometimes `find_library` returns improper filenames. + __log__.info('Failed to load SSL library: %s (%s)', type(e), e) + _libssl = None + +if not _libssl: + decrypt_ige = None + encrypt_ige = None +else: + # https://github.com/openssl/openssl/blob/master/include/openssl/aes.h + AES_ENCRYPT = ctypes.c_int(1) + AES_DECRYPT = ctypes.c_int(0) + AES_MAXNR = 14 + + class AES_KEY(ctypes.Structure): + """Helper class representing an AES key""" + _fields_ = [ + ('rd_key', ctypes.c_uint32 * (4 * (AES_MAXNR + 1))), + ('rounds', ctypes.c_uint), + ] + + def decrypt_ige(cipher_text, key, iv): + aes_key = AES_KEY() + key_len = ctypes.c_int(8 * len(key)) + key = (ctypes.c_ubyte * len(key))(*key) + iv = (ctypes.c_ubyte * len(iv))(*iv) + + in_len = ctypes.c_size_t(len(cipher_text)) + in_ptr = (ctypes.c_ubyte * len(cipher_text))(*cipher_text) + out_ptr = (ctypes.c_ubyte * len(cipher_text))() + + _libssl.AES_set_decrypt_key(key, key_len, ctypes.byref(aes_key)) + _libssl.AES_ige_encrypt( + ctypes.byref(in_ptr), + ctypes.byref(out_ptr), + in_len, + ctypes.byref(aes_key), + ctypes.byref(iv), + AES_DECRYPT + ) + + return bytes(out_ptr) + + def encrypt_ige(plain_text, key, iv): + aes_key = AES_KEY() + key_len = ctypes.c_int(8 * len(key)) + key = (ctypes.c_ubyte * len(key))(*key) + iv = (ctypes.c_ubyte * len(iv))(*iv) + + in_len = ctypes.c_size_t(len(plain_text)) + in_ptr = (ctypes.c_ubyte * len(plain_text))(*plain_text) + out_ptr = (ctypes.c_ubyte * len(plain_text))() + + _libssl.AES_set_encrypt_key(key, key_len, ctypes.byref(aes_key)) + _libssl.AES_ige_encrypt( + ctypes.byref(in_ptr), + ctypes.byref(out_ptr), + in_len, + ctypes.byref(aes_key), + ctypes.byref(iv), + AES_ENCRYPT + ) + + return bytes(out_ptr) diff --git a/module/scripts/lib/telethon/crypto/rsa.py b/module/scripts/lib/telethon/crypto/rsa.py new file mode 100644 index 0000000..91ca7ba --- /dev/null +++ b/module/scripts/lib/telethon/crypto/rsa.py @@ -0,0 +1,165 @@ +""" +This module holds several utilities regarding RSA and server fingerprints. +""" +import os +import struct +from hashlib import sha1 +try: + import rsa + import rsa.core +except ImportError: + rsa = None + raise ImportError('Missing module "rsa", please install via pip.') + +from ..tl import TLObject + + +# {fingerprint: (Crypto.PublicKey.RSA._RSAobj, old)} dictionary +_server_keys = {} + + +def get_byte_array(integer): + """Return the variable length bytes corresponding to the given int""" + # Operate in big endian (unlike most of Telegram API) since: + # > "...pq is a representation of a natural number + # (in binary *big endian* format)..." + # > "...current value of dh_prime equals + # (in *big-endian* byte order)..." + # Reference: https://core.telegram.org/mtproto/auth_key + return int.to_bytes( + integer, + (integer.bit_length() + 8 - 1) // 8, # 8 bits per byte, + byteorder='big', + signed=False + ) + + +def _compute_fingerprint(key): + """ + Given a RSA key, computes its fingerprint like Telegram does. + + :param key: the Crypto.RSA key. + :return: its 8-bytes-long fingerprint. + """ + n = TLObject.serialize_bytes(get_byte_array(key.n)) + e = TLObject.serialize_bytes(get_byte_array(key.e)) + # Telegram uses the last 8 bytes as the fingerprint + return struct.unpack('".' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PackShortNameOccupiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A stickerpack with this name already exists' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PackTitleInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The stickerpack title is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ParticipantsTooFewError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Not enough participants' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ParticipantCallFailedError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Failure while making call' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ParticipantIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified participant ID is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ParticipantJoinMissingError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("Trying to enable a presentation, when the user hasn't joined the Video Chat with [phone.joinGroupCall](https://core.telegram.org/method/phone.joinGroupCall)" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ParticipantVersionOutdatedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The other participant does not use an up to date telegram client with support for calls' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PasswordEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided password is empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PasswordHashInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The password (and thus its hash value) you entered is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PasswordMissingError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The account must have 2-factor authentication enabled (a password) before this method can be used' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PasswordRecoveryExpiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The recovery code has expired' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PasswordRecoveryNaError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("No email was set, can't recover password via email" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PasswordRequiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The account must have 2-factor authentication enabled (a password) before this method can be used' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PasswordTooFreshError(BadRequestError): + def __init__(self, request, capture=0): + self.request = request + self.seconds = int(capture) + super(Exception, self).__init__('The password was added too recently and {seconds} seconds must pass before using the method'.format(seconds=self.seconds) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.seconds) + + +class PaymentProviderInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The payment provider was not recognised or its token was invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PeerFloodError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The account is limited (spam-reported) and cannot message non-contacts; account-wide with no defined duration, so retrying will not help. Check @SpamBot' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PeerHistoryEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PeerIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('An invalid Peer was used. Make sure to pass the right peer type and that the value is valid (for instance, bots cannot start conversations)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PeerIdNotSupportedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided peer ID is not supported' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PersistentTimestampEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Persistent timestamp empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PersistentTimestampInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Persistent timestamp invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PersistentTimestampOutdatedError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Persistent timestamp outdated' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneCodeEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The phone code is missing' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneCodeExpiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The confirmation code has expired' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneCodeHashEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The phone code hash is missing' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneCodeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The phone code entered was invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneHashExpiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('An invalid or expired `phone_code_hash` was provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneMigrateError(InvalidDCError): + def __init__(self, request, capture=0): + self.request = request + self.new_dc = int(capture) + super(Exception, self).__init__('The phone number a user is trying to use for authorization is associated with DC {new_dc}'.format(new_dc=self.new_dc) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.new_dc) + + +class PhoneNotOccupiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('No user is associated to the specified phone number' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneNumberAppSignupForbiddenError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("You can't sign up using this app" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneNumberBannedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The used phone number has been banned from Telegram and cannot be used anymore. Maybe check https://www.telegram.org/faq_spam' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneNumberFloodError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You asked for the code too many times.' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneNumberInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The phone number is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneNumberOccupiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The phone number is already in use' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhoneNumberUnoccupiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The phone number is not yet being used' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhonePasswordFloodError(AuthKeyError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You have tried logging in too many times' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhonePasswordProtectedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('This phone is password protected' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoContentTypeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Photo mime-type invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoContentUrlEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The content from the URL used as a photo appears to be empty or has caused another HTTP error' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoCropFileMissingError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Photo crop file missing' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoCropSizeSmallError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Photo is too small' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoExtInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The extension of the photo is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoFileMissingError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Profile photo file missing' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Photo id is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Photo invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoInvalidDimensionsError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The photo dimensions are invalid (hint: `pip install pillow` for `send_file` to resize images)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoSaveFileInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The photo you tried to send cannot be saved by Telegram. A reason may be that it exceeds 10MB. Try resizing it locally' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PhotoThumbUrlEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The URL used as a thumbnail appears to be empty or has caused another HTTP error' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PinnedDialogsTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Too many pinned dialogs' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PinRestrictedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("You can't pin messages in private chats with other people" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PollAnswersInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The poll did not have enough answers or had too many' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PollAnswerInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('One of the poll answers is not acceptable' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PollOptionDuplicateError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A duplicate option was sent in the same poll' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PollOptionInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A poll option used invalid data (the data may be too long)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PollQuestionInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The poll question was either empty or too long' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PollUnsupportedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('This layer does not support polls in the issued method' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PollVoteRequiredError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Cast a vote in the poll before calling this method' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PostponedTimeoutError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The postponed call has timed out' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PremiumAccountRequiredError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A premium account is required to execute this action' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PremiumCurrentlyUnavailableError(AuthKeyError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PreviousChatImportActiveWaitMinError(AuthKeyError): + def __init__(self, request, capture=0): + self.request = request + self.minutes = int(capture) + super(Exception, self).__init__('Similar to a flood wait, must wait {minutes} minutes'.format(minutes=self.minutes) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.minutes) + + +class PrivacyKeyInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The privacy key is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PrivacyTooLongError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Cannot add that many entities in a single request' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PrivacyValueInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The privacy value is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PtsChangeEmptyError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('No PTS change' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PublicChannelMissingError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You can only export group call invite links for public chats or channels' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class PublicKeyRequiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A public key is required' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QueryIdEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The query ID is empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QueryIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The query ID is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QueryTooShortError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The query string is too short' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QuizAnswerMissingError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You can forward a quiz while hiding the original author only after choosing an option in the quiz' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QuizCorrectAnswersEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A quiz must specify one correct answer' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QuizCorrectAnswersTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('There can only be one correct answer' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QuizCorrectAnswerInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The correct answer is not an existing answer' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class QuizMultipleInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A poll cannot be both multiple choice and quiz' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RandomIdDuplicateError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You provided a random ID that was already used' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RandomIdEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Random ID empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RandomIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A provided random ID is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RandomLengthInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Random length invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RangesInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid range provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReactionsTooManyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("The message already has exactly `reactions_uniq_max` reaction emojis, you can't react with a new emoji, see [the docs for more info](/api/config#client-configuration)" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReactionEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('No reaction provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReactionInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid reaction provided (only emoji are allowed)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReflectorNotAvailableError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid call reflector server' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RegIdGenerateFailedError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Failure while generating registration ID' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReplyMarkupBuyEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Reply markup for buy button empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReplyMarkupGameEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided reply markup for the game is empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReplyMarkupInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided reply markup is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ReplyMarkupTooLongError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The data embedded in the reply markup buttons was too much' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ResetRequestMissingError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('No password reset is in progress' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ResultsTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You sent too many results, see https://core.telegram.org/bots/api#answerinlinequery for the current limit' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ResultIdDuplicateError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Duplicated IDs on the sent results. Make sure to use unique IDs' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ResultIdEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Result ID empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ResultIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The given result cannot be used to send the selection to the bot' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ResultTypeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Result type invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RevoteNotAllowedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot change your vote' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RightsNotModifiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The new admin rights are equal to the old rights, no change was made' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RightForbiddenError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Either your admin rights do not allow you to do this or you passed the wrong rights combination (some rights only apply to channels and vice versa)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RpcCallFailError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Telegram is having internal issues, please try again later.' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RpcMcgetFailError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Telegram is having internal issues, please try again later.' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class RsaDecryptFailedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Internal RSA decryption failed' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ScheduleBotNotAllowedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Bots are not allowed to schedule messages' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ScheduleDateInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid schedule date provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ScheduleDateTooLateError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The date you tried to schedule is too far in the future (last known limit of 1 year and a few hours)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ScheduleStatusPrivateError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot schedule a message until the person comes online if their privacy does not show this information' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ScheduleTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot schedule more messages in this chat (last known limit of 100 per chat)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ScoreInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified game score is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SearchQueryEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The search query is empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SearchWithLinkNotSupportedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot provide a search query and an invite link at the same time' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SecondsInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Slow mode only supports certain values (e.g. 0, 10s, 30s, 1m, 5m, 15m and 1h)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SendAsPeerInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("You can't send messages as the specified peer" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SendCodeUnavailableError(AuthKeyError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Returned when all available options for this type of number were already used (e.g. flash-call, then SMS, then this error might be returned to trigger a second resend)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SendMessageMediaInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The message media was invalid or not specified' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SendMessageTypeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The message type is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SensitiveChangeForbiddenError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Your sensitive content settings cannot be changed at this time' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SessionExpiredError(UnauthorizedError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The authorization has expired' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SessionPasswordNeededError(UnauthorizedError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Two-steps verification is enabled and a password is required' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SessionRevokedError(UnauthorizedError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The authorization has been invalidated, because of the user terminating all sessions' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SessionTooFreshError(BadRequestError): + def __init__(self, request, capture=0): + self.request = request + self.seconds = int(capture) + super(Exception, self).__init__('The session logged in too recently and {seconds} seconds must pass before calling the method'.format(seconds=self.seconds) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.seconds) + + +class SettingsInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid settings were provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class Sha256HashInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided SHA256 hash is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ShortnameOccupyFailedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('An error occurred when trying to register the short-name used for the sticker pack. Try a different name' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ShortNameInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified short name is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ShortNameOccupiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified short name is already in use' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SignInFailedError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Failure while signing in' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SlowModeMultiMsgsDisabledError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Slowmode is enabled, you cannot forward multiple messages to this group' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SlowModeWaitError(FloodError): + def __init__(self, request, capture=0): + self.request = request + self.seconds = int(capture) + super(Exception, self).__init__('A wait of {seconds} seconds is required before sending another message in this chat'.format(seconds=self.seconds) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.seconds) + + +class SmsCodeCreateFailedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('An error occurred while creating the SMS code' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SrpIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid SRP ID provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SrpPasswordChangedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Password has changed' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StartParamEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The start parameter is empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StartParamInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Start parameter invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StartParamTooLongError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Start parameter is too long' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StatsMigrateError(InvalidDCError): + def __init__(self, request, capture=0): + self.request = request + self.dc = int(capture) + super(Exception, self).__init__('The channel statistics must be fetched from DC {dc}'.format(dc=self.dc) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.dc) + + +class StickerpackStickersTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("There are too many stickers in this stickerpack, you can't add any more" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickersetInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided sticker set is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickersetOwnerAnonymousError(AuthKeyError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("This sticker set can't be used as the group's official stickers because it was created by one of its anonymous admins" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickersEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('No sticker provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickersTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("There are too many stickers in this stickerpack, you can't add any more" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerDocumentInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The sticker file was invalid (this file has failed Telegram internal checks, make sure to use the correct format and comply with https://core.telegram.org/animated_stickers)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerEmojiInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Sticker emoji invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerFileInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Sticker file invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerGifDimensionsError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified video sticker has invalid dimensions' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided sticker ID is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided sticker is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerMimeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Make sure to pass a valid image file for the right InputFile parameter' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerPngDimensionsError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Sticker png dimensions invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerPngNopngError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Stickers must be a png file but the used image was not a png' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerTgsNodocError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You must send the animated sticker as a document' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerTgsNotgsError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Stickers must be a tgs file but the used file was not a tgs' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerThumbPngNopngError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Stickerset thumb must be a png file but the used file was not png' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerThumbTgsNotgsError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Stickerset thumb must be a tgs file but the used file was not tgs' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerVideoBigError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified video sticker is too big' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerVideoNodocError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You must send the video sticker as a document' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StickerVideoNowebmError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified video sticker is not in webm format' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StorageCheckFailedError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Server storage check failed' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class StoreInvalidScalarTypeError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class SwitchPmTextEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The switch_pm.text field was empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TakeoutInitDelayError(FloodError): + def __init__(self, request, capture=0): + self.request = request + self.seconds = int(capture) + super(Exception, self).__init__('A wait of {seconds} seconds is required before being able to initiate the takeout'.format(seconds=self.seconds) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.seconds) + + +class TakeoutInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The takeout session has been invalidated by another data export session' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TakeoutRequiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You must initialize a takeout request first' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TempAuthKeyAlreadyBoundError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The passed temporary key is already bound to another **perm_auth_key_id**' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TempAuthKeyEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('No temporary auth key provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ThemeFileInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid theme file provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ThemeFormatInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid theme format provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ThemeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Theme invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ThemeMimeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot create this theme, the mime-type is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ThemeTitleInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified theme title is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TimeoutError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('A timeout occurred while fetching data from the worker' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TitleInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified stickerpack title is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TmpPasswordDisabledError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The temporary password is disabled' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TmpPasswordInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Password auth needs to be regenerated' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TokenInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided token is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TopicDeletedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The topic was deleted' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ToLangInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified destination language is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TtlDaysInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided TTL is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TtlMediaInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided media cannot be used with a TTL' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TtlPeriodInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided TTL Period is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TypesEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The types field is empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TypeConstructorInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The type constructor is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TimedoutError(TimedOutError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Timeout while fetching data' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class TimeoutError(TimedOutError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Timeout while fetching data' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UnknownErrorError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UnknownMethodError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The method you tried to call cannot be called on non-CDN DCs' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UntilDateInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('That date cannot be specified in this request (try using None)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UpdateAppToLoginError(AuthKeyError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UrlInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("The URL used was invalid (e.g. when answering a callback with a URL that's not t.me/yourbot or your game's URL)" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsageLimitInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified usage limit is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsernameInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Nobody is using this username or the username is unacceptable' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsernameNotModifiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The username is not different from the current username' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsernameNotOccupiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The username is not in use by anyone else yet' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsernameOccupiedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The username is already taken' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsernamePurchaseAvailableError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserpicPrivacyRequiredError(AuthKeyError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You need to disable privacy settings for your profile picture in order to make your geolocation public' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserpicUploadRequiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You must have a profile picture before using this method' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsersTooFewError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Not enough users (to create a chat, for example)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UsersTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The maximum number of users has been exceeded (to create a chat, for example)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserAdminInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("Either you're not an admin or you tried to ban an admin that you didn't promote" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserAlreadyInvitedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You have already invited this user' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserAlreadyParticipantError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The authenticated user is already a participant of the chat' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserBannedInChannelError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("You're banned from sending messages in supergroups/channels" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserBlockedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('User blocked' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserBotError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Bots can only be admins in channels.' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserBotInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('This method can only be called by a bot' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserBotRequiredError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('This method can only be called by a bot' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserChannelsTooMuchError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('One of the users you tried to add is already in too many channels/supergroups' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserCreatorError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("You can't leave this channel, because you're its creator" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserDeactivatedError(UnauthorizedError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The user has been deleted/deactivated' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserDeactivatedBanError(UnauthorizedError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The user has been deleted/deactivated' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserDeletedError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("You can't send this secret message because the other participant deleted their account" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserIdInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid object ID for a user. Make sure to pass the right types, for instance making sure that the request is designed for users or otherwise look for a different one more suited' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The given user was invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserIsBlockedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('User is blocked' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserIsBotError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("Bots can't send messages to other bots" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserKickedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('This user was kicked from this supergroup/channel' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserMigrateError(InvalidDCError): + def __init__(self, request, capture=0): + self.request = request + self.new_dc = int(capture) + super(Exception, self).__init__('The user whose identity is being used to execute queries is associated with DC {new_dc}'.format(new_dc=self.new_dc) + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request, self.new_dc) + + +class UserNotMutualContactError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The provided user is not a mutual contact' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserNotParticipantError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The target user is not a member of the specified megagroup or channel' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserPrivacyRestrictedError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("The user's privacy settings do not allow you to do this" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserRestrictedError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("You're spamreported, you can't create channels or chats." + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class UserVolumeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified user volume is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class VideoContentTypeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The video content type is not supported with the given parameters (i.e. supports_streaming)' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class VideoFileInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The given video cannot be used' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class VideoTitleEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified video title is empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class VoiceMessagesForbiddenError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("This user's privacy settings forbid you from sending voice messages" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WallpaperFileInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The given file cannot be used as a wallpaper' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WallpaperInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The input wallpaper was not valid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WallpaperMimeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified wallpaper MIME type is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WcConvertUrlInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('WC convert URL invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebdocumentInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid webdocument URL provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebdocumentMimeInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Invalid webdocument mime type provided' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebdocumentSizeTooBigError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Webdocument is too big!' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebdocumentUrlInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The given URL cannot be used' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebpageCurlFailedError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Failure while fetching the webpage with cURL' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebpageMediaEmptyError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Webpage media empty' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebpushAuthInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified web push authentication secret is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebpushKeyInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified web push elliptic curve Diffie-Hellman public key is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WebpushTokenInvalidError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('The specified web push token is invalid' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class WorkerBusyTooLongRetryError(ServerError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('Telegram workers are too busy to respond immediately' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class YouBlockedUserError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You blocked this user' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class FrozenMethodInvalidError(FloodError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You tried to use a method that is not available for frozen accounts' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class FrozenParticipantMissingError(BadRequestError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__("Your account is frozen and can't access the chat" + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ChatSendVoicesForbiddenError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot send voices results in this chat' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ChatSendPhotosForbiddenError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot send photos results in this chat' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ChatSendVideosForbiddenError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot send videos results in this Chat' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +class ChatSendPlainForbiddenError(ForbiddenError): + def __init__(self, request): + self.request = request + super(Exception, self).__init__('You cannot send plain results in this chat' + self._fmt_request(self.request)) + + def __reduce__(self): + return type(self), (self.request,) + + +rpc_errors_dict = { + 'ABOUT_TOO_LONG': AboutTooLongError, + 'ACCESS_TOKEN_EXPIRED': AccessTokenExpiredError, + 'ACCESS_TOKEN_INVALID': AccessTokenInvalidError, + 'ACTIVE_USER_REQUIRED': ActiveUserRequiredError, + 'ADMINS_TOO_MUCH': AdminsTooMuchError, + 'ADMIN_ID_INVALID': AdminIdInvalidError, + 'ADMIN_RANK_EMOJI_NOT_ALLOWED': AdminRankEmojiNotAllowedError, + 'ADMIN_RANK_INVALID': AdminRankInvalidError, + 'ALBUM_PHOTOS_TOO_MANY': AlbumPhotosTooManyError, + 'API_ID_INVALID': ApiIdInvalidError, + 'API_ID_PUBLISHED_FLOOD': ApiIdPublishedFloodError, + 'ARTICLE_TITLE_EMPTY': ArticleTitleEmptyError, + 'AUDIO_CONTENT_URL_EMPTY': AudioContentUrlEmptyError, + 'AUDIO_TITLE_EMPTY': AudioTitleEmptyError, + 'AUTH_BYTES_INVALID': AuthBytesInvalidError, + 'AUTH_KEY_DUPLICATED': AuthKeyDuplicatedError, + 'AUTH_KEY_INVALID': AuthKeyInvalidError, + 'AUTH_KEY_PERM_EMPTY': AuthKeyPermEmptyError, + 'AUTH_KEY_UNREGISTERED': AuthKeyUnregisteredError, + 'AUTH_RESTART': AuthRestartError, + 'AUTH_TOKEN_ALREADY_ACCEPTED': AuthTokenAlreadyAcceptedError, + 'AUTH_TOKEN_EXCEPTION': AuthTokenExceptionError, + 'AUTH_TOKEN_EXPIRED': AuthTokenExpiredError, + 'AUTH_TOKEN_INVALID': AuthTokenInvalidError, + 'AUTH_TOKEN_INVALID2': AuthTokenInvalid2Error, + 'AUTH_TOKEN_INVALIDX': AuthTokenInvalidxError, + 'AUTOARCHIVE_NOT_AVAILABLE': AutoarchiveNotAvailableError, + 'BANK_CARD_NUMBER_INVALID': BankCardNumberInvalidError, + 'BANNED_RIGHTS_INVALID': BannedRightsInvalidError, + 'BASE_PORT_LOC_INVALID': BasePortLocInvalidError, + 'BOTS_TOO_MUCH': BotsTooMuchError, + 'BOT_CHANNELS_NA': BotChannelsNaError, + 'BOT_COMMAND_DESCRIPTION_INVALID': BotCommandDescriptionInvalidError, + 'BOT_COMMAND_INVALID': BotCommandInvalidError, + 'BOT_COMMANDS_TOO_MUCH': BotCommandsTooMuchError, + 'BOT_DOMAIN_INVALID': BotDomainInvalidError, + 'BOT_GAMES_DISABLED': BotGamesDisabledError, + 'BOT_GROUPS_BLOCKED': BotGroupsBlockedError, + 'BOT_INLINE_DISABLED': BotInlineDisabledError, + 'BOT_INVALID': BotInvalidError, + 'BOT_METHOD_INVALID': BotMethodInvalidError, + 'BOT_MISSING': BotMissingError, + 'BOT_ONESIDE_NOT_AVAIL': BotOnesideNotAvailError, + 'BOT_PAYMENTS_DISABLED': BotPaymentsDisabledError, + 'BOT_POLLS_DISABLED': BotPollsDisabledError, + 'BOT_RESPONSE_TIMEOUT': BotResponseTimeoutError, + 'BOT_SCORE_NOT_MODIFIED': BotScoreNotModifiedError, + 'BROADCAST_CALLS_DISABLED': BroadcastCallsDisabledError, + 'BROADCAST_FORBIDDEN': BroadcastForbiddenError, + 'BROADCAST_ID_INVALID': BroadcastIdInvalidError, + 'BROADCAST_PUBLIC_VOTERS_FORBIDDEN': BroadcastPublicVotersForbiddenError, + 'BROADCAST_REQUIRED': BroadcastRequiredError, + 'BUTTON_DATA_INVALID': ButtonDataInvalidError, + 'BUTTON_TEXT_INVALID': ButtonTextInvalidError, + 'BUTTON_TYPE_INVALID': ButtonTypeInvalidError, + 'BUTTON_URL_INVALID': ButtonUrlInvalidError, + 'BUTTON_USER_PRIVACY_RESTRICTED': ButtonUserPrivacyRestrictedError, + 'CALL_ALREADY_ACCEPTED': CallAlreadyAcceptedError, + 'CALL_ALREADY_DECLINED': CallAlreadyDeclinedError, + 'CALL_OCCUPY_FAILED': CallOccupyFailedError, + 'CALL_PEER_INVALID': CallPeerInvalidError, + 'CALL_PROTOCOL_FLAGS_INVALID': CallProtocolFlagsInvalidError, + 'CDN_METHOD_INVALID': CdnMethodInvalidError, + 'CDN_UPLOAD_TIMEOUT': CdnUploadTimeoutError, + 'CHANNELS_ADMIN_LOCATED_TOO_MUCH': ChannelsAdminLocatedTooMuchError, + 'CHANNELS_ADMIN_PUBLIC_TOO_MUCH': ChannelsAdminPublicTooMuchError, + 'CHANNELS_TOO_MUCH': ChannelsTooMuchError, + 'CHANNEL_BANNED': ChannelBannedError, + 'CHANNEL_FORUM_MISSING': ChannelForumMissingError, + 'CHANNEL_ID_INVALID': ChannelIdInvalidError, + 'CHANNEL_INVALID': ChannelInvalidError, + 'CHANNEL_PARICIPANT_MISSING': ChannelParicipantMissingError, + 'CHANNEL_PRIVATE': ChannelPrivateError, + 'CHANNEL_PUBLIC_GROUP_NA': ChannelPublicGroupNaError, + 'CHANNEL_TOO_BIG': ChannelTooBigError, + 'CHANNEL_TOO_LARGE': ChannelTooLargeError, + 'CHAT_ABOUT_NOT_MODIFIED': ChatAboutNotModifiedError, + 'CHAT_ABOUT_TOO_LONG': ChatAboutTooLongError, + 'CHAT_ADMIN_INVITE_REQUIRED': ChatAdminInviteRequiredError, + 'CHAT_ADMIN_REQUIRED': ChatAdminRequiredError, + 'CHAT_DISCUSSION_UNALLOWED': ChatDiscussionUnallowedError, + 'CHAT_FORBIDDEN': ChatForbiddenError, + 'CHAT_FORWARDS_RESTRICTED': ChatForwardsRestrictedError, + 'CHAT_GET_FAILED': ChatGetFailedError, + 'CHAT_GUEST_SEND_FORBIDDEN': ChatGuestSendForbiddenError, + 'CHAT_ID_EMPTY': ChatIdEmptyError, + 'CHAT_ID_GENERATE_FAILED': ChatIdGenerateFailedError, + 'CHAT_ID_INVALID': ChatIdInvalidError, + 'CHAT_INVALID': ChatInvalidError, + 'CHAT_INVITE_PERMANENT': ChatInvitePermanentError, + 'CHAT_LINK_EXISTS': ChatLinkExistsError, + 'CHAT_NOT_MODIFIED': ChatNotModifiedError, + 'CHAT_RESTRICTED': ChatRestrictedError, + 'CHAT_REVOKE_DATE_UNSUPPORTED': ChatRevokeDateUnsupportedError, + 'CHAT_SEND_GAME_FORBIDDEN': ChatSendGameForbiddenError, + 'CHAT_SEND_GIFS_FORBIDDEN': ChatSendGifsForbiddenError, + 'CHAT_SEND_INLINE_FORBIDDEN': ChatSendInlineForbiddenError, + 'CHAT_SEND_MEDIA_FORBIDDEN': ChatSendMediaForbiddenError, + 'CHAT_SEND_POLL_FORBIDDEN': ChatSendPollForbiddenError, + 'CHAT_SEND_STICKERS_FORBIDDEN': ChatSendStickersForbiddenError, + 'CHAT_TITLE_EMPTY': ChatTitleEmptyError, + 'CHAT_TOO_BIG': ChatTooBigError, + 'CHAT_WRITE_FORBIDDEN': ChatWriteForbiddenError, + 'CHP_CALL_FAIL': ChpCallFailError, + 'CODE_EMPTY': CodeEmptyError, + 'CODE_HASH_INVALID': CodeHashInvalidError, + 'CODE_INVALID': CodeInvalidError, + 'CONNECTION_API_ID_INVALID': ConnectionApiIdInvalidError, + 'CONNECTION_APP_VERSION_EMPTY': ConnectionAppVersionEmptyError, + 'CONNECTION_DEVICE_MODEL_EMPTY': ConnectionDeviceModelEmptyError, + 'CONNECTION_LANG_PACK_INVALID': ConnectionLangPackInvalidError, + 'CONNECTION_LAYER_INVALID': ConnectionLayerInvalidError, + 'CONNECTION_NOT_INITED': ConnectionNotInitedError, + 'CONNECTION_SYSTEM_EMPTY': ConnectionSystemEmptyError, + 'CONNECTION_SYSTEM_LANG_CODE_EMPTY': ConnectionSystemLangCodeEmptyError, + 'CONTACT_ADD_MISSING': ContactAddMissingError, + 'CONTACT_ID_INVALID': ContactIdInvalidError, + 'CONTACT_NAME_EMPTY': ContactNameEmptyError, + 'CONTACT_REQ_MISSING': ContactReqMissingError, + 'CREATE_CALL_FAILED': CreateCallFailedError, + 'CURRENCY_TOTAL_AMOUNT_INVALID': CurrencyTotalAmountInvalidError, + 'DATA_INVALID': DataInvalidError, + 'DATA_JSON_INVALID': DataJsonInvalidError, + 'DATA_TOO_LONG': DataTooLongError, + 'DATE_EMPTY': DateEmptyError, + 'DC_ID_INVALID': DcIdInvalidError, + 'DH_G_A_INVALID': DhGAInvalidError, + 'DOCUMENT_INVALID': DocumentInvalidError, + 'EDIT_BOT_INVITE_FORBIDDEN': EditBotInviteForbiddenError, + 'EMAIL_HASH_EXPIRED': EmailHashExpiredError, + 'EMAIL_INVALID': EmailInvalidError, + 'EMAIL_UNCONFIRMED': EmailUnconfirmedError, + 'EMAIL_VERIFY_EXPIRED': EmailVerifyExpiredError, + 'EMOJI_INVALID': EmojiInvalidError, + 'EMOJI_NOT_MODIFIED': EmojiNotModifiedError, + 'EMOTICON_EMPTY': EmoticonEmptyError, + 'EMOTICON_INVALID': EmoticonInvalidError, + 'EMOTICON_STICKERPACK_MISSING': EmoticonStickerpackMissingError, + 'ENCRYPTED_MESSAGE_INVALID': EncryptedMessageInvalidError, + 'ENCRYPTION_ALREADY_ACCEPTED': EncryptionAlreadyAcceptedError, + 'ENCRYPTION_ALREADY_DECLINED': EncryptionAlreadyDeclinedError, + 'ENCRYPTION_DECLINED': EncryptionDeclinedError, + 'ENCRYPTION_ID_INVALID': EncryptionIdInvalidError, + 'ENCRYPTION_OCCUPY_FAILED': EncryptionOccupyFailedError, + 'ENTITIES_TOO_LONG': EntitiesTooLongError, + 'ENTITY_BOUNDS_INVALID': EntityBoundsInvalidError, + 'ENTITY_MENTION_USER_INVALID': EntityMentionUserInvalidError, + 'ERROR_TEXT_EMPTY': ErrorTextEmptyError, + 'EXPIRE_DATE_INVALID': ExpireDateInvalidError, + 'EXPIRE_FORBIDDEN': ExpireForbiddenError, + 'EXPORT_CARD_INVALID': ExportCardInvalidError, + 'EXTERNAL_URL_INVALID': ExternalUrlInvalidError, + 'FIELD_NAME_EMPTY': FieldNameEmptyError, + 'FIELD_NAME_INVALID': FieldNameInvalidError, + 'FILEREF_UPGRADE_NEEDED': FilerefUpgradeNeededError, + 'FILE_CONTENT_TYPE_INVALID': FileContentTypeInvalidError, + 'FILE_EMTPY': FileEmtpyError, + 'FILE_ID_INVALID': FileIdInvalidError, + 'FILE_PARTS_INVALID': FilePartsInvalidError, + 'FILE_PART_0_MISSING': FilePart0MissingError, + 'FILE_PART_EMPTY': FilePartEmptyError, + 'FILE_PART_INVALID': FilePartInvalidError, + 'FILE_PART_LENGTH_INVALID': FilePartLengthInvalidError, + 'FILE_PART_SIZE_CHANGED': FilePartSizeChangedError, + 'FILE_PART_SIZE_INVALID': FilePartSizeInvalidError, + 'FILE_PART_TOO_BIG': FilePartTooBigError, + 'FILE_REFERENCE_EMPTY': FileReferenceEmptyError, + 'FILE_REFERENCE_EXPIRED': FileReferenceExpiredError, + 'FILE_REFERENCE_INVALID': FileReferenceInvalidError, + 'FILE_TITLE_EMPTY': FileTitleEmptyError, + 'FILTER_ID_INVALID': FilterIdInvalidError, + 'FILTER_INCLUDE_EMPTY': FilterIncludeEmptyError, + 'FILTER_NOT_SUPPORTED': FilterNotSupportedError, + 'FILTER_TITLE_EMPTY': FilterTitleEmptyError, + 'FIRSTNAME_INVALID': FirstNameInvalidError, + 'FOLDER_ID_EMPTY': FolderIdEmptyError, + 'FOLDER_ID_INVALID': FolderIdInvalidError, + 'FRESH_CHANGE_ADMINS_FORBIDDEN': FreshChangeAdminsForbiddenError, + 'FRESH_CHANGE_PHONE_FORBIDDEN': FreshChangePhoneForbiddenError, + 'FRESH_RESET_AUTHORISATION_FORBIDDEN': FreshResetAuthorisationForbiddenError, + 'FROM_MESSAGE_BOT_DISABLED': FromMessageBotDisabledError, + 'FROM_PEER_INVALID': FromPeerInvalidError, + 'GAME_BOT_INVALID': GameBotInvalidError, + 'GEO_POINT_INVALID': GeoPointInvalidError, + 'GIF_CONTENT_TYPE_INVALID': GifContentTypeInvalidError, + 'GIF_ID_INVALID': GifIdInvalidError, + 'GRAPH_EXPIRED_RELOAD': GraphExpiredReloadError, + 'GRAPH_INVALID_RELOAD': GraphInvalidReloadError, + 'GRAPH_OUTDATED_RELOAD': GraphOutdatedReloadError, + 'GROUPCALL_ADD_PARTICIPANTS_FAILED': GroupcallAddParticipantsFailedError, + 'GROUPCALL_ALREADY_DISCARDED': GroupcallAlreadyDiscardedError, + 'GROUPCALL_ALREADY_STARTED': GroupcallAlreadyStartedError, + 'GROUPCALL_FORBIDDEN': GroupcallForbiddenError, + 'GROUPCALL_INVALID': GroupcallInvalidError, + 'GROUPCALL_JOIN_MISSING': GroupcallJoinMissingError, + 'GROUPCALL_NOT_MODIFIED': GroupcallNotModifiedError, + 'GROUPCALL_SSRC_DUPLICATE_MUCH': GroupcallSsrcDuplicateMuchError, + 'GROUPED_MEDIA_INVALID': GroupedMediaInvalidError, + 'GROUP_CALL_INVALID': GroupCallInvalidError, + 'HASH_INVALID': HashInvalidError, + 'HIDE_REQUESTER_MISSING': HideRequesterMissingError, + 'HISTORY_GET_FAILED': HistoryGetFailedError, + 'IMAGE_PROCESS_FAILED': ImageProcessFailedError, + 'IMPORT_FILE_INVALID': ImportFileInvalidError, + 'IMPORT_FORMAT_UNRECOGNIZED': ImportFormatUnrecognizedError, + 'IMPORT_ID_INVALID': ImportIdInvalidError, + 'INLINE_BOT_REQUIRED': InlineBotRequiredError, + 'INLINE_RESULT_EXPIRED': InlineResultExpiredError, + 'INPUT_CONSTRUCTOR_INVALID': InputConstructorInvalidError, + 'INPUT_FETCH_ERROR': InputFetchErrorError, + 'INPUT_FETCH_FAIL': InputFetchFailError, + 'INPUT_FILTER_INVALID': InputFilterInvalidError, + 'INPUT_LAYER_INVALID': InputLayerInvalidError, + 'INPUT_METHOD_INVALID': InputMethodInvalidError, + 'INPUT_REQUEST_TOO_LONG': InputRequestTooLongError, + 'INPUT_TEXT_EMPTY': InputTextEmptyError, + 'INPUT_USER_DEACTIVATED': InputUserDeactivatedError, + 'INVITE_FORBIDDEN_WITH_JOINAS': InviteForbiddenWithJoinasError, + 'INVITE_HASH_EMPTY': InviteHashEmptyError, + 'INVITE_HASH_EXPIRED': InviteHashExpiredError, + 'INVITE_HASH_INVALID': InviteHashInvalidError, + 'INVITE_REQUEST_SENT': InviteRequestSentError, + 'INVITE_REVOKED_MISSING': InviteRevokedMissingError, + 'INVOICE_PAYLOAD_INVALID': InvoicePayloadInvalidError, + 'JOIN_AS_PEER_INVALID': JoinAsPeerInvalidError, + 'LANG_CODE_INVALID': LangCodeInvalidError, + 'LANG_CODE_NOT_SUPPORTED': LangCodeNotSupportedError, + 'LANG_PACK_INVALID': LangPackInvalidError, + 'LASTNAME_INVALID': LastnameInvalidError, + 'LIMIT_INVALID': LimitInvalidError, + 'LINK_NOT_MODIFIED': LinkNotModifiedError, + 'LOCATION_INVALID': LocationInvalidError, + 'MAX_DATE_INVALID': MaxDateInvalidError, + 'MAX_ID_INVALID': MaxIdInvalidError, + 'MAX_QTS_INVALID': MaxQtsInvalidError, + 'MD5_CHECKSUM_INVALID': Md5ChecksumInvalidError, + 'MEDIA_CAPTION_TOO_LONG': MediaCaptionTooLongError, + 'MEDIA_EMPTY': MediaEmptyError, + 'MEDIA_GROUPED_INVALID': MediaGroupedInvalidError, + 'MEDIA_INVALID': MediaInvalidError, + 'MEDIA_NEW_INVALID': MediaNewInvalidError, + 'MEDIA_PREV_INVALID': MediaPrevInvalidError, + 'MEDIA_TTL_INVALID': MediaTtlInvalidError, + 'MEGAGROUP_ID_INVALID': MegagroupIdInvalidError, + 'MEGAGROUP_PREHISTORY_HIDDEN': MegagroupPrehistoryHiddenError, + 'MEGAGROUP_REQUIRED': MegagroupRequiredError, + 'MEMBER_NO_LOCATION': MemberNoLocationError, + 'MEMBER_OCCUPY_PRIMARY_LOC_FAILED': MemberOccupyPrimaryLocFailedError, + 'MESSAGE_AUTHOR_REQUIRED': MessageAuthorRequiredError, + 'MESSAGE_DELETE_FORBIDDEN': MessageDeleteForbiddenError, + 'MESSAGE_EDIT_TIME_EXPIRED': MessageEditTimeExpiredError, + 'MESSAGE_EMPTY': MessageEmptyError, + 'MESSAGE_IDS_EMPTY': MessageIdsEmptyError, + 'MESSAGE_ID_INVALID': MessageIdInvalidError, + 'MESSAGE_NOT_MODIFIED': MessageNotModifiedError, + 'MESSAGE_POLL_CLOSED': MessagePollClosedError, + 'MESSAGE_TOO_LONG': MessageTooLongError, + 'METHOD_INVALID': MethodInvalidError, + 'MIN_DATE_INVALID': MinDateInvalidError, + 'MSGID_DECREASE_RETRY': MsgidDecreaseRetryError, + 'MSG_ID_INVALID': MsgIdInvalidError, + 'MSG_TOO_OLD': MsgTooOldError, + 'MSG_WAIT_FAILED': MsgWaitFailedError, + 'MT_SEND_QUEUE_TOO_LONG': MtSendQueueTooLongError, + 'MULTI_MEDIA_TOO_LONG': MultiMediaTooLongError, + 'NEED_CHAT_INVALID': NeedChatInvalidError, + 'NEED_MEMBER_INVALID': NeedMemberInvalidError, + 'NEW_SALT_INVALID': NewSaltInvalidError, + 'NEW_SETTINGS_EMPTY': NewSettingsEmptyError, + 'NEW_SETTINGS_INVALID': NewSettingsInvalidError, + 'NEXT_OFFSET_INVALID': NextOffsetInvalidError, + 'NOT_ALLOWED': NotAllowedError, + 'OFFSET_INVALID': OffsetInvalidError, + 'OFFSET_PEER_ID_INVALID': OffsetPeerIdInvalidError, + 'OPTIONS_TOO_MUCH': OptionsTooMuchError, + 'OPTION_INVALID': OptionInvalidError, + 'PACK_SHORT_NAME_INVALID': PackShortNameInvalidError, + 'PACK_SHORT_NAME_OCCUPIED': PackShortNameOccupiedError, + 'PACK_TITLE_INVALID': PackTitleInvalidError, + 'PARTICIPANTS_TOO_FEW': ParticipantsTooFewError, + 'PARTICIPANT_CALL_FAILED': ParticipantCallFailedError, + 'PARTICIPANT_ID_INVALID': ParticipantIdInvalidError, + 'PARTICIPANT_JOIN_MISSING': ParticipantJoinMissingError, + 'PARTICIPANT_VERSION_OUTDATED': ParticipantVersionOutdatedError, + 'PASSWORD_EMPTY': PasswordEmptyError, + 'PASSWORD_HASH_INVALID': PasswordHashInvalidError, + 'PASSWORD_MISSING': PasswordMissingError, + 'PASSWORD_RECOVERY_EXPIRED': PasswordRecoveryExpiredError, + 'PASSWORD_RECOVERY_NA': PasswordRecoveryNaError, + 'PASSWORD_REQUIRED': PasswordRequiredError, + 'PAYMENT_PROVIDER_INVALID': PaymentProviderInvalidError, + 'PEER_FLOOD': PeerFloodError, + 'PEER_HISTORY_EMPTY': PeerHistoryEmptyError, + 'PEER_ID_INVALID': PeerIdInvalidError, + 'PEER_ID_NOT_SUPPORTED': PeerIdNotSupportedError, + 'PERSISTENT_TIMESTAMP_EMPTY': PersistentTimestampEmptyError, + 'PERSISTENT_TIMESTAMP_INVALID': PersistentTimestampInvalidError, + 'PERSISTENT_TIMESTAMP_OUTDATED': PersistentTimestampOutdatedError, + 'PHONE_CODE_EMPTY': PhoneCodeEmptyError, + 'PHONE_CODE_EXPIRED': PhoneCodeExpiredError, + 'PHONE_CODE_HASH_EMPTY': PhoneCodeHashEmptyError, + 'PHONE_CODE_INVALID': PhoneCodeInvalidError, + 'PHONE_HASH_EXPIRED': PhoneHashExpiredError, + 'PHONE_NOT_OCCUPIED': PhoneNotOccupiedError, + 'PHONE_NUMBER_APP_SIGNUP_FORBIDDEN': PhoneNumberAppSignupForbiddenError, + 'PHONE_NUMBER_BANNED': PhoneNumberBannedError, + 'PHONE_NUMBER_FLOOD': PhoneNumberFloodError, + 'PHONE_NUMBER_INVALID': PhoneNumberInvalidError, + 'PHONE_NUMBER_OCCUPIED': PhoneNumberOccupiedError, + 'PHONE_NUMBER_UNOCCUPIED': PhoneNumberUnoccupiedError, + 'PHONE_PASSWORD_FLOOD': PhonePasswordFloodError, + 'PHONE_PASSWORD_PROTECTED': PhonePasswordProtectedError, + 'PHOTO_CONTENT_TYPE_INVALID': PhotoContentTypeInvalidError, + 'PHOTO_CONTENT_URL_EMPTY': PhotoContentUrlEmptyError, + 'PHOTO_CROP_FILE_MISSING': PhotoCropFileMissingError, + 'PHOTO_CROP_SIZE_SMALL': PhotoCropSizeSmallError, + 'PHOTO_EXT_INVALID': PhotoExtInvalidError, + 'PHOTO_FILE_MISSING': PhotoFileMissingError, + 'PHOTO_ID_INVALID': PhotoIdInvalidError, + 'PHOTO_INVALID': PhotoInvalidError, + 'PHOTO_INVALID_DIMENSIONS': PhotoInvalidDimensionsError, + 'PHOTO_SAVE_FILE_INVALID': PhotoSaveFileInvalidError, + 'PHOTO_THUMB_URL_EMPTY': PhotoThumbUrlEmptyError, + 'PINNED_DIALOGS_TOO_MUCH': PinnedDialogsTooMuchError, + 'PIN_RESTRICTED': PinRestrictedError, + 'POLL_ANSWERS_INVALID': PollAnswersInvalidError, + 'POLL_ANSWER_INVALID': PollAnswerInvalidError, + 'POLL_OPTION_DUPLICATE': PollOptionDuplicateError, + 'POLL_OPTION_INVALID': PollOptionInvalidError, + 'POLL_QUESTION_INVALID': PollQuestionInvalidError, + 'POLL_UNSUPPORTED': PollUnsupportedError, + 'POLL_VOTE_REQUIRED': PollVoteRequiredError, + 'POSTPONED_TIMEOUT': PostponedTimeoutError, + 'PREMIUM_ACCOUNT_REQUIRED': PremiumAccountRequiredError, + 'PREMIUM_CURRENTLY_UNAVAILABLE': PremiumCurrentlyUnavailableError, + 'PRIVACY_KEY_INVALID': PrivacyKeyInvalidError, + 'PRIVACY_TOO_LONG': PrivacyTooLongError, + 'PRIVACY_VALUE_INVALID': PrivacyValueInvalidError, + 'PTS_CHANGE_EMPTY': PtsChangeEmptyError, + 'PUBLIC_CHANNEL_MISSING': PublicChannelMissingError, + 'PUBLIC_KEY_REQUIRED': PublicKeyRequiredError, + 'QUERY_ID_EMPTY': QueryIdEmptyError, + 'QUERY_ID_INVALID': QueryIdInvalidError, + 'QUERY_TOO_SHORT': QueryTooShortError, + 'QUIZ_ANSWER_MISSING': QuizAnswerMissingError, + 'QUIZ_CORRECT_ANSWERS_EMPTY': QuizCorrectAnswersEmptyError, + 'QUIZ_CORRECT_ANSWERS_TOO_MUCH': QuizCorrectAnswersTooMuchError, + 'QUIZ_CORRECT_ANSWER_INVALID': QuizCorrectAnswerInvalidError, + 'QUIZ_MULTIPLE_INVALID': QuizMultipleInvalidError, + 'RANDOM_ID_DUPLICATE': RandomIdDuplicateError, + 'RANDOM_ID_EMPTY': RandomIdEmptyError, + 'RANDOM_ID_INVALID': RandomIdInvalidError, + 'RANDOM_LENGTH_INVALID': RandomLengthInvalidError, + 'RANGES_INVALID': RangesInvalidError, + 'REACTIONS_TOO_MANY': ReactionsTooManyError, + 'REACTION_EMPTY': ReactionEmptyError, + 'REACTION_INVALID': ReactionInvalidError, + 'REFLECTOR_NOT_AVAILABLE': ReflectorNotAvailableError, + 'REG_ID_GENERATE_FAILED': RegIdGenerateFailedError, + 'REPLY_MARKUP_BUY_EMPTY': ReplyMarkupBuyEmptyError, + 'REPLY_MARKUP_GAME_EMPTY': ReplyMarkupGameEmptyError, + 'REPLY_MARKUP_INVALID': ReplyMarkupInvalidError, + 'REPLY_MARKUP_TOO_LONG': ReplyMarkupTooLongError, + 'RESET_REQUEST_MISSING': ResetRequestMissingError, + 'RESULTS_TOO_MUCH': ResultsTooMuchError, + 'RESULT_ID_DUPLICATE': ResultIdDuplicateError, + 'RESULT_ID_EMPTY': ResultIdEmptyError, + 'RESULT_ID_INVALID': ResultIdInvalidError, + 'RESULT_TYPE_INVALID': ResultTypeInvalidError, + 'REVOTE_NOT_ALLOWED': RevoteNotAllowedError, + 'RIGHTS_NOT_MODIFIED': RightsNotModifiedError, + 'RIGHT_FORBIDDEN': RightForbiddenError, + 'RPC_CALL_FAIL': RpcCallFailError, + 'RPC_MCGET_FAIL': RpcMcgetFailError, + 'RSA_DECRYPT_FAILED': RsaDecryptFailedError, + 'SCHEDULE_BOT_NOT_ALLOWED': ScheduleBotNotAllowedError, + 'SCHEDULE_DATE_INVALID': ScheduleDateInvalidError, + 'SCHEDULE_DATE_TOO_LATE': ScheduleDateTooLateError, + 'SCHEDULE_STATUS_PRIVATE': ScheduleStatusPrivateError, + 'SCHEDULE_TOO_MUCH': ScheduleTooMuchError, + 'SCORE_INVALID': ScoreInvalidError, + 'SEARCH_QUERY_EMPTY': SearchQueryEmptyError, + 'SEARCH_WITH_LINK_NOT_SUPPORTED': SearchWithLinkNotSupportedError, + 'SECONDS_INVALID': SecondsInvalidError, + 'SEND_AS_PEER_INVALID': SendAsPeerInvalidError, + 'SEND_CODE_UNAVAILABLE': SendCodeUnavailableError, + 'SEND_MESSAGE_MEDIA_INVALID': SendMessageMediaInvalidError, + 'SEND_MESSAGE_TYPE_INVALID': SendMessageTypeInvalidError, + 'SENSITIVE_CHANGE_FORBIDDEN': SensitiveChangeForbiddenError, + 'SESSION_EXPIRED': SessionExpiredError, + 'SESSION_PASSWORD_NEEDED': SessionPasswordNeededError, + 'SESSION_REVOKED': SessionRevokedError, + 'SETTINGS_INVALID': SettingsInvalidError, + 'SHA256_HASH_INVALID': Sha256HashInvalidError, + 'SHORTNAME_OCCUPY_FAILED': ShortnameOccupyFailedError, + 'SHORT_NAME_INVALID': ShortNameInvalidError, + 'SHORT_NAME_OCCUPIED': ShortNameOccupiedError, + 'SIGN_IN_FAILED': SignInFailedError, + 'SLOWMODE_MULTI_MSGS_DISABLED': SlowModeMultiMsgsDisabledError, + 'SMS_CODE_CREATE_FAILED': SmsCodeCreateFailedError, + 'SRP_ID_INVALID': SrpIdInvalidError, + 'SRP_PASSWORD_CHANGED': SrpPasswordChangedError, + 'START_PARAM_EMPTY': StartParamEmptyError, + 'START_PARAM_INVALID': StartParamInvalidError, + 'START_PARAM_TOO_LONG': StartParamTooLongError, + 'STICKERPACK_STICKERS_TOO_MUCH': StickerpackStickersTooMuchError, + 'STICKERSET_INVALID': StickersetInvalidError, + 'STICKERSET_OWNER_ANONYMOUS': StickersetOwnerAnonymousError, + 'STICKERS_EMPTY': StickersEmptyError, + 'STICKERS_TOO_MUCH': StickersTooMuchError, + 'STICKER_DOCUMENT_INVALID': StickerDocumentInvalidError, + 'STICKER_EMOJI_INVALID': StickerEmojiInvalidError, + 'STICKER_FILE_INVALID': StickerFileInvalidError, + 'STICKER_GIF_DIMENSIONS': StickerGifDimensionsError, + 'STICKER_ID_INVALID': StickerIdInvalidError, + 'STICKER_INVALID': StickerInvalidError, + 'STICKER_MIME_INVALID': StickerMimeInvalidError, + 'STICKER_PNG_DIMENSIONS': StickerPngDimensionsError, + 'STICKER_PNG_NOPNG': StickerPngNopngError, + 'STICKER_TGS_NODOC': StickerTgsNodocError, + 'STICKER_TGS_NOTGS': StickerTgsNotgsError, + 'STICKER_THUMB_PNG_NOPNG': StickerThumbPngNopngError, + 'STICKER_THUMB_TGS_NOTGS': StickerThumbTgsNotgsError, + 'STICKER_VIDEO_BIG': StickerVideoBigError, + 'STICKER_VIDEO_NODOC': StickerVideoNodocError, + 'STICKER_VIDEO_NOWEBM': StickerVideoNowebmError, + 'STORAGE_CHECK_FAILED': StorageCheckFailedError, + 'STORE_INVALID_SCALAR_TYPE': StoreInvalidScalarTypeError, + 'SWITCH_PM_TEXT_EMPTY': SwitchPmTextEmptyError, + 'TAKEOUT_INVALID': TakeoutInvalidError, + 'TAKEOUT_REQUIRED': TakeoutRequiredError, + 'TEMP_AUTH_KEY_ALREADY_BOUND': TempAuthKeyAlreadyBoundError, + 'TEMP_AUTH_KEY_EMPTY': TempAuthKeyEmptyError, + 'THEME_FILE_INVALID': ThemeFileInvalidError, + 'THEME_FORMAT_INVALID': ThemeFormatInvalidError, + 'THEME_INVALID': ThemeInvalidError, + 'THEME_MIME_INVALID': ThemeMimeInvalidError, + 'THEME_TITLE_INVALID': ThemeTitleInvalidError, + 'TIMEOUT': TimeoutError, + 'TITLE_INVALID': TitleInvalidError, + 'TMP_PASSWORD_DISABLED': TmpPasswordDisabledError, + 'TMP_PASSWORD_INVALID': TmpPasswordInvalidError, + 'TOKEN_INVALID': TokenInvalidError, + 'TOPIC_DELETED': TopicDeletedError, + 'TO_LANG_INVALID': ToLangInvalidError, + 'TTL_DAYS_INVALID': TtlDaysInvalidError, + 'TTL_MEDIA_INVALID': TtlMediaInvalidError, + 'TTL_PERIOD_INVALID': TtlPeriodInvalidError, + 'TYPES_EMPTY': TypesEmptyError, + 'TYPE_CONSTRUCTOR_INVALID': TypeConstructorInvalidError, + 'Timedout': TimedoutError, + 'Timeout': TimeoutError, + 'UNKNOWN_ERROR': UnknownErrorError, + 'UNKNOWN_METHOD': UnknownMethodError, + 'UNTIL_DATE_INVALID': UntilDateInvalidError, + 'UPDATE_APP_TO_LOGIN': UpdateAppToLoginError, + 'URL_INVALID': UrlInvalidError, + 'USAGE_LIMIT_INVALID': UsageLimitInvalidError, + 'USERNAME_INVALID': UsernameInvalidError, + 'USERNAME_NOT_MODIFIED': UsernameNotModifiedError, + 'USERNAME_NOT_OCCUPIED': UsernameNotOccupiedError, + 'USERNAME_OCCUPIED': UsernameOccupiedError, + 'USERNAME_PURCHASE_AVAILABLE': UsernamePurchaseAvailableError, + 'USERPIC_PRIVACY_REQUIRED': UserpicPrivacyRequiredError, + 'USERPIC_UPLOAD_REQUIRED': UserpicUploadRequiredError, + 'USERS_TOO_FEW': UsersTooFewError, + 'USERS_TOO_MUCH': UsersTooMuchError, + 'USER_ADMIN_INVALID': UserAdminInvalidError, + 'USER_ALREADY_INVITED': UserAlreadyInvitedError, + 'USER_ALREADY_PARTICIPANT': UserAlreadyParticipantError, + 'USER_BANNED_IN_CHANNEL': UserBannedInChannelError, + 'USER_BLOCKED': UserBlockedError, + 'USER_BOT': UserBotError, + 'USER_BOT_INVALID': UserBotInvalidError, + 'USER_BOT_REQUIRED': UserBotRequiredError, + 'USER_CHANNELS_TOO_MUCH': UserChannelsTooMuchError, + 'USER_CREATOR': UserCreatorError, + 'USER_DEACTIVATED': UserDeactivatedError, + 'USER_DEACTIVATED_BAN': UserDeactivatedBanError, + 'USER_DELETED': UserDeletedError, + 'USER_ID_INVALID': UserIdInvalidError, + 'USER_INVALID': UserInvalidError, + 'USER_IS_BLOCKED': UserIsBlockedError, + 'USER_IS_BOT': UserIsBotError, + 'USER_KICKED': UserKickedError, + 'USER_NOT_MUTUAL_CONTACT': UserNotMutualContactError, + 'USER_NOT_PARTICIPANT': UserNotParticipantError, + 'USER_PRIVACY_RESTRICTED': UserPrivacyRestrictedError, + 'USER_RESTRICTED': UserRestrictedError, + 'USER_VOLUME_INVALID': UserVolumeInvalidError, + 'VIDEO_CONTENT_TYPE_INVALID': VideoContentTypeInvalidError, + 'VIDEO_FILE_INVALID': VideoFileInvalidError, + 'VIDEO_TITLE_EMPTY': VideoTitleEmptyError, + 'VOICE_MESSAGES_FORBIDDEN': VoiceMessagesForbiddenError, + 'WALLPAPER_FILE_INVALID': WallpaperFileInvalidError, + 'WALLPAPER_INVALID': WallpaperInvalidError, + 'WALLPAPER_MIME_INVALID': WallpaperMimeInvalidError, + 'WC_CONVERT_URL_INVALID': WcConvertUrlInvalidError, + 'WEBDOCUMENT_INVALID': WebdocumentInvalidError, + 'WEBDOCUMENT_MIME_INVALID': WebdocumentMimeInvalidError, + 'WEBDOCUMENT_SIZE_TOO_BIG': WebdocumentSizeTooBigError, + 'WEBDOCUMENT_URL_INVALID': WebdocumentUrlInvalidError, + 'WEBPAGE_CURL_FAILED': WebpageCurlFailedError, + 'WEBPAGE_MEDIA_EMPTY': WebpageMediaEmptyError, + 'WEBPUSH_AUTH_INVALID': WebpushAuthInvalidError, + 'WEBPUSH_KEY_INVALID': WebpushKeyInvalidError, + 'WEBPUSH_TOKEN_INVALID': WebpushTokenInvalidError, + 'WORKER_BUSY_TOO_LONG_RETRY': WorkerBusyTooLongRetryError, + 'YOU_BLOCKED_USER': YouBlockedUserError, + 'FROZEN_METHOD_INVALID': FrozenMethodInvalidError, + 'FROZEN_PARTICIPANT_MISSING': FrozenParticipantMissingError, + 'CHAT_SEND_VOICES_FORBIDDEN': ChatSendVoicesForbiddenError, + 'CHAT_SEND_PHOTOS_FORBIDDEN': ChatSendPhotosForbiddenError, + 'CHAT_SEND_VIDEOS_FORBIDDEN': ChatSendVideosForbiddenError, + 'CHAT_SEND_PLAIN_FORBIDDEN': ChatSendPlainForbiddenError, +} + +rpc_errors_re = ( + ('2FA_CONFIRM_WAIT_(\\d+)', TwoFaConfirmWaitError), + ('EMAIL_UNCONFIRMED_(\\d+)', EmailUnconfirmedError), + ('FILE_MIGRATE_(\\d+)', FileMigrateError), + ('FILE_PART_(\\d+)_MISSING', FilePartMissingError), + ('FLOOD_TEST_PHONE_WAIT_(\\d+)', FloodTestPhoneWaitError), + ('FLOOD_WAIT_(\\d+)', FloodWaitError), + ('FLOOD_PREMIUM_WAIT_(\\d+)', FloodPremiumWaitError), + ('INTERDC_(\\d+)_CALL_ERROR', InterdcCallErrorError), + ('INTERDC_(\\d+)_CALL_RICH_ERROR', InterdcCallRichErrorError), + ('NETWORK_MIGRATE_(\\d+)', NetworkMigrateError), + ('PASSWORD_TOO_FRESH_(\\d+)', PasswordTooFreshError), + ('PHONE_MIGRATE_(\\d+)', PhoneMigrateError), + ('PREVIOUS_CHAT_IMPORT_ACTIVE_WAIT_(\\d+)MIN', PreviousChatImportActiveWaitMinError), + ('SESSION_TOO_FRESH_(\\d+)', SessionTooFreshError), + ('SLOWMODE_WAIT_(\\d+)', SlowModeWaitError), + ('STATS_MIGRATE_(\\d+)', StatsMigrateError), + ('TAKEOUT_INIT_DELAY_(\\d+)', TakeoutInitDelayError), + ('USER_MIGRATE_(\\d+)', UserMigrateError), +) diff --git a/module/scripts/lib/telethon/events/__init__.py b/module/scripts/lib/telethon/events/__init__.py new file mode 100644 index 0000000..28f85b1 --- /dev/null +++ b/module/scripts/lib/telethon/events/__init__.py @@ -0,0 +1,140 @@ +from .raw import Raw +from .album import Album +from .chataction import ChatAction +from .messagedeleted import MessageDeleted +from .messageedited import MessageEdited +from .messageread import MessageRead +from .newmessage import NewMessage +from .userupdate import UserUpdate +from .callbackquery import CallbackQuery +from .inlinequery import InlineQuery + + +_HANDLERS_ATTRIBUTE = '__tl.handlers' + + +class StopPropagation(Exception): + """ + If this exception is raised in any of the handlers for a given event, + it will stop the execution of all other registered event handlers. + It can be seen as the ``StopIteration`` in a for loop but for events. + + Example usage: + + >>> from telethon import TelegramClient, events + >>> client = TelegramClient(...) + >>> + >>> @client.on(events.NewMessage) + ... async def delete(event): + ... await event.delete() + ... # No other event handler will have a chance to handle this event + ... raise StopPropagation + ... + >>> @client.on(events.NewMessage) + ... async def _(event): + ... # Will never be reached, because it is the second handler + ... pass + """ + # For some reason Sphinx wants the silly >>> or + # it will show warnings and look bad when generated. + pass + + +def register(event=None): + """ + Decorator method to *register* event handlers. This is the client-less + `add_event_handler() + ` variant. + + Note that this method only registers callbacks as handlers, + and does not attach them to any client. This is useful for + external modules that don't have access to the client, but + still want to define themselves as a handler. Example: + + >>> from telethon import events + >>> @events.register(events.NewMessage) + ... async def handler(event): + ... ... + ... + >>> # (somewhere else) + ... + >>> from telethon import TelegramClient + >>> client = TelegramClient(...) + >>> client.add_event_handler(handler) + + Remember that you can use this as a non-decorator + through ``register(event)(callback)``. + + Args: + event (`_EventBuilder` | `type`): + The event builder class or instance to be used, + for instance ``events.NewMessage``. + """ + if isinstance(event, type): + event = event() + elif not event: + event = Raw() + + def decorator(callback): + handlers = getattr(callback, _HANDLERS_ATTRIBUTE, []) + handlers.append(event) + setattr(callback, _HANDLERS_ATTRIBUTE, handlers) + return callback + + return decorator + + +def unregister(callback, event=None): + """ + Inverse operation of `register` (though not a decorator). Client-less + `remove_event_handler + ` + variant. **Note that this won't remove handlers from the client**, + because it simply can't, so you would generally use this before + adding the handlers to the client. + + This method is here for symmetry. You will rarely need to + unregister events, since you can simply just not add them + to any client. + + If no event is given, all events for this callback are removed. + Returns how many callbacks were removed. + """ + found = 0 + if event and not isinstance(event, type): + event = type(event) + + handlers = getattr(callback, _HANDLERS_ATTRIBUTE, []) + handlers.append((event, callback)) + i = len(handlers) + while i: + i -= 1 + ev = handlers[i] + if not event or isinstance(ev, event): + del handlers[i] + found += 1 + + return found + + +def is_handler(callback): + """ + Returns `True` if the given callback is an + event handler (i.e. you used `register` on it). + """ + return hasattr(callback, _HANDLERS_ATTRIBUTE) + + +def list(callback): + """ + Returns a list containing the registered event + builders inside the specified callback handler. + """ + return getattr(callback, _HANDLERS_ATTRIBUTE, [])[:] + + +def _get_handlers(callback): + """ + Like ``list`` but returns `None` if the callback was never registered. + """ + return getattr(callback, _HANDLERS_ATTRIBUTE, None) diff --git a/module/scripts/lib/telethon/events/album.py b/module/scripts/lib/telethon/events/album.py new file mode 100644 index 0000000..481fedf --- /dev/null +++ b/module/scripts/lib/telethon/events/album.py @@ -0,0 +1,343 @@ +import asyncio +import time +import weakref + +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils +from ..tl import types +from ..tl.custom.sendergetter import SenderGetter + +_IGNORE_MAX_SIZE = 100 # len() +_IGNORE_MAX_AGE = 5 # seconds + +# IDs to ignore, and when they were added. If it grows too large, we will +# remove old entries. Although it should generally not be bigger than 10, +# it may be possible some updates are not processed and thus not removed. +_IGNORE_DICT = {} + + +_HACK_DELAY = 0.5 + + +class AlbumHack: + """ + When receiving an album from a different data-center, they will come in + separate `Updates`, so we need to temporarily remember them for a while + and only after produce the event. + + Of course events are not designed for this kind of wizardy, so this is + a dirty hack that gets the job done. + + When cleaning up the code base we may want to figure out a better way + to do this, or just leave the album problem to the users; the update + handling code is bad enough as it is. + """ + def __init__(self, client, event): + # It's probably silly to use a weakref here because this object is + # very short-lived but might as well try to do "the right thing". + self._client = weakref.ref(client) + self._event = event # parent event + self._due = client.loop.time() + _HACK_DELAY + + client.loop.create_task(self.deliver_event()) + + def extend(self, messages): + client = self._client() + if client: # weakref may be dead + self._event.messages.extend(messages) + self._due = client.loop.time() + _HACK_DELAY + + async def deliver_event(self): + while True: + client = self._client() + if client is None: + return # weakref is dead, nothing to deliver + + diff = self._due - client.loop.time() + if diff <= 0: + # We've hit our due time, deliver event. It won't respect + # sequential updates but fixing that would just worsen this. + await client._dispatch_event(self._event) + return + + del client # Clear ref and sleep until our due time + await asyncio.sleep(diff) + + +@name_inner_event +class Album(EventBuilder): + """ + Occurs whenever you receive an album. This event only exists + to ease dealing with an unknown amount of messages that belong + to the same album. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.Album) + async def handler(event): + # Counting how many photos or videos the album has + print('Got an album with', len(event), 'items') + + # Forwarding the album as a whole to some chat + event.forward_to(chat) + + # Printing the caption + print(event.text) + + # Replying to the fifth item in the album + await event.messages[4].reply('Cool!') + """ + + def __init__( + self, chats=None, *, blacklist_chats=False, func=None): + super().__init__(chats, blacklist_chats=blacklist_chats, func=func) + + @classmethod + def build(cls, update, others=None, self_id=None): + # TODO normally we'd only check updates if they come with other updates + # but MessageBox is not designed for this so others will always be None. + # In essence we always rely on AlbumHack rather than returning early if not others. + others = [update] + + if isinstance(update, + (types.UpdateNewMessage, types.UpdateNewChannelMessage)): + if not isinstance(update.message, types.Message): + return # We don't care about MessageService's here + + group = update.message.grouped_id + if group is None: + return # It must be grouped + + # Check whether we are supposed to skip this update, and + # if we do also remove it from the ignore list since we + # won't need to check against it again. + if _IGNORE_DICT.pop(id(update), None): + return + + # Check if the ignore list is too big, and if it is clean it + # TODO time could technically go backwards; time is not monotonic + now = time.time() + if len(_IGNORE_DICT) > _IGNORE_MAX_SIZE: + for i in [i for i, t in _IGNORE_DICT.items() if now - t > _IGNORE_MAX_AGE]: + del _IGNORE_DICT[i] + + # Add the other updates to the ignore list + for u in others: + if u is not update: + _IGNORE_DICT[id(u)] = now + + # Figure out which updates share the same group and use those + return cls.Event([ + u.message for u in others + if (isinstance(u, (types.UpdateNewMessage, types.UpdateNewChannelMessage)) + and isinstance(u.message, types.Message) + and u.message.grouped_id == group) + ]) + + def filter(self, event): + # Albums with less than two messages require a few hacks to work. + if len(event.messages) > 1: + return super().filter(event) + + class Event(EventCommon, SenderGetter): + """ + Represents the event of a new album. + + Members: + messages (Sequence[`Message `]): + The list of messages belonging to the same album. + """ + def __init__(self, messages): + message = messages[0] + super().__init__(chat_peer=message.peer_id, + msg_id=message.id, broadcast=bool(message.post)) + SenderGetter.__init__(self, message.sender_id) + self.messages = messages + + def _set_client(self, client): + super()._set_client(client) + self._sender, self._input_sender = utils._get_entity_pair( + self.sender_id, self._entities, client._mb_entity_cache) + + for msg in self.messages: + msg._finish_init(client, self._entities, None) + + if len(self.messages) == 1: + # This will require hacks to be a proper album event + hack = client._albums.get(self.grouped_id) + if hack is None: + client._albums[self.grouped_id] = AlbumHack(client, self) + else: + hack.extend(self.messages) + + @property + def grouped_id(self): + """ + The shared ``grouped_id`` between all the messages. + """ + return self.messages[0].grouped_id + + @property + def text(self): + """ + The message text of the first photo with a caption, + formatted using the client's default parse mode. + """ + return next((m.text for m in self.messages if m.text), '') + + @property + def raw_text(self): + """ + The raw message text of the first photo + with a caption, ignoring any formatting. + """ + return next((m.raw_text for m in self.messages if m.raw_text), '') + + @property + def is_reply(self): + """ + `True` if the album is a reply to some other message. + + Remember that you can access the ID of the message + this one is replying to through `reply_to_msg_id`, + and the `Message` object with `get_reply_message()`. + """ + # Each individual message in an album all reply to the same message + return self.messages[0].is_reply + + @property + def forward(self): + """ + The `Forward ` + information for the first message in the album if it was forwarded. + """ + # Each individual message in an album all reply to the same message + return self.messages[0].forward + + # endregion Public Properties + + # region Public Methods + + async def get_reply_message(self): + """ + The `Message ` + that this album is replying to, or `None`. + + The result will be cached after its first use. + """ + return await self.messages[0].get_reply_message() + + async def respond(self, *args, **kwargs): + """ + Responds to the album (not as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` + with ``entity`` already set. + """ + return await self.messages[0].respond(*args, **kwargs) + + async def reply(self, *args, **kwargs): + """ + Replies to the first photo in the album (as a reply). Shorthand + for `telethon.client.messages.MessageMethods.send_message` + with both ``entity`` and ``reply_to`` already set. + """ + return await self.messages[0].reply(*args, **kwargs) + + async def forward_to(self, *args, **kwargs): + """ + Forwards the entire album. Shorthand for + `telethon.client.messages.MessageMethods.forward_messages` + with both ``messages`` and ``from_peer`` already set. + """ + if self._client: + kwargs['messages'] = self.messages + kwargs['from_peer'] = await self.get_input_chat() + return await self._client.forward_messages(*args, **kwargs) + + async def edit(self, *args, **kwargs): + """ + Edits the first caption or the message, or the first messages' + caption if no caption is set, iff it's outgoing. Shorthand for + `telethon.client.messages.MessageMethods.edit_message` + with both ``entity`` and ``message`` already set. + + Returns `None` if the message was incoming, + or the edited `Message` otherwise. + + .. note:: + + This is different from `client.edit_message + ` + and **will respect** the previous state of the message. + For example, if the message didn't have a link preview, + the edit won't add one by default, and you should force + it by setting it to `True` if you want it. + + This is generally the most desired and convenient behaviour, + and will work for link previews and message buttons. + """ + for msg in self.messages: + if msg.raw_text: + return await msg.edit(*args, **kwargs) + + return await self.messages[0].edit(*args, **kwargs) + + async def delete(self, *args, **kwargs): + """ + Deletes the entire album. You're responsible for checking whether + you have the permission to do so, or to except the error otherwise. + Shorthand for + `telethon.client.messages.MessageMethods.delete_messages` with + ``entity`` and ``message_ids`` already set. + """ + if self._client: + return await self._client.delete_messages( + await self.get_input_chat(), self.messages, + *args, **kwargs + ) + + async def mark_read(self): + """ + Marks the entire album as read. Shorthand for + `client.send_read_acknowledge() + ` + with both ``entity`` and ``message`` already set. + """ + if self._client: + await self._client.send_read_acknowledge( + await self.get_input_chat(), max_id=self.messages[-1].id) + + async def pin(self, *, notify=False): + """ + Pins the first photo in the album. Shorthand for + `telethon.client.messages.MessageMethods.pin_message` + with both ``entity`` and ``message`` already set. + """ + return await self.messages[0].pin(notify=notify) + + def __len__(self): + """ + Return the amount of messages in the album. + + Equivalent to ``len(self.messages)``. + """ + return len(self.messages) + + def __iter__(self): + """ + Iterate over the messages in the album. + + Equivalent to ``iter(self.messages)``. + """ + return iter(self.messages) + + def __getitem__(self, n): + """ + Access the n'th message in the album. + + Equivalent to ``event.messages[n]``. + """ + return self.messages[n] diff --git a/module/scripts/lib/telethon/events/callbackquery.py b/module/scripts/lib/telethon/events/callbackquery.py new file mode 100644 index 0000000..29ffd05 --- /dev/null +++ b/module/scripts/lib/telethon/events/callbackquery.py @@ -0,0 +1,346 @@ +import re +import struct + +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils +from ..tl import types, functions +from ..tl.custom.sendergetter import SenderGetter + + +@name_inner_event +class CallbackQuery(EventBuilder): + """ + Occurs whenever you sign in as a bot and a user + clicks one of the inline buttons on your messages. + + Note that the `chats` parameter will **not** work with normal + IDs or peers if the clicked inline button comes from a "via bot" + message. The `chats` parameter also supports checking against the + `chat_instance` which should be used for inline callbacks. + + Args: + data (`bytes`, `str`, `callable`, optional): + If set, the inline button payload data must match this data. + A UTF-8 string can also be given, a regex or a callable. For + instance, to check against ``'data_1'`` and ``'data_2'`` you + can use ``re.compile(b'data_')``. + + pattern (`bytes`, `str`, `callable`, `Pattern`, optional): + If set, only buttons with payload matching this pattern will be handled. + You can specify a regex-like string which will be matched + against the payload data, a callable function that returns `True` + if a the payload data is acceptable, or a compiled regex pattern. + + Example + .. code-block:: python + + from telethon import events, Button + + # Handle all callback queries and check data inside the handler + @client.on(events.CallbackQuery) + async def handler(event): + if event.data == b'yes': + await event.answer('Correct answer!') + + # Handle only callback queries with data being b'no' + @client.on(events.CallbackQuery(data=b'no')) + async def handler(event): + # Pop-up message with alert + await event.answer('Wrong answer!', alert=True) + + # Send a message with buttons users can click + async def main(): + await client.send_message(user, 'Yes or no?', buttons=[ + Button.inline('Yes!', b'yes'), + Button.inline('Nope', b'no') + ]) + """ + def __init__( + self, chats=None, *, blacklist_chats=False, func=None, data=None, pattern=None): + super().__init__(chats, blacklist_chats=blacklist_chats, func=func) + + if data and pattern: + raise ValueError("Only pass either data or pattern not both.") + + if isinstance(data, str): + data = data.encode('utf-8') + if isinstance(pattern, str): + pattern = pattern.encode('utf-8') + + match = data if data else pattern + + if isinstance(match, bytes): + self.match = data if data else re.compile(pattern).match + elif not match or callable(match): + self.match = match + elif hasattr(match, 'match') and callable(match.match): + if not isinstance(getattr(match, 'pattern', b''), bytes): + match = re.compile(match.pattern.encode('utf-8'), + match.flags & (~re.UNICODE)) + + self.match = match.match + else: + raise TypeError('Invalid data or pattern type given') + + self._no_check = all(x is None for x in ( + self.chats, self.func, self.match, + )) + + @classmethod + def build(cls, update, others=None, self_id=None): + if isinstance(update, types.UpdateBotCallbackQuery): + return cls.Event(update, update.peer, update.msg_id) + elif isinstance(update, types.UpdateInlineBotCallbackQuery): + # See https://github.com/LonamiWebs/Telethon/pull/1005 + # The long message ID is actually just msg_id + peer_id + mid, pid = struct.unpack('`, + since the message object is normally not present. + """ + self._client.loop.create_task(self.answer()) + if isinstance(self.query.msg_id, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)): + return await self._client.edit_message( + self.query.msg_id, *args, **kwargs + ) + else: + return await self._client.edit_message( + await self.get_input_chat(), self.query.msg_id, + *args, **kwargs + ) + + async def delete(self, *args, **kwargs): + """ + Deletes the message. Shorthand for + `telethon.client.messages.MessageMethods.delete_messages` with + ``entity`` and ``message_ids`` already set. + + If you need to delete more than one message at once, don't use + this `delete` method. Use a + `telethon.client.telegramclient.TelegramClient` instance directly. + + This method also creates a task to `answer` the callback. + + This method will likely fail if `via_inline` is `True`. + """ + self._client.loop.create_task(self.answer()) + if isinstance(self.query.msg_id, (types.InputBotInlineMessageID, types.InputBotInlineMessageID64)): + raise TypeError('Inline messages cannot be deleted as there is no API request available to do so') + return await self._client.delete_messages( + await self.get_input_chat(), [self.query.msg_id], + *args, **kwargs + ) diff --git a/module/scripts/lib/telethon/events/chataction.py b/module/scripts/lib/telethon/events/chataction.py new file mode 100644 index 0000000..b8fc3dc --- /dev/null +++ b/module/scripts/lib/telethon/events/chataction.py @@ -0,0 +1,458 @@ +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils +from ..tl import types + + +@name_inner_event +class ChatAction(EventBuilder): + """ + Occurs on certain chat actions: + + * Whenever a new chat is created. + * Whenever a chat's title or photo is changed or removed. + * Whenever a new message is pinned. + * Whenever a user scores in a game. + * Whenever a user joins or is added to the group. + * Whenever a user is removed or leaves a group if it has + less than 50 members or the removed user was a bot. + + Note that "chat" refers to "small group, megagroup and broadcast + channel", whereas "group" refers to "small group and megagroup" only. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.ChatAction) + async def handler(event): + # Welcome every new user + if event.user_joined: + await event.reply('Welcome to the group!') + """ + + @classmethod + def build(cls, update, others=None, self_id=None): + # Rely on specific pin updates for unpins, but otherwise ignore them + # for new pins (we'd rather handle the new service message with pin, + # so that we can act on that message'). + if isinstance(update, types.UpdatePinnedChannelMessages) and not update.pinned: + return cls.Event(types.PeerChannel(update.channel_id), + pin_ids=update.messages, + pin=update.pinned) + + elif isinstance(update, types.UpdatePinnedMessages) and not update.pinned: + return cls.Event(update.peer, + pin_ids=update.messages, + pin=update.pinned) + + elif isinstance(update, types.UpdateChatParticipantAdd): + return cls.Event(types.PeerChat(update.chat_id), + added_by=update.inviter_id or True, + users=update.user_id) + + elif isinstance(update, types.UpdateChatParticipantDelete): + return cls.Event(types.PeerChat(update.chat_id), + kicked_by=True, + users=update.user_id) + + # UpdateChannel is sent if we leave a channel, and the update._entities + # set by _process_update would let us make some guesses. However it's + # better not to rely on this. Rely only in MessageActionChatDeleteUser. + + elif (isinstance(update, ( + types.UpdateNewMessage, types.UpdateNewChannelMessage)) + and isinstance(update.message, types.MessageService)): + msg = update.message + action = update.message.action + if isinstance(action, types.MessageActionChatJoinedByLink): + return cls.Event(msg, + added_by=True, + users=msg.from_id) + elif isinstance(action, types.MessageActionChatAddUser): + # If a user adds itself, it means they joined via the public chat username + added_by = ([msg.sender_id] == action.users) or msg.from_id + return cls.Event(msg, + added_by=added_by, + users=action.users) + elif isinstance(action, types.MessageActionChatDeleteUser): + return cls.Event(msg, + kicked_by=utils.get_peer_id(msg.from_id) if msg.from_id else True, + users=action.user_id) + elif isinstance(action, types.MessageActionChatCreate): + return cls.Event(msg, + users=action.users, + created=True, + new_title=action.title) + elif isinstance(action, types.MessageActionChannelCreate): + return cls.Event(msg, + created=True, + users=msg.from_id, + new_title=action.title) + elif isinstance(action, types.MessageActionChatEditTitle): + return cls.Event(msg, + users=msg.from_id, + new_title=action.title) + elif isinstance(action, types.MessageActionChatEditPhoto): + return cls.Event(msg, + users=msg.from_id, + new_photo=action.photo) + elif isinstance(action, types.MessageActionChatDeletePhoto): + return cls.Event(msg, + users=msg.from_id, + new_photo=True) + elif isinstance(action, types.MessageActionPinMessage) and msg.reply_to: + return cls.Event(msg, + pin_ids=[msg.reply_to_msg_id]) + elif isinstance(action, types.MessageActionGameScore): + return cls.Event(msg, + new_score=action.score) + + elif isinstance(update, types.UpdateChannelParticipant) \ + and bool(update.new_participant) != bool(update.prev_participant): + # If members are hidden, bots will receive this update instead, + # as there won't be a service message. Promotions and demotions + # seem to have both new and prev participant, which are ignored + # by this event. + return cls.Event(types.PeerChannel(update.channel_id), + users=update.user_id, + added_by=update.actor_id if update.new_participant else None, + kicked_by=update.actor_id if update.prev_participant else None) + + class Event(EventCommon): + """ + Represents the event of a new chat action. + + Members: + action_message (`MessageAction `_): + The message invoked by this Chat Action. + + new_pin (`bool`): + `True` if there is a new pin. + + new_photo (`bool`): + `True` if there's a new chat photo (or it was removed). + + photo (:tl:`Photo`, optional): + The new photo (or `None` if it was removed). + + user_added (`bool`): + `True` if the user was added by some other. + + user_joined (`bool`): + `True` if the user joined on their own. + + user_left (`bool`): + `True` if the user left on their own. + + user_kicked (`bool`): + `True` if the user was kicked by some other. + + created (`bool`, optional): + `True` if this chat was just created. + + new_title (`str`, optional): + The new title string for the chat, if applicable. + + new_score (`str`, optional): + The new score string for the game, if applicable. + + unpin (`bool`): + `True` if the existing pin gets unpinned. + """ + + def __init__(self, where, new_photo=None, + added_by=None, kicked_by=None, created=None, + users=None, new_title=None, pin_ids=None, pin=None, new_score=None): + if isinstance(where, types.MessageService): + self.action_message = where + where = where.peer_id + else: + self.action_message = None + + # TODO needs some testing (can there be more than one id, and do they follow pin order?) + # same in get_pinned_message + super().__init__(chat_peer=where, msg_id=pin_ids[0] if pin_ids else None) + + self.new_pin = pin_ids is not None + self._pin_ids = pin_ids + self._pinned_messages = None + + self.new_photo = new_photo is not None + self.photo = \ + new_photo if isinstance(new_photo, types.Photo) else None + + self._added_by = None + self._kicked_by = None + self.user_added = self.user_joined = self.user_left = \ + self.user_kicked = self.unpin = False + + if added_by is True: + self.user_joined = True + elif added_by: + self.user_added = True + self._added_by = added_by + + # If `from_id` was not present (it's `True`) or the affected + # user was "kicked by itself", then it left. Else it was kicked. + if kicked_by is True or (users is not None and kicked_by == users): + self.user_left = True + elif kicked_by: + self.user_kicked = True + self._kicked_by = kicked_by + + self.created = bool(created) + + if isinstance(users, list): + self._user_ids = [utils.get_peer_id(u) for u in users] + elif users: + self._user_ids = [utils.get_peer_id(users)] + else: + self._user_ids = [] + + self._users = None + self._input_users = None + self.new_title = new_title + self.new_score = new_score + self.unpin = not pin + + def _set_client(self, client): + super()._set_client(client) + if self.action_message: + self.action_message._finish_init(client, self._entities, None) + + async def respond(self, *args, **kwargs): + """ + Responds to the chat action message (not as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` with + ``entity`` already set. + """ + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def reply(self, *args, **kwargs): + """ + Replies to the chat action message (as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` with + both ``entity`` and ``reply_to`` already set. + + Has the same effect as `respond` if there is no message. + """ + if not self.action_message: + return await self.respond(*args, **kwargs) + + kwargs['reply_to'] = self.action_message.id + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def delete(self, *args, **kwargs): + """ + Deletes the chat action message. You're responsible for checking + whether you have the permission to do so, or to except the error + otherwise. Shorthand for + `telethon.client.messages.MessageMethods.delete_messages` with + ``entity`` and ``message_ids`` already set. + + Does nothing if no message action triggered this event. + """ + if not self.action_message: + return + + return await self._client.delete_messages( + await self.get_input_chat(), [self.action_message], + *args, **kwargs + ) + + async def get_pinned_message(self): + """ + If ``new_pin`` is `True`, this returns the `Message + ` object that was pinned. + """ + if self._pinned_messages is None: + await self.get_pinned_messages() + + if self._pinned_messages: + return self._pinned_messages[0] + + async def get_pinned_messages(self): + """ + If ``new_pin`` is `True`, this returns a `list` of `Message + ` objects that were pinned. + """ + if not self._pin_ids: + return self._pin_ids # either None or empty list + + chat = await self.get_input_chat() + if chat: + self._pinned_messages = await self._client.get_messages( + self._input_chat, ids=self._pin_ids) + + return self._pinned_messages + + @property + def added_by(self): + """ + The user who added ``users``, if applicable (`None` otherwise). + """ + if self._added_by and not isinstance(self._added_by, types.User): + aby = self._entities.get(utils.get_peer_id(self._added_by)) + if aby: + self._added_by = aby + + return self._added_by + + async def get_added_by(self): + """ + Returns `added_by` but will make an API call if necessary. + """ + if not self.added_by and self._added_by: + self._added_by = await self._client.get_entity(self._added_by) + + return self._added_by + + @property + def kicked_by(self): + """ + The user who kicked ``users``, if applicable (`None` otherwise). + """ + if self._kicked_by and not isinstance(self._kicked_by, types.User): + kby = self._entities.get(utils.get_peer_id(self._kicked_by)) + if kby: + self._kicked_by = kby + + return self._kicked_by + + async def get_kicked_by(self): + """ + Returns `kicked_by` but will make an API call if necessary. + """ + if not self.kicked_by and self._kicked_by: + self._kicked_by = await self._client.get_entity(self._kicked_by) + + return self._kicked_by + + @property + def user(self): + """ + The first user that takes part in this action. For example, who joined. + + Might be `None` if the information can't be retrieved or + there is no user taking part. + """ + if self.users: + return self._users[0] + + async def get_user(self): + """ + Returns `user` but will make an API call if necessary. + """ + if self.users or await self.get_users(): + return self._users[0] + + @property + def input_user(self): + """ + Input version of the ``self.user`` property. + """ + if self.input_users: + return self._input_users[0] + + async def get_input_user(self): + """ + Returns `input_user` but will make an API call if necessary. + """ + if self.input_users or await self.get_input_users(): + return self._input_users[0] + + @property + def user_id(self): + """ + Returns the marked signed ID of the first user, if any. + """ + if self._user_ids: + return self._user_ids[0] + + @property + def users(self): + """ + A list of users that take part in this action. For example, who joined. + + Might be empty if the information can't be retrieved or there + are no users taking part. + """ + if not self._user_ids: + return [] + + if self._users is None: + self._users = [ + self._entities[user_id] + for user_id in self._user_ids + if user_id in self._entities + ] + + return self._users + + async def get_users(self): + """ + Returns `users` but will make an API call if necessary. + """ + if not self._user_ids: + return [] + + # Note: we access the property first so that it fills if needed + if (self.users is None or len(self._users) != len(self._user_ids)) and self.action_message: + await self.action_message._reload_message() + self._users = [ + u for u in self.action_message.action_entities + if isinstance(u, (types.User, types.UserEmpty))] + + return self._users + + @property + def input_users(self): + """ + Input version of the ``self.users`` property. + """ + if self._input_users is None and self._user_ids: + self._input_users = [] + for user_id in self._user_ids: + # First try to get it from our entities + try: + self._input_users.append(utils.get_input_peer(self._entities[user_id])) + continue + except (KeyError, TypeError): + pass + + # If missing, try from the entity cache + try: + self._input_users.append(self._client._mb_entity_cache.get( + utils.resolve_id(user_id)[0])._as_input_peer()) + continue + except AttributeError: + pass + + return self._input_users or [] + + async def get_input_users(self): + """ + Returns `input_users` but will make an API call if necessary. + """ + if not self._user_ids: + return [] + + # Note: we access the property first so that it fills if needed + if (self.input_users is None or len(self._input_users) != len(self._user_ids)) and self.action_message: + self._input_users = [ + utils.get_input_peer(u) + for u in self.action_message.action_entities + if isinstance(u, (types.User, types.UserEmpty))] + + return self._input_users or [] + + @property + def user_ids(self): + """ + Returns the marked signed ID of the users, if any. + """ + if self._user_ids: + return self._user_ids[:] diff --git a/module/scripts/lib/telethon/events/common.py b/module/scripts/lib/telethon/events/common.py new file mode 100644 index 0000000..e295adb --- /dev/null +++ b/module/scripts/lib/telethon/events/common.py @@ -0,0 +1,186 @@ +import abc +import asyncio +import warnings + +from .. import utils +from ..tl import TLObject, types +from ..tl.custom.chatgetter import ChatGetter + + +async def _into_id_set(client, chats): + """Helper util to turn the input chat or chats into a set of IDs.""" + if chats is None: + return None + + if not utils.is_list_like(chats): + chats = (chats,) + + result = set() + for chat in chats: + if isinstance(chat, int): + if chat < 0: + result.add(chat) # Explicitly marked IDs are negative + else: + result.update({ # Support all valid types of peers + utils.get_peer_id(types.PeerUser(chat)), + utils.get_peer_id(types.PeerChat(chat)), + utils.get_peer_id(types.PeerChannel(chat)), + }) + elif isinstance(chat, TLObject) and chat.SUBCLASS_OF_ID == 0x2d45687: + # 0x2d45687 == crc32(b'Peer') + result.add(utils.get_peer_id(chat)) + else: + chat = await client.get_input_entity(chat) + if isinstance(chat, types.InputPeerSelf): + chat = await client.get_me(input_peer=True) + result.add(utils.get_peer_id(chat)) + + return result + + +class EventBuilder(abc.ABC): + """ + The common event builder, with builtin support to filter per chat. + + Args: + chats (`entity`, optional): + May be one or more entities (username/peer/etc.), preferably IDs. + By default, only matching chats will be handled. + + blacklist_chats (`bool`, optional): + Whether to treat the chats as a blacklist instead of + as a whitelist (default). This means that every chat + will be handled *except* those specified in ``chats`` + which will be ignored if ``blacklist_chats=True``. + + func (`callable`, optional): + A callable (async or not) function that should accept the event as input + parameter, and return a value indicating whether the event + should be dispatched or not (any truthy value will do, it + does not need to be a `bool`). It works like a custom filter: + + .. code-block:: python + + @client.on(events.NewMessage(func=lambda e: e.is_private)) + async def handler(event): + pass # code here + """ + def __init__(self, chats=None, *, blacklist_chats=False, func=None): + self.chats = chats + self.blacklist_chats = bool(blacklist_chats) + self.resolved = False + self.func = func + self._resolve_lock = None + + @classmethod + @abc.abstractmethod + def build(cls, update, others=None, self_id=None): + """ + Builds an event for the given update if possible, or returns None. + + `others` are the rest of updates that came in the same container + as the current `update`. + + `self_id` should be the current user's ID, since it is required + for some events which lack this information but still need it. + """ + # TODO So many parameters specific to only some update types seems dirty + + async def resolve(self, client): + """Helper method to allow event builders to be resolved before usage""" + if self.resolved: + return + + if not self._resolve_lock: + self._resolve_lock = asyncio.Lock() + + async with self._resolve_lock: + if not self.resolved: + await self._resolve(client) + self.resolved = True + + async def _resolve(self, client): + self.chats = await _into_id_set(client, self.chats) + + def filter(self, event): + """ + Returns a truthy value if the event passed the filter and should be + used, or falsy otherwise. The return value may need to be awaited. + + The events must have been resolved before this can be called. + """ + if not self.resolved: + return + + if self.chats is not None: + # Note: the `event.chat_id` property checks if it's `None` for us + inside = event.chat_id in self.chats + if inside == self.blacklist_chats: + # If this chat matches but it's a blacklist ignore. + # If it doesn't match but it's a whitelist ignore. + return + + if not self.func: + return True + + # Return the result of func directly as it may need to be awaited + return self.func(event) + + +class EventCommon(ChatGetter, abc.ABC): + """ + Intermediate class with common things to all events. + + Remember that this class implements `ChatGetter + ` which + means you have access to all chat properties and methods. + + In addition, you can access the `original_update` + field which contains the original :tl:`Update`. + """ + _event_name = 'Event' + + def __init__(self, chat_peer=None, msg_id=None, broadcast=None): + super().__init__(chat_peer, broadcast=broadcast) + self._entities = {} + self._client = None + self._message_id = msg_id + self.original_update = None + + def _set_client(self, client): + """ + Setter so subclasses can act accordingly when the client is set. + """ + self._client = client + if self._chat_peer: + self._chat, self._input_chat = utils._get_entity_pair( + self.chat_id, self._entities, client._mb_entity_cache) + else: + self._chat = self._input_chat = None + + @property + def client(self): + """ + The `telethon.TelegramClient` that created this event. + """ + return self._client + + def __str__(self): + return TLObject.pretty_format(self.to_dict()) + + def stringify(self): + return TLObject.pretty_format(self.to_dict(), indent=0) + + def to_dict(self): + d = {k: v for k, v in self.__dict__.items() if k[0] != '_'} + d['_'] = self._event_name + return d + + +def name_inner_event(cls): + """Decorator to rename cls.Event 'Event' as 'cls.Event'""" + if hasattr(cls, 'Event'): + cls.Event._event_name = '{}.Event'.format(cls.__name__) + else: + warnings.warn('Class {} does not have a inner Event'.format(cls)) + return cls diff --git a/module/scripts/lib/telethon/events/inlinequery.py b/module/scripts/lib/telethon/events/inlinequery.py new file mode 100644 index 0000000..7a065e5 --- /dev/null +++ b/module/scripts/lib/telethon/events/inlinequery.py @@ -0,0 +1,247 @@ +import inspect +import re + +import asyncio + +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils, helpers +from ..tl import types, functions, custom +from ..tl.custom.sendergetter import SenderGetter + + +@name_inner_event +class InlineQuery(EventBuilder): + """ + Occurs whenever you sign in as a bot and a user + sends an inline query such as ``@bot query``. + + Args: + users (`entity`, optional): + May be one or more entities (username/peer/etc.), preferably IDs. + By default, only inline queries from these users will be handled. + + blacklist_users (`bool`, optional): + Whether to treat the users as a blacklist instead of + as a whitelist (default). This means that every chat + will be handled *except* those specified in ``users`` + which will be ignored if ``blacklist_users=True``. + + pattern (`str`, `callable`, `Pattern`, optional): + If set, only queries matching this pattern will be handled. + You can specify a regex-like string which will be matched + against the message, a callable function that returns `True` + if a message is acceptable, or a compiled regex pattern. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.InlineQuery) + async def handler(event): + builder = event.builder + + # Two options (convert user text to UPPERCASE or lowercase) + await event.answer([ + builder.article('UPPERCASE', text=event.text.upper()), + builder.article('lowercase', text=event.text.lower()), + ]) + """ + def __init__( + self, users=None, *, blacklist_users=False, func=None, pattern=None): + super().__init__(users, blacklist_chats=blacklist_users, func=func) + + if isinstance(pattern, str): + self.pattern = re.compile(pattern).match + elif not pattern or callable(pattern): + self.pattern = pattern + elif hasattr(pattern, 'match') and callable(pattern.match): + self.pattern = pattern.match + else: + raise TypeError('Invalid pattern type given') + + @classmethod + def build(cls, update, others=None, self_id=None): + if isinstance(update, types.UpdateBotInlineQuery): + return cls.Event(update) + + def filter(self, event): + if self.pattern: + match = self.pattern(event.text) + if not match: + return + event.pattern_match = match + + return super().filter(event) + + class Event(EventCommon, SenderGetter): + """ + Represents the event of a new callback query. + + Members: + query (:tl:`UpdateBotInlineQuery`): + The original :tl:`UpdateBotInlineQuery`. + + Make sure to access the `text` property of the query if + you want the text rather than the actual query object. + + pattern_match (`obj`, optional): + The resulting object from calling the passed ``pattern`` + function, which is ``re.compile(...).match`` by default. + """ + def __init__(self, query): + super().__init__(chat_peer=types.PeerUser(query.user_id)) + SenderGetter.__init__(self, query.user_id) + self.query = query + self.pattern_match = None + self._answered = False + + def _set_client(self, client): + super()._set_client(client) + self._sender, self._input_sender = utils._get_entity_pair( + self.sender_id, self._entities, client._mb_entity_cache) + + @property + def id(self): + """ + Returns the unique identifier for the query ID. + """ + return self.query.query_id + + @property + def text(self): + """ + Returns the text the user used to make the inline query. + """ + return self.query.query + + @property + def offset(self): + """ + The string the user's client used as an offset for the query. + This will either be empty or equal to offsets passed to `answer`. + """ + return self.query.offset + + @property + def geo(self): + """ + If the user location is requested when using inline mode + and the user's device is able to send it, this will return + the :tl:`GeoPoint` with the position of the user. + """ + return self.query.geo + + @property + def builder(self): + """ + Returns a new `InlineBuilder + ` instance. + """ + return custom.InlineBuilder(self._client) + + async def answer( + self, results=None, cache_time=0, *, + gallery=False, next_offset=None, private=False, + switch_pm=None, switch_pm_param=''): + """ + Answers the inline query with the given results. + + See the documentation for `builder` to know what kind of answers + can be given. + + Args: + results (`list`, optional): + A list of :tl:`InputBotInlineResult` to use. + You should use `builder` to create these: + + .. code-block:: python + + builder = inline.builder + r1 = builder.article('Be nice', text='Have a nice day') + r2 = builder.article('Be bad', text="I don't like you") + await inline.answer([r1, r2]) + + You can send up to 50 results as documented in + https://core.telegram.org/bots/api#answerinlinequery. + Sending more will raise ``ResultsTooMuchError``, + and you should consider using `next_offset` to + paginate them. + + cache_time (`int`, optional): + For how long this result should be cached on + the user's client. Defaults to 0 for no cache. + + gallery (`bool`, optional): + Whether the results should show as a gallery (grid) or not. + + next_offset (`str`, optional): + The offset the client will send when the user scrolls the + results and it repeats the request. + + private (`bool`, optional): + Whether the results should be cached by Telegram + (not private) or by the user's client (private). + + switch_pm (`str`, optional): + If set, this text will be shown in the results + to allow the user to switch to private messages. + + switch_pm_param (`str`, optional): + Optional parameter to start the bot with if + `switch_pm` was used. + + Example: + + .. code-block:: python + + @bot.on(events.InlineQuery) + async def handler(event): + builder = event.builder + + rev_text = event.text[::-1] + await event.answer([ + builder.article('Reverse text', text=rev_text), + builder.photo('/path/to/photo.jpg') + ]) + """ + if self._answered: + return + + if results: + futures = [self._as_future(x) for x in results] + + await asyncio.wait(futures) + + # All futures will be in the `done` *set* that `wait` returns. + # + # Precisely because it's a `set` and not a `list`, it + # will not preserve the order, but since all futures + # completed we can use our original, ordered `list`. + results = [x.result() for x in futures] + else: + results = [] + + if switch_pm: + switch_pm = types.InlineBotSwitchPM(switch_pm, switch_pm_param) + + return await self._client( + functions.messages.SetInlineBotResultsRequest( + query_id=self.query.query_id, + results=results, + cache_time=cache_time, + gallery=gallery, + next_offset=next_offset, + private=private, + switch_pm=switch_pm + ) + ) + + @staticmethod + def _as_future(obj): + if inspect.isawaitable(obj): + return asyncio.ensure_future(obj) + + f = helpers.get_running_loop().create_future() + f.set_result(obj) + return f diff --git a/module/scripts/lib/telethon/events/messagedeleted.py b/module/scripts/lib/telethon/events/messagedeleted.py new file mode 100644 index 0000000..f631fd4 --- /dev/null +++ b/module/scripts/lib/telethon/events/messagedeleted.py @@ -0,0 +1,57 @@ +from .common import EventBuilder, EventCommon, name_inner_event +from ..tl import types + + +@name_inner_event +class MessageDeleted(EventBuilder): + """ + Occurs whenever a message is deleted. Note that this event isn't 100% + reliable, since Telegram doesn't always notify the clients that a message + was deleted. + + .. important:: + + Telegram **does not** send information about *where* a message + was deleted if it occurs in private conversations with other users + or in small group chats, because message IDs are *unique* and you + can identify the chat with the message ID alone if you saved it + previously. + + Telethon **does not** save information of where messages occur, + so it cannot know in which chat a message was deleted (this will + only work in channels, where the channel ID *is* present). + + This means that the ``chats=`` parameter will not work reliably, + unless you intend on working with channels and super-groups only. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.MessageDeleted) + async def handler(event): + # Log all deleted message IDs + for msg_id in event.deleted_ids: + print('Message', msg_id, 'was deleted in', event.chat_id) + """ + @classmethod + def build(cls, update, others=None, self_id=None): + if isinstance(update, types.UpdateDeleteMessages): + return cls.Event( + deleted_ids=update.messages, + peer=None + ) + elif isinstance(update, types.UpdateDeleteChannelMessages): + return cls.Event( + deleted_ids=update.messages, + peer=types.PeerChannel(update.channel_id) + ) + + class Event(EventCommon): + def __init__(self, deleted_ids, peer): + super().__init__( + chat_peer=peer, msg_id=(deleted_ids or [0])[0] + ) + self.deleted_id = None if not deleted_ids else deleted_ids[0] + self.deleted_ids = deleted_ids diff --git a/module/scripts/lib/telethon/events/messageedited.py b/module/scripts/lib/telethon/events/messageedited.py new file mode 100644 index 0000000..c4a2b4a --- /dev/null +++ b/module/scripts/lib/telethon/events/messageedited.py @@ -0,0 +1,52 @@ +from .common import name_inner_event +from .newmessage import NewMessage +from ..tl import types + + +@name_inner_event +class MessageEdited(NewMessage): + """ + Occurs whenever a message is edited. Just like `NewMessage + `, you should treat + this event as a `Message `. + + .. warning:: + + On channels, `Message.out ` + will be `True` if you sent the message originally, **not if + you edited it**! This can be dangerous if you run outgoing + commands on edits. + + Some examples follow: + + * You send a message "A", ``out is True``. + * You edit "A" to "B", ``out is True``. + * Someone else edits "B" to "C", ``out is True`` (**be careful!**). + * Someone sends "X", ``out is False``. + * Someone edits "X" to "Y", ``out is False``. + * You edit "Y" to "Z", ``out is False``. + + Since there are useful cases where you need the right ``out`` + value, the library cannot do anything automatically to help you. + Instead, consider using ``from_users='me'`` (it won't work in + broadcast channels at all since the sender is the channel and + not you). + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.MessageEdited) + async def handler(event): + # Log the date of new edits + print('Message', event.id, 'changed at', event.date) + """ + @classmethod + def build(cls, update, others=None, self_id=None): + if isinstance(update, (types.UpdateEditMessage, + types.UpdateEditChannelMessage)): + return cls.Event(update.message) + + class Event(NewMessage.Event): + pass # Required if we want a different name for it diff --git a/module/scripts/lib/telethon/events/messageread.py b/module/scripts/lib/telethon/events/messageread.py new file mode 100644 index 0000000..29f17ab --- /dev/null +++ b/module/scripts/lib/telethon/events/messageread.py @@ -0,0 +1,143 @@ +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils +from ..tl import types + + +@name_inner_event +class MessageRead(EventBuilder): + """ + Occurs whenever one or more messages are read in a chat. + + Args: + inbox (`bool`, optional): + If this argument is `True`, then when you read someone else's + messages the event will be fired. By default (`False`) only + when messages you sent are read by someone else will fire it. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.MessageRead) + async def handler(event): + # Log when someone reads your messages + print('Someone has read all your messages until', event.max_id) + + @client.on(events.MessageRead(inbox=True)) + async def handler(event): + # Log when you read message in a chat (from your "inbox") + print('You have read messages until', event.max_id) + """ + def __init__( + self, chats=None, *, blacklist_chats=False, func=None, inbox=False): + super().__init__(chats, blacklist_chats=blacklist_chats, func=func) + self.inbox = inbox + + @classmethod + def build(cls, update, others=None, self_id=None): + if isinstance(update, types.UpdateReadHistoryInbox): + return cls.Event(update.peer, update.max_id, False) + elif isinstance(update, types.UpdateReadHistoryOutbox): + return cls.Event(update.peer, update.max_id, True) + elif isinstance(update, types.UpdateReadChannelInbox): + return cls.Event(types.PeerChannel(update.channel_id), + update.max_id, False) + elif isinstance(update, types.UpdateReadChannelOutbox): + return cls.Event(types.PeerChannel(update.channel_id), + update.max_id, True) + elif isinstance(update, types.UpdateReadMessagesContents): + return cls.Event(message_ids=update.messages, + contents=True) + elif isinstance(update, types.UpdateChannelReadMessagesContents): + return cls.Event(types.PeerChannel(update.channel_id), + message_ids=update.messages, + contents=True) + + def filter(self, event): + if self.inbox == event.outbox: + return + + return super().filter(event) + + class Event(EventCommon): + """ + Represents the event of one or more messages being read. + + Members: + max_id (`int`): + Up to which message ID has been read. Every message + with an ID equal or lower to it have been read. + + outbox (`bool`): + `True` if someone else has read your messages. + + contents (`bool`): + `True` if what was read were the contents of a message. + This will be the case when e.g. you play a voice note. + It may only be set on ``inbox`` events. + """ + def __init__(self, peer=None, max_id=None, out=False, contents=False, + message_ids=None): + self.outbox = out + self.contents = contents + self._message_ids = message_ids or [] + self._messages = None + self.max_id = max_id or max(message_ids or [], default=None) + super().__init__(peer, self.max_id) + + @property + def inbox(self): + """ + `True` if you have read someone else's messages. + """ + return not self.outbox + + @property + def message_ids(self): + """ + The IDs of the messages **which contents'** were read. + + Use :meth:`is_read` if you need to check whether a message + was read instead checking if it's in here. + """ + return self._message_ids + + async def get_messages(self): + """ + Returns the list of `Message ` + **which contents'** were read. + + Use :meth:`is_read` if you need to check whether a message + was read instead checking if it's in here. + """ + if self._messages is None: + chat = await self.get_input_chat() + if not chat: + self._messages = [] + else: + self._messages = await self._client.get_messages( + chat, ids=self._message_ids) + + return self._messages + + def is_read(self, message): + """ + Returns `True` if the given message (or its ID) has been read. + + If a list-like argument is provided, this method will return a + list of booleans indicating which messages have been read. + """ + if utils.is_list_like(message): + return [(m if isinstance(m, int) else m.id) <= self.max_id + for m in message] + else: + return (message if isinstance(message, int) + else message.id) <= self.max_id + + def __contains__(self, message): + """`True` if the message(s) are read message.""" + if utils.is_list_like(message): + return all(self.is_read(message)) + else: + return self.is_read(message) diff --git a/module/scripts/lib/telethon/events/newmessage.py b/module/scripts/lib/telethon/events/newmessage.py new file mode 100644 index 0000000..d2077a7 --- /dev/null +++ b/module/scripts/lib/telethon/events/newmessage.py @@ -0,0 +1,223 @@ +import re + +from .common import EventBuilder, EventCommon, name_inner_event, _into_id_set +from .. import utils +from ..tl import types + + +@name_inner_event +class NewMessage(EventBuilder): + """ + Occurs whenever a new text message or a message with media arrives. + + Args: + incoming (`bool`, optional): + If set to `True`, only **incoming** messages will be handled. + Mutually exclusive with ``outgoing`` (can only set one of either). + + outgoing (`bool`, optional): + If set to `True`, only **outgoing** messages will be handled. + Mutually exclusive with ``incoming`` (can only set one of either). + + from_users (`entity`, optional): + Unlike `chats`, this parameter filters the *senders* of the + message. That is, only messages *sent by these users* will be + handled. Use `chats` if you want private messages with this/these + users. `from_users` lets you filter by messages sent by *one or + more* users across the desired chats (doesn't need a list). + + forwards (`bool`, optional): + Whether forwarded messages should be handled or not. By default, + both forwarded and normal messages are included. If it's `True` + *only* forwards will be handled. If it's `False` only messages + that are *not* forwards will be handled. + + pattern (`str`, `callable`, `Pattern`, optional): + If set, only messages matching this pattern will be handled. + You can specify a regex-like string which will be matched + against the message, a callable function that returns `True` + if a message is acceptable, or a compiled regex pattern. + + Example + .. code-block:: python + + import asyncio + from telethon import events + + @client.on(events.NewMessage(pattern='(?i)hello.+')) + async def handler(event): + # Respond whenever someone says "Hello" and something else + await event.reply('Hey!') + + @client.on(events.NewMessage(outgoing=True, pattern='!ping')) + async def handler(event): + # Say "!pong" whenever you send "!ping", then delete both messages + m = await event.respond('!pong') + await asyncio.sleep(5) + await client.delete_messages(event.chat_id, [event.id, m.id]) + """ + def __init__(self, chats=None, *, blacklist_chats=False, func=None, + incoming=None, outgoing=None, + from_users=None, forwards=None, pattern=None): + if incoming and outgoing: + incoming = outgoing = None # Same as no filter + elif incoming is not None and outgoing is None: + outgoing = not incoming + elif outgoing is not None and incoming is None: + incoming = not outgoing + elif all(x is not None and not x for x in (incoming, outgoing)): + raise ValueError("Don't create an event handler if you " + "don't want neither incoming nor outgoing!") + + super().__init__(chats, blacklist_chats=blacklist_chats, func=func) + self.incoming = incoming + self.outgoing = outgoing + self.from_users = from_users + self.forwards = forwards + if isinstance(pattern, str): + self.pattern = re.compile(pattern).match + elif not pattern or callable(pattern): + self.pattern = pattern + elif hasattr(pattern, 'match') and callable(pattern.match): + self.pattern = pattern.match + else: + raise TypeError('Invalid pattern type given') + + # Should we short-circuit? E.g. perform no check at all + self._no_check = all(x is None for x in ( + self.chats, self.incoming, self.outgoing, self.pattern, + self.from_users, self.forwards, self.from_users, self.func + )) + + async def _resolve(self, client): + await super()._resolve(client) + self.from_users = await _into_id_set(client, self.from_users) + + @classmethod + def build(cls, update, others=None, self_id=None): + if isinstance(update, + (types.UpdateNewMessage, types.UpdateNewChannelMessage)): + if not isinstance(update.message, types.Message): + return # We don't care about MessageService's here + event = cls.Event(update.message) + elif isinstance(update, types.UpdateShortMessage): + event = cls.Event(types.Message( + out=update.out, + mentioned=update.mentioned, + media_unread=update.media_unread, + silent=update.silent, + id=update.id, + peer_id=types.PeerUser(update.user_id), + from_id=types.PeerUser(self_id if update.out else update.user_id), + message=update.message, + date=update.date, + fwd_from=update.fwd_from, + via_bot_id=update.via_bot_id, + reply_to=update.reply_to, + entities=update.entities, + ttl_period=update.ttl_period + )) + elif isinstance(update, types.UpdateShortChatMessage): + event = cls.Event(types.Message( + out=update.out, + mentioned=update.mentioned, + media_unread=update.media_unread, + silent=update.silent, + id=update.id, + from_id=types.PeerUser(self_id if update.out else update.from_id), + peer_id=types.PeerChat(update.chat_id), + message=update.message, + date=update.date, + fwd_from=update.fwd_from, + via_bot_id=update.via_bot_id, + reply_to=update.reply_to, + entities=update.entities, + ttl_period=update.ttl_period + )) + else: + return + + return event + + def filter(self, event): + if self._no_check: + return event + + if self.incoming and event.message.out: + return + if self.outgoing and not event.message.out: + return + if self.forwards is not None: + if bool(self.forwards) != bool(event.message.fwd_from): + return + + if self.from_users is not None: + if event.message.sender_id not in self.from_users: + return + + if self.pattern: + match = self.pattern(event.message.message or '') + if not match: + return + event.pattern_match = match + + return super().filter(event) + + class Event(EventCommon): + """ + Represents the event of a new message. This event can be treated + to all effects as a `Message `, + so please **refer to its documentation** to know what you can do + with this event. + + Members: + message (`Message `): + This is the only difference with the received + `Message `, and will + return the `telethon.tl.custom.message.Message` itself, + not the text. + + See `Message ` for + the rest of available members and methods. + + pattern_match (`obj`): + The resulting object from calling the passed ``pattern`` function. + Here's an example using a string (defaults to regex match): + + >>> from telethon import TelegramClient, events + >>> client = TelegramClient(...) + >>> + >>> @client.on(events.NewMessage(pattern=r'hi (\\w+)!')) + ... async def handler(event): + ... # In this case, the result is a ``Match`` object + ... # since the `str` pattern was converted into + ... # the ``re.compile(pattern).match`` function. + ... print('Welcomed', event.pattern_match.group(1)) + ... + >>> + """ + def __init__(self, message): + self.__dict__['_init'] = False + super().__init__(chat_peer=message.peer_id, + msg_id=message.id, broadcast=bool(message.post)) + + self.pattern_match = None + self.message = message + + def _set_client(self, client): + super()._set_client(client) + m = self.message + m._finish_init(client, self._entities, None) + self.__dict__['_init'] = True # No new attributes can be set + + def __getattr__(self, item): + if item in self.__dict__: + return self.__dict__[item] + else: + return getattr(self.message, item) + + def __setattr__(self, name, value): + if not self.__dict__['_init'] or name in self.__dict__: + self.__dict__[name] = value + else: + setattr(self.message, name, value) diff --git a/module/scripts/lib/telethon/events/raw.py b/module/scripts/lib/telethon/events/raw.py new file mode 100644 index 0000000..8491077 --- /dev/null +++ b/module/scripts/lib/telethon/events/raw.py @@ -0,0 +1,53 @@ +from .common import EventBuilder +from .. import utils + + +class Raw(EventBuilder): + """ + Raw events are not actual events. Instead, they are the raw + :tl:`Update` object that Telegram sends. You normally shouldn't + need these. + + Args: + types (`list` | `tuple` | `type`, optional): + The type or types that the :tl:`Update` instance must be. + Equivalent to ``if not isinstance(update, types): return``. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.Raw) + async def handler(update): + # Print all incoming updates + print(update.stringify()) + """ + def __init__(self, types=None, *, func=None): + super().__init__(func=func) + if not types: + self.types = None + elif not utils.is_list_like(types): + if not isinstance(types, type): + raise TypeError('Invalid input type given: {}'.format(types)) + + self.types = types + else: + if not all(isinstance(x, type) for x in types): + raise TypeError('Invalid input types given: {}'.format(types)) + + self.types = tuple(types) + + async def resolve(self, client): + self.resolved = True + + @classmethod + def build(cls, update, others=None, self_id=None): + return update + + def filter(self, event): + if not self.types or isinstance(event, self.types): + if self.func: + # Return the result of func directly as it may need to be awaited + return self.func(event) + return event diff --git a/module/scripts/lib/telethon/events/userupdate.py b/module/scripts/lib/telethon/events/userupdate.py new file mode 100644 index 0000000..450172e --- /dev/null +++ b/module/scripts/lib/telethon/events/userupdate.py @@ -0,0 +1,310 @@ +import datetime +import functools + +from .common import EventBuilder, EventCommon, name_inner_event +from .. import utils +from ..tl import types +from ..tl.custom.sendergetter import SenderGetter + + +# TODO Either the properties are poorly named or they should be +# different events, but that would be a breaking change. +# +# TODO There are more "user updates", but bundling them all up +# in a single place will make it annoying to use (since +# the user needs to check for the existence of `None`). +# +# TODO Handle UpdateUserBlocked, UpdateUserName, UpdateUserPhone, UpdateUser + +def _requires_action(function): + @functools.wraps(function) + def wrapped(self): + return None if self.action is None else function(self) + + return wrapped + + +def _requires_status(function): + @functools.wraps(function) + def wrapped(self): + return None if self.status is None else function(self) + + return wrapped + + +@name_inner_event +class UserUpdate(EventBuilder): + """ + Occurs whenever a user goes online, starts typing, etc. + + Example + .. code-block:: python + + from telethon import events + + @client.on(events.UserUpdate) + async def handler(event): + # If someone is uploading, say something + if event.uploading: + await client.send_message(event.user_id, 'What are you sending?') + """ + @classmethod + def build(cls, update, others=None, self_id=None): + if isinstance(update, types.UpdateUserStatus): + return cls.Event(types.PeerUser(update.user_id), + status=update.status) + elif isinstance(update, types.UpdateChannelUserTyping): + return cls.Event(update.from_id, + chat_peer=types.PeerChannel(update.channel_id), + typing=update.action) + elif isinstance(update, types.UpdateChatUserTyping): + return cls.Event(update.from_id, + chat_peer=types.PeerChat(update.chat_id), + typing=update.action) + elif isinstance(update, types.UpdateUserTyping): + return cls.Event(update.user_id, + typing=update.action) + + class Event(EventCommon, SenderGetter): + """ + Represents the event of a user update + such as gone online, started typing, etc. + + Members: + status (:tl:`UserStatus`, optional): + The user status if the update is about going online or offline. + + You should check this attribute first before checking any + of the seen within properties, since they will all be `None` + if the status is not set. + + action (:tl:`SendMessageAction`, optional): + The "typing" action if any the user is performing if any. + + You should check this attribute first before checking any + of the typing properties, since they will all be `None` + if the action is not set. + """ + def __init__(self, peer, *, status=None, chat_peer=None, typing=None): + super().__init__(chat_peer or peer) + SenderGetter.__init__(self, utils.get_peer_id(peer)) + + self.status = status + self.action = typing + + def _set_client(self, client): + super()._set_client(client) + self._sender, self._input_sender = utils._get_entity_pair( + self.sender_id, self._entities, client._mb_entity_cache) + + @property + def user(self): + """Alias for `sender `.""" + return self.sender + + async def get_user(self): + """Alias for `get_sender `.""" + return await self.get_sender() + + @property + def input_user(self): + """Alias for `input_sender `.""" + return self.input_sender + + async def get_input_user(self): + """Alias for `get_input_sender `.""" + return await self.get_input_sender() + + @property + def user_id(self): + """Alias for `sender_id `.""" + return self.sender_id + + @property + @_requires_action + def typing(self): + """ + `True` if the action is typing a message. + """ + return isinstance(self.action, types.SendMessageTypingAction) + + @property + @_requires_action + def uploading(self): + """ + `True` if the action is uploading something. + """ + return isinstance(self.action, ( + types.SendMessageChooseContactAction, + types.SendMessageChooseStickerAction, + types.SendMessageUploadAudioAction, + types.SendMessageUploadDocumentAction, + types.SendMessageUploadPhotoAction, + types.SendMessageUploadRoundAction, + types.SendMessageUploadVideoAction + )) + + @property + @_requires_action + def recording(self): + """ + `True` if the action is recording something. + """ + return isinstance(self.action, ( + types.SendMessageRecordAudioAction, + types.SendMessageRecordRoundAction, + types.SendMessageRecordVideoAction + )) + + @property + @_requires_action + def playing(self): + """ + `True` if the action is playing a game. + """ + return isinstance(self.action, types.SendMessageGamePlayAction) + + @property + @_requires_action + def cancel(self): + """ + `True` if the action was cancelling other actions. + """ + return isinstance(self.action, types.SendMessageCancelAction) + + @property + @_requires_action + def geo(self): + """ + `True` if what's being uploaded is a geo. + """ + return isinstance(self.action, types.SendMessageGeoLocationAction) + + @property + @_requires_action + def audio(self): + """ + `True` if what's being recorded/uploaded is an audio. + """ + return isinstance(self.action, ( + types.SendMessageRecordAudioAction, + types.SendMessageUploadAudioAction + )) + + @property + @_requires_action + def round(self): + """ + `True` if what's being recorded/uploaded is a round video. + """ + return isinstance(self.action, ( + types.SendMessageRecordRoundAction, + types.SendMessageUploadRoundAction + )) + + @property + @_requires_action + def video(self): + """ + `True` if what's being recorded/uploaded is an video. + """ + return isinstance(self.action, ( + types.SendMessageRecordVideoAction, + types.SendMessageUploadVideoAction + )) + + @property + @_requires_action + def contact(self): + """ + `True` if what's being uploaded (selected) is a contact. + """ + return isinstance(self.action, types.SendMessageChooseContactAction) + + @property + @_requires_action + def document(self): + """ + `True` if what's being uploaded is document. + """ + return isinstance(self.action, types.SendMessageUploadDocumentAction) + + @property + @_requires_action + def sticker(self): + """ + `True` if what's being uploaded is a sticker. + """ + return isinstance(self.action, types.SendMessageChooseStickerAction) + + @property + @_requires_action + def photo(self): + """ + `True` if what's being uploaded is a photo. + """ + return isinstance(self.action, types.SendMessageUploadPhotoAction) + + @property + @_requires_status + def last_seen(self): + """ + Exact `datetime.datetime` when the user was last seen if known. + """ + if isinstance(self.status, types.UserStatusOffline): + return self.status.was_online + + @property + @_requires_status + def until(self): + """ + The `datetime.datetime` until when the user should appear online. + """ + if isinstance(self.status, types.UserStatusOnline): + return self.status.expires + + def _last_seen_delta(self): + if isinstance(self.status, types.UserStatusOffline): + return datetime.datetime.now(tz=datetime.timezone.utc) - self.status.was_online + elif isinstance(self.status, types.UserStatusOnline): + return datetime.timedelta(days=0) + elif isinstance(self.status, types.UserStatusRecently): + return datetime.timedelta(days=1) + elif isinstance(self.status, types.UserStatusLastWeek): + return datetime.timedelta(days=7) + elif isinstance(self.status, types.UserStatusLastMonth): + return datetime.timedelta(days=30) + else: + return datetime.timedelta(days=365) + + @property + @_requires_status + def online(self): + """ + `True` if the user is currently online, + """ + return self._last_seen_delta() <= datetime.timedelta(days=0) + + @property + @_requires_status + def recently(self): + """ + `True` if the user was seen within a day. + """ + return self._last_seen_delta() <= datetime.timedelta(days=1) + + @property + @_requires_status + def within_weeks(self): + """ + `True` if the user was seen within 7 days. + """ + return self._last_seen_delta() <= datetime.timedelta(days=7) + + @property + @_requires_status + def within_months(self): + """ + `True` if the user was seen within 30 days. + """ + return self._last_seen_delta() <= datetime.timedelta(days=30) diff --git a/module/scripts/lib/telethon/extensions/__init__.py b/module/scripts/lib/telethon/extensions/__init__.py new file mode 100644 index 0000000..a3c7729 --- /dev/null +++ b/module/scripts/lib/telethon/extensions/__init__.py @@ -0,0 +1,6 @@ +""" +Several extensions Python is missing, such as a proper class to handle a TCP +communication with support for cancelling the operation, and a utility class +to read arbitrary binary data in a more comfortable way, with int/strings/etc. +""" +from .binaryreader import BinaryReader diff --git a/module/scripts/lib/telethon/extensions/binaryreader.py b/module/scripts/lib/telethon/extensions/binaryreader.py new file mode 100644 index 0000000..84f3b5b --- /dev/null +++ b/module/scripts/lib/telethon/extensions/binaryreader.py @@ -0,0 +1,201 @@ +""" +This module contains the BinaryReader utility class. +""" +import struct +import time +from datetime import datetime, timedelta, timezone + +from ..errors import TypeNotFoundError +from ..tl.alltlobjects import tlobjects +from ..tl.core import core_objects + +_EPOCH_NAIVE = datetime(*time.gmtime(0)[:6]) +_EPOCH = _EPOCH_NAIVE.replace(tzinfo=timezone.utc) + + +class BinaryReader: + """ + Small utility class to read binary data. + """ + + def __init__(self, data): + self.stream = data or b'' + self.position = 0 + self._last = None # Should come in handy to spot -404 errors + + # region Reading + + # "All numbers are written as little endian." + # https://core.telegram.org/mtproto + def read_byte(self): + """Reads a single byte value.""" + value, = struct.unpack_from("= 0: + result = self.stream[self.position:self.position + length] + self.position += length + else: + result = self.stream[self.position:] + self.position += len(result) + if (length >= 0) and (len(result) != length): + raise BufferError( + 'No more data left to read (need {}, got {}: {}); last read {}' + .format(length, len(result), repr(result), repr(self._last)) + ) + + self._last = result + return result + + def get_bytes(self): + """Gets the byte array representing the current buffer as a whole.""" + return self.stream + + # endregion + + # region Telegram custom reading + + def tgread_bytes(self): + """ + Reads a Telegram-encoded byte array, without the need of + specifying its length. + """ + first_byte = self.read_byte() + if first_byte == 254: + length = self.read_byte() | (self.read_byte() << 8) | ( + self.read_byte() << 16) + padding = length % 4 + else: + length = first_byte + padding = (length + 1) % 4 + + data = self.read(length) + if padding > 0: + padding = 4 - padding + self.read(padding) + + return data + + def tgread_string(self): + """Reads a Telegram-encoded string.""" + return str(self.tgread_bytes(), encoding='utf-8', errors='replace') + + def tgread_bool(self): + """Reads a Telegram boolean value.""" + value = self.read_int(signed=False) + if value == 0x997275b5: # boolTrue + return True + elif value == 0xbc799737: # boolFalse + return False + else: + raise RuntimeError('Invalid boolean code {}'.format(hex(value))) + + def tgread_date(self): + """Reads and converts Unix time (used by Telegram) + into a Python datetime object. + """ + value = self.read_int() + return _EPOCH + timedelta(seconds=value) + + def tgread_object(self): + """Reads a Telegram object.""" + constructor_id = self.read_int(signed=False) + clazz = tlobjects.get(constructor_id, None) + if clazz is None: + # The class was None, but there's still a + # chance of it being a manually parsed value like bool! + value = constructor_id + if value == 0x997275b5: # boolTrue + return True + elif value == 0xbc799737: # boolFalse + return False + elif value == 0x1cb5c415: # Vector + return [self.tgread_object() for _ in range(self.read_int())] + + clazz = core_objects.get(constructor_id, None) + if clazz is None: + # If there was still no luck, give up + self.seek(-4) # Go back + pos = self.tell_position() + error = TypeNotFoundError(constructor_id, self.read()) + self.set_position(pos) + raise error + + return clazz.from_reader(self) + + def tgread_vector(self): + """Reads a vector (a list) of Telegram objects.""" + if 0x1cb5c415 != self.read_int(signed=False): + raise RuntimeError('Invalid constructor code, vector was expected') + + count = self.read_int() + return [self.tgread_object() for _ in range(count)] + + # endregion + + def close(self): + """Closes the reader, freeing the BytesIO stream.""" + self.stream = b'' + + # region Position related + + def tell_position(self): + """Tells the current position on the stream.""" + return self.position + + def set_position(self, position): + """Sets the current position on the stream.""" + self.position = position + + def seek(self, offset): + """ + Seeks the stream position given an offset from the current position. + The offset may be negative. + """ + self.position += offset + + # endregion + + # region with block + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + + # endregion diff --git a/module/scripts/lib/telethon/extensions/html.py b/module/scripts/lib/telethon/extensions/html.py new file mode 100644 index 0000000..8a04035 --- /dev/null +++ b/module/scripts/lib/telethon/extensions/html.py @@ -0,0 +1,208 @@ +""" +Simple HTML -> Telegram entity parser. +""" +from collections import deque +from html import escape +from html.parser import HTMLParser +from typing import Iterable, Tuple, List + +from ..helpers import add_surrogate, del_surrogate, within_surrogate, strip_text +from ..tl import TLObject +from ..tl.types import ( + MessageEntityBold, MessageEntityItalic, MessageEntityCode, + MessageEntityPre, MessageEntityEmail, MessageEntityUrl, + MessageEntityTextUrl, MessageEntityMentionName, + MessageEntityUnderline, MessageEntityStrike, MessageEntityBlockquote, + MessageEntityCustomEmoji, TypeMessageEntity +) + + +class HTMLToTelegramParser(HTMLParser): + def __init__(self): + super().__init__() + self.text = '' + self.entities = [] + self._building_entities = {} + self._open_tags = deque() + self._open_tags_meta = deque() + + def handle_starttag(self, tag, attrs): + self._open_tags.appendleft(tag) + self._open_tags_meta.appendleft(None) + + attrs = dict(attrs) + EntityType = None + args = {} + if tag == 'strong' or tag == 'b': + EntityType = MessageEntityBold + elif tag == 'em' or tag == 'i': + EntityType = MessageEntityItalic + elif tag == 'u': + EntityType = MessageEntityUnderline + elif tag == 'del' or tag == 's': + EntityType = MessageEntityStrike + elif tag == 'blockquote': + EntityType = MessageEntityBlockquote + if 'expandable' in attrs: + args['collapsed'] = True + elif tag == 'code': + try: + # If we're in the middle of a
 tag, this  tag is
+                # probably intended for syntax highlighting.
+                #
+                # Syntax highlighting is set with
+                #     codeblock
+                # inside 
 tags
+                pre = self._building_entities['pre']
+                try:
+                    pre.language = attrs['class'][len('language-'):]
+                except KeyError:
+                    pass
+            except KeyError:
+                EntityType = MessageEntityCode
+        elif tag == 'pre':
+            EntityType = MessageEntityPre
+            args['language'] = ''
+        elif tag == 'a':
+            try:
+                url = attrs['href']
+            except KeyError:
+                return
+            if url.startswith('mailto:'):
+                url = url[len('mailto:'):]
+                EntityType = MessageEntityEmail
+            else:
+                if self.get_starttag_text() == url:
+                    EntityType = MessageEntityUrl
+                else:
+                    EntityType = MessageEntityTextUrl
+                    args['url'] = del_surrogate(url)
+                    url = None
+            self._open_tags_meta.popleft()
+            self._open_tags_meta.appendleft(url)
+        elif tag == 'tg-emoji':
+            try:
+                emoji_id = int(attrs['emoji-id'])
+            except (KeyError, ValueError):
+                return
+
+            EntityType = MessageEntityCustomEmoji
+            args['document_id'] = emoji_id
+            
+        if EntityType and tag not in self._building_entities:
+            self._building_entities[tag] = EntityType(
+                offset=len(self.text),
+                # The length will be determined when closing the tag.
+                length=0,
+                **args)
+
+    def handle_data(self, text):
+        previous_tag = self._open_tags[0] if len(self._open_tags) > 0 else ''
+        if previous_tag == 'a':
+            url = self._open_tags_meta[0]
+            if url:
+                text = url
+
+        for tag, entity in self._building_entities.items():
+            entity.length += len(text)
+
+        self.text += text
+
+    def handle_endtag(self, tag):
+        try:
+            self._open_tags.popleft()
+            self._open_tags_meta.popleft()
+        except IndexError:
+            pass
+        entity = self._building_entities.pop(tag, None)
+        if entity:
+            self.entities.append(entity)
+
+
+def parse(html: str) -> Tuple[str, List[TypeMessageEntity]]:
+    """
+    Parses the given HTML message and returns its stripped representation
+    plus a list of the MessageEntity's that were found.
+
+    :param html: the message with HTML to be parsed.
+    :return: a tuple consisting of (clean message, [message entities]).
+    """
+    if not html:
+        return html, []
+
+    parser = HTMLToTelegramParser()
+    parser.feed(add_surrogate(html))
+    text = strip_text(parser.text, parser.entities)
+    parser.entities.reverse()
+    parser.entities.sort(key=lambda entity: entity.offset)
+    return del_surrogate(text), parser.entities
+
+
+ENTITY_TO_FORMATTER = {
+    MessageEntityBold: ('', ''),
+    MessageEntityItalic: ('', ''),
+    MessageEntityCode: ('', ''),
+    MessageEntityUnderline: ('', ''),
+    MessageEntityStrike: ('', ''),
+    MessageEntityBlockquote: lambda e, _: (
+        '
' if e.collapsed else '
', + '
' + ), + MessageEntityPre: lambda e, _: ( + "
\n"
+        "    \n"
+        "        ".format(e.language), "{}\n"
+        "    \n"
+        "
" + ), + MessageEntityEmail: lambda _, t: (''.format(t), ''), + MessageEntityUrl: lambda _, t: (''.format(t), ''), + MessageEntityTextUrl: lambda e, _: (''.format(escape(e.url)), ''), + MessageEntityMentionName: lambda e, _: (''.format(e.user_id), ''), + MessageEntityCustomEmoji: lambda e, _: (''.format(e.document_id), ''), +} + + +def unparse(text: str, entities: Iterable[TypeMessageEntity]) -> str: + """ + Performs the reverse operation to .parse(), effectively returning HTML + given a normal text and its MessageEntity's. + + :param text: the text to be reconverted into HTML. + :param entities: the MessageEntity's applied to the text. + :return: a HTML representation of the combination of both inputs. + """ + if not text: + return text + elif not entities: + return escape(text) + + if isinstance(entities, TLObject): + entities = (entities,) + + text = add_surrogate(text) + insert_at = [] + for i, entity in enumerate(entities): + s = entity.offset + e = entity.offset + entity.length + delimiter = ENTITY_TO_FORMATTER.get(type(entity), None) + if delimiter: + if callable(delimiter): + delimiter = delimiter(entity, text[s:e]) + insert_at.append((s, i, delimiter[0])) + insert_at.append((e, -i, delimiter[1])) + + insert_at.sort(key=lambda t: (t[0], t[1])) + next_escape_bound = len(text) + while insert_at: + # Same logic as markdown.py + at, _, what = insert_at.pop() + while within_surrogate(text, at): + at += 1 + + text = text[:at] + what + escape(text[at:next_escape_bound]) + text[next_escape_bound:] + next_escape_bound = at + + text = escape(text[:next_escape_bound]) + text[next_escape_bound:] + + return del_surrogate(text) diff --git a/module/scripts/lib/telethon/extensions/markdown.py b/module/scripts/lib/telethon/extensions/markdown.py new file mode 100644 index 0000000..4dbbe39 --- /dev/null +++ b/module/scripts/lib/telethon/extensions/markdown.py @@ -0,0 +1,193 @@ +""" +Simple markdown parser which does not support nesting. Intended primarily +for use within the library, which attempts to handle emojies correctly, +since they seem to count as two characters and it's a bit strange. +""" +import re +import warnings + +from ..helpers import add_surrogate, del_surrogate, within_surrogate, strip_text +from ..tl import TLObject +from ..tl.types import ( + MessageEntityBold, MessageEntityItalic, MessageEntityCode, + MessageEntityPre, MessageEntityTextUrl, MessageEntityMentionName, + MessageEntityStrike +) + +DEFAULT_DELIMITERS = { + '**': MessageEntityBold, + '__': MessageEntityItalic, + '~~': MessageEntityStrike, + '`': MessageEntityCode, + '```': MessageEntityPre +} + +DEFAULT_URL_RE = re.compile(r'\[([^]]*?)\]\(([\s\S]*?)\)') +DEFAULT_URL_FORMAT = '[{0}]({1})' + + +def parse(message, delimiters=None, url_re=None): + """ + Parses the given markdown message and returns its stripped representation + plus a list of the MessageEntity's that were found. + + :param message: the message with markdown-like syntax to be parsed. + :param delimiters: the delimiters to be used, {delimiter: type}. + :param url_re: the URL bytes regex to be used. Must have two groups. + :return: a tuple consisting of (clean message, [message entities]). + """ + if not message: + return message, [] + + if url_re is None: + url_re = DEFAULT_URL_RE + elif isinstance(url_re, str): + url_re = re.compile(url_re) + + if not delimiters: + if delimiters is not None: + return message, [] + delimiters = DEFAULT_DELIMITERS + + # Build a regex to efficiently test all delimiters at once. + # Note that the largest delimiter should go first, we don't + # want ``` to be interpreted as a single back-tick in a code block. + delim_re = re.compile('|'.join('({})'.format(re.escape(k)) + for k in sorted(delimiters, key=len, reverse=True))) + + # Cannot use a for loop because we need to skip some indices + i = 0 + result = [] + + # Work on byte level with the utf-16le encoding to get the offsets right. + # The offset will just be half the index we're at. + message = add_surrogate(message) + while i < len(message): + m = delim_re.match(message, pos=i) + + # Did we find some delimiter here at `i`? + if m: + delim = next(filter(None, m.groups())) + + # +1 to avoid matching right after (e.g. "****") + end = message.find(delim, i + len(delim) + 1) + + # Did we find the earliest closing tag? + if end != -1: + + # Remove the delimiter from the string + message = ''.join(( + message[:i], + message[i + len(delim):end], + message[end + len(delim):] + )) + + # Check other affected entities + for ent in result: + # If the end is after our start, it is affected + if ent.offset + ent.length > i: + # If the old start is before ours and the old end is after ours, we are fully enclosed + if ent.offset <= i and ent.offset + ent.length >= end + len(delim): + ent.length -= len(delim) * 2 + else: + ent.length -= len(delim) + + # Append the found entity + ent = delimiters[delim] + if ent == MessageEntityPre: + result.append(ent(i, end - i - len(delim), '')) # has 'lang' + else: + result.append(ent(i, end - i - len(delim))) + + # No nested entities inside code blocks + if ent in (MessageEntityCode, MessageEntityPre): + i = end - len(delim) + + continue + + elif url_re: + m = url_re.match(message, pos=i) + if m: + # Replace the whole match with only the inline URL text. + message = ''.join(( + message[:m.start()], + m.group(1), + message[m.end():] + )) + + delim_size = m.end() - m.start() - len(m.group(1)) + for ent in result: + # If the end is after our start, it is affected + if ent.offset + ent.length > m.start(): + ent.length -= delim_size + + result.append(MessageEntityTextUrl( + offset=m.start(), length=len(m.group(1)), + url=del_surrogate(m.group(2)) + )) + i += len(m.group(1)) + continue + + i += 1 + + message = strip_text(message, result) + return del_surrogate(message), result + + +def unparse(text, entities, delimiters=None, url_fmt=None): + """ + Performs the reverse operation to .parse(), effectively returning + markdown-like syntax given a normal text and its MessageEntity's. + + :param text: the text to be reconverted into markdown. + :param entities: the MessageEntity's applied to the text. + :return: a markdown-like text representing the combination of both inputs. + """ + if not text or not entities: + return text + + if not delimiters: + if delimiters is not None: + return text + delimiters = DEFAULT_DELIMITERS + + if url_fmt is not None: + warnings.warn('url_fmt is deprecated') # since it complicates everything *a lot* + + if isinstance(entities, TLObject): + entities = (entities,) + + text = add_surrogate(text) + delimiters = {v: k for k, v in delimiters.items()} + insert_at = [] + for i, entity in enumerate(entities): + s = entity.offset + e = entity.offset + entity.length + delimiter = delimiters.get(type(entity), None) + if delimiter: + insert_at.append((s, i, delimiter)) + insert_at.append((e, -i, delimiter)) + else: + url = None + if isinstance(entity, MessageEntityTextUrl): + url = entity.url + elif isinstance(entity, MessageEntityMentionName): + url = 'tg://user?id={}'.format(entity.user_id) + if url: + insert_at.append((s, i, '[')) + insert_at.append((e, -i, ']({})'.format(url))) + + insert_at.sort(key=lambda t: (t[0], t[1])) + while insert_at: + at, _, what = insert_at.pop() + + # If we are in the middle of a surrogate nudge the position by -1. + # Otherwise we would end up with malformed text and fail to encode. + # For example of bad input: "Hi \ud83d\ude1c" + # https://en.wikipedia.org/wiki/UTF-16#U+010000_to_U+10FFFF + while within_surrogate(text, at): + at += 1 + + text = text[:at] + what + text[at:] + + return del_surrogate(text) diff --git a/module/scripts/lib/telethon/extensions/messagepacker.py b/module/scripts/lib/telethon/extensions/messagepacker.py new file mode 100644 index 0000000..c0f46f4 --- /dev/null +++ b/module/scripts/lib/telethon/extensions/messagepacker.py @@ -0,0 +1,111 @@ +import asyncio +import collections +import io +import struct + +from ..tl import TLRequest +from ..tl.core.messagecontainer import MessageContainer +from ..tl.core.tlmessage import TLMessage + + +class MessagePacker: + """ + This class packs `RequestState` as outgoing `TLMessages`. + + The purpose of this class is to support putting N `RequestState` into a + queue, and then awaiting for "packed" `TLMessage` in the other end. The + simplest case would be ``State -> TLMessage`` (1-to-1 relationship) but + for efficiency purposes it's ``States -> Container`` (N-to-1). + + This addresses several needs: outgoing messages will be smaller, so the + encryption and network overhead also is smaller. It's also a central + point where outgoing requests are put, and where ready-messages are get. + """ + + def __init__(self, state, loggers): + self._state = state + self._deque = collections.deque() + self._ready = asyncio.Event() + self._log = loggers[__name__] + + def append(self, state): + self._deque.append(state) + self._ready.set() + + def extend(self, states): + self._deque.extend(states) + self._ready.set() + + async def get(self): + """ + Returns (batch, data) if one or more items could be retrieved. + + If the cancellation occurs or only invalid items were in the + queue, (None, None) will be returned instead. + """ + if not self._deque: + self._ready.clear() + await self._ready.wait() + + buffer = io.BytesIO() + batch = [] + size = 0 + + # Fill a new batch to return while the size is small enough, + # as long as we don't exceed the maximum length of messages. + while self._deque and len(batch) <= MessageContainer.MAXIMUM_LENGTH: + state = self._deque.popleft() + size += len(state.data) + TLMessage.SIZE_OVERHEAD + + if size <= MessageContainer.MAXIMUM_SIZE: + state.msg_id = self._state.write_data_as_message( + buffer, state.data, isinstance(state.request, TLRequest), + after_id=state.after.msg_id if state.after else None + ) + batch.append(state) + self._log.debug('Assigned msg_id = %d to %s (%x)', + state.msg_id, state.request.__class__.__name__, + id(state.request)) + continue + + if batch: + # Put the item back since it can't be sent in this batch + self._deque.appendleft(state) + break + + # If a single message exceeds the maximum size, then the + # message payload cannot be sent. Telegram would forcibly + # close the connection; message would never be confirmed. + # + # We don't put the item back because it can never be sent. + # If we did, we would loop again and reach this same path. + # Setting the exception twice results in `InvalidStateError` + # and this method should never return with error, which we + # really want to avoid. + self._log.warning( + 'Message payload for %s is too long (%d) and cannot be sent', + state.request.__class__.__name__, len(state.data) + ) + state.future.set_exception( + ValueError('Request payload is too big')) + + size = 0 + continue + + if not batch: + return None, None + + if len(batch) > 1: + # Inlined code to pack several messages into a container + data = struct.pack( + ' Surrogate Pairs (Telegram offsets are calculated with these). + # See https://en.wikipedia.org/wiki/Plane_(Unicode)#Overview for more. + ''.join(chr(y) for y in struct.unpack(' left_offset: + if e.offset >= left_offset: + # 0 1|2 3 4 5 | 0 1|2 3 4 5 + # ^ ^ | ^ + # lo(2) o(5) | o(2)/lo(2) + e.offset -= left_offset + # |0 1 2 3 | |0 1 2 3 + # ^ | ^ + # o=o-lo(3=5-2) | o=o-lo(0=2-2) + else: + # e.offset < left_offset and e.offset + e.length > left_offset + # 0 1 2 3|4 5 6 7 8 9 10 + # ^ ^ ^ + # o(1) lo(4) o+l(1+9) + e.length = e.offset + e.length - left_offset + e.offset = 0 + # |0 1 2 3 4 5 6 + # ^ ^ + # o(0) o+l=0+o+l-lo(6=0+6=0+1+9-4) + else: + # e.offset + e.length <= left_offset + # 0 1 2 3|4 5 + # ^ ^ + # o(0) o+l(4) + # lo(4) + del entities[i] + continue + + if e.offset + e.length <= len_final: + # |0 1 2 3 4 5 6 7 8 9 + # ^ ^ + # o(1) o+l(1+9)/lf(10) + continue + if e.offset >= len_final: + # |0 1 2 3 4 + # ^ + # o(5)/lf(5) + del entities[i] + else: + # e.offset < len_final and e.offset + e.length > len_final + # |0 1 2 3 4 5 (6) (7) (8) (9) + # ^ ^ ^ + # o(1) lf(6) o+l(1+8) + e.length = len_final - e.offset + # |0 1 2 3 4 5 + # ^ ^ + # o(1) o+l=o+lf-o=lf(6=1+5=1+6-1) + + return text + + +def retry_range(retries, force_retry=True): + """ + Generates an integer sequence starting from 1. If `retries` is + not a zero or a positive integer value, the sequence will be + infinite, otherwise it will end at `retries + 1`. + """ + + # We need at least one iteration even if the retries are 0 + # when force_retry is True. + if force_retry and not (retries is None or retries < 0): + retries += 1 + + attempt = 0 + while attempt != retries: + attempt += 1 + yield attempt + + + +async def _maybe_await(value): + if inspect.isawaitable(value): + return await value + else: + return value + + +async def _cancel(log, **tasks): + """ + Helper to cancel one or more tasks gracefully, logging exceptions. + """ + for name, task in tasks.items(): + if not task: + continue + + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + except RuntimeError: + # Probably: RuntimeError: await wasn't used with future + # + # See: https://github.com/python/cpython/blob/12d3061c7819a73d891dcce44327410eaf0e1bc2/Lib/asyncio/futures.py#L265 + # + # Happens with _asyncio.Task instances (in "Task cancelling" state) + # trying to SIGINT the program right during initial connection, on + # _recv_loop coroutine (but we're creating its task explicitly with + # a loop, so how can it bug out like this?). + # + # Since we're aware of this error there's no point in logging it. + # *May* be https://bugs.python.org/issue37172 + pass + except AssertionError as e: + # In Python 3.6, the above RuntimeError is an AssertionError + # See https://github.com/python/cpython/blob/7df32f844efed33ca781a016017eab7050263b90/Lib/asyncio/futures.py#L328 + if e.args != ("yield from wasn't used with future",): + log.exception('Unhandled exception from %s after cancelling ' + '%s (%s)', name, type(task), task) + except Exception: + log.exception('Unhandled exception from %s after cancelling ' + '%s (%s)', name, type(task), task) + + +def _sync_enter(self): + """ + Helps to cut boilerplate on async context + managers that offer synchronous variants. + """ + if hasattr(self, 'loop'): + loop = self.loop + else: + loop = self._client.loop + + if loop.is_running(): + raise RuntimeError( + 'You must use "async with" if the event loop ' + 'is running (i.e. you are inside an "async def")' + ) + + return loop.run_until_complete(self.__aenter__()) + + +def _sync_exit(self, *args): + if hasattr(self, 'loop'): + loop = self.loop + else: + loop = self._client.loop + + return loop.run_until_complete(self.__aexit__(*args)) + + +def _entity_type(entity): + # This could be a `utils` method that just ran a few `isinstance` on + # `utils.get_peer(...)`'s result. However, there are *a lot* of auto + # casts going on, plenty of calls and temporary short-lived objects. + # + # So we just check if a string is in the class name. + # Still, assert that it's the right type to not return false results. + try: + if entity.SUBCLASS_OF_ID not in ( + 0x2d45687, # crc32(b'Peer') + 0xc91c90b6, # crc32(b'InputPeer') + 0xe669bf46, # crc32(b'InputUser') + 0x40f202fd, # crc32(b'InputChannel') + 0x2da17977, # crc32(b'User') + 0xc5af5d94, # crc32(b'Chat') + 0x1f4661b9, # crc32(b'UserFull') + 0xd49a2697, # crc32(b'ChatFull') + ): + raise TypeError('{} does not have any entity type'.format(entity)) + except AttributeError: + raise TypeError('{} is not a TLObject, cannot determine entity type'.format(entity)) + + name = entity.__class__.__name__ + if 'User' in name: + return _EntityType.USER + elif 'Chat' in name: + return _EntityType.CHAT + elif 'Channel' in name: + return _EntityType.CHANNEL + elif 'Self' in name: + return _EntityType.USER + + # 'Empty' in name or not found, we don't care, not a valid entity. + raise TypeError('{} does not have any entity type'.format(entity)) + +# endregion + +# region Cryptographic related utils + + +def generate_key_data_from_nonce(server_nonce, new_nonce): + """Generates the key data corresponding to the given nonce""" + server_nonce = server_nonce.to_bytes(16, 'little', signed=True) + new_nonce = new_nonce.to_bytes(32, 'little', signed=True) + hash1 = sha1(new_nonce + server_nonce).digest() + hash2 = sha1(server_nonce + new_nonce).digest() + hash3 = sha1(new_nonce + new_nonce).digest() + + key = hash1 + hash2[:12] + iv = hash2[12:20] + hash3 + new_nonce[:4] + return key, iv + + +# endregion + +# region Custom Classes + + +class TotalList(list): + """ + A list with an extra `total` property, which may not match its `len` + since the total represents the total amount of items *available* + somewhere else, not the items *in this list*. + + Examples: + + .. code-block:: python + + # Telethon returns these lists in some cases (for example, + # only when a chunk is returned, but the "total" count + # is available). + result = await client.get_messages(chat, limit=10) + + print(result.total) # large number + print(len(result)) # 10 + print(result[0]) # latest message + + for x in result: # show the 10 messages + print(x.text) + + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.total = 0 + + def __str__(self): + return '[{}, total={}]'.format( + ', '.join(str(x) for x in self), self.total) + + def __repr__(self): + return '[{}, total={}]'.format( + ', '.join(repr(x) for x in self), self.total) + + +class _FileStream(io.IOBase): + """ + Proxy around things that represent a file and need to be used as streams + which may or not need to be closed. + + This will handle `pathlib.Path`, `str` paths, in-memory `bytes`, and + anything IO-like (including `aiofiles`). + + It also provides access to the name and file size (also necessary). + """ + def __init__(self, file, *, file_size=None): + if isinstance(file, Path): + file = str(file.absolute()) + + self._file = file + self._name = None + self._size = file_size + self._stream = None + self._close_stream = None + + async def __aenter__(self): + if isinstance(self._file, str): + self._name = os.path.basename(self._file) + self._size = os.path.getsize(self._file) + self._stream = open(self._file, 'rb') + self._close_stream = True + return self + + if isinstance(self._file, bytes): + self._size = len(self._file) + self._stream = io.BytesIO(self._file) + self._close_stream = True + return self + + if not callable(getattr(self._file, 'read', None)): + raise TypeError('file description should have a `read` method') + + self._name = getattr(self._file, 'name', None) + self._stream = self._file + self._close_stream = False + + if self._size is None: + if callable(getattr(self._file, 'seekable', None)): + seekable = await _maybe_await(self._file.seekable()) + else: + seekable = False + + if seekable: + pos = await _maybe_await(self._file.tell()) + await _maybe_await(self._file.seek(0, os.SEEK_END)) + self._size = await _maybe_await(self._file.tell()) + await _maybe_await(self._file.seek(pos, os.SEEK_SET)) + else: + _log.warning( + 'Could not determine file size beforehand so the entire ' + 'file will be read in-memory') + + data = await _maybe_await(self._file.read()) + self._size = len(data) + self._stream = io.BytesIO(data) + self._close_stream = True + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self._close_stream and self._stream: + await _maybe_await(self._stream.close()) + + @property + def file_size(self): + return self._size + + @property + def name(self): + return self._name + + # Proxy all the methods. Doesn't need to be readable (makes multiline edits easier) + def read(self, *args, **kwargs): return self._stream.read(*args, **kwargs) + def readinto(self, *args, **kwargs): return self._stream.readinto(*args, **kwargs) + def write(self, *args, **kwargs): return self._stream.write(*args, **kwargs) + def fileno(self, *args, **kwargs): return self._stream.fileno(*args, **kwargs) + def flush(self, *args, **kwargs): return self._stream.flush(*args, **kwargs) + def isatty(self, *args, **kwargs): return self._stream.isatty(*args, **kwargs) + def readable(self, *args, **kwargs): return self._stream.readable(*args, **kwargs) + def readline(self, *args, **kwargs): return self._stream.readline(*args, **kwargs) + def readlines(self, *args, **kwargs): return self._stream.readlines(*args, **kwargs) + def seek(self, *args, **kwargs): return self._stream.seek(*args, **kwargs) + def seekable(self, *args, **kwargs): return self._stream.seekable(*args, **kwargs) + def tell(self, *args, **kwargs): return self._stream.tell(*args, **kwargs) + def truncate(self, *args, **kwargs): return self._stream.truncate(*args, **kwargs) + def writable(self, *args, **kwargs): return self._stream.writable(*args, **kwargs) + def writelines(self, *args, **kwargs): return self._stream.writelines(*args, **kwargs) + + # close is special because it will be called by __del__ but we do NOT + # want to close the file unless we have to (we're just a wrapper). + # Instead, we do nothing (we should be used through the decorator which + # has its own mechanism to close the file correctly). + def close(self, *args, **kwargs): + pass + +# endregion + +def get_running_loop(): + try: + return asyncio.get_running_loop() + except RuntimeError: + try: + return asyncio.get_event_loop() + except RuntimeError: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop diff --git a/module/scripts/lib/telethon/hints.py b/module/scripts/lib/telethon/hints.py new file mode 100644 index 0000000..18337ac --- /dev/null +++ b/module/scripts/lib/telethon/hints.py @@ -0,0 +1,72 @@ +import datetime +import typing + +from . import helpers +from .tl import types, custom + +Phone = str +Username = str +PeerID = int +Entity = typing.Union[types.User, types.Chat, types.Channel, types.Community] +FullEntity = typing.Union[types.UserFull, types.messages.ChatFull, types.ChatFull, types.ChannelFull, types.CommunityFull] + +EntityLike = typing.Union[ + Phone, + Username, + PeerID, + types.TypePeer, + types.TypeInputPeer, + Entity, + FullEntity +] +EntitiesLike = typing.Union[EntityLike, typing.Sequence[EntityLike]] + +ButtonLike = typing.Union[types.TypeKeyboardButton, custom.Button] +MarkupLike = typing.Union[ + types.TypeReplyMarkup, + ButtonLike, + typing.Sequence[ButtonLike], + typing.Sequence[typing.Sequence[ButtonLike]] +] + +TotalList = helpers.TotalList + +DateLike = typing.Optional[typing.Union[float, datetime.datetime, datetime.date, datetime.timedelta]] + +LocalPath = str +ExternalUrl = str +BotFileID = str +FileLike = typing.Union[ + LocalPath, + ExternalUrl, + BotFileID, + bytes, + typing.BinaryIO, + types.TypeMessageMedia, + types.TypeInputFile, + types.TypeInputFileLocation, + types.TypeInputMedia, + types.TypePhoto, + types.TypeInputPhoto, + types.TypeDocument, + types.TypeInputDocument +] + +# Can't use `typing.Type` in Python 3.5.2 +# See https://github.com/python/typing/issues/266 +try: + OutFileLike = typing.Union[ + str, + typing.Type[bytes], + typing.BinaryIO + ] +except TypeError: + OutFileLike = typing.Union[ + str, + typing.BinaryIO + ] + +MessageLike = typing.Union[str, types.Message] +MessageIDLike = typing.Union[int, types.Message, types.TypeInputMessage] + +ProgressCallback = typing.Callable[[int, int], None] diff --git a/module/scripts/lib/telethon/network/__init__.py b/module/scripts/lib/telethon/network/__init__.py new file mode 100644 index 0000000..0b985d5 --- /dev/null +++ b/module/scripts/lib/telethon/network/__init__.py @@ -0,0 +1,14 @@ +""" +This module contains several classes regarding network, low level connection +with Telegram's servers and the protocol used (TCP full, abridged, etc.). +""" +from .mtprotoplainsender import MTProtoPlainSender +from .authenticator import do_authentication +from .mtprotosender import MTProtoSender +from .connection import ( + Connection, + ConnectionTcpFull, ConnectionTcpIntermediate, ConnectionTcpAbridged, + ConnectionTcpObfuscated, ConnectionTcpMTProxyAbridged, + ConnectionTcpMTProxyIntermediate, + ConnectionTcpMTProxyRandomizedIntermediate, ConnectionHttp, TcpMTProxy +) diff --git a/module/scripts/lib/telethon/network/authenticator.py b/module/scripts/lib/telethon/network/authenticator.py new file mode 100644 index 0000000..ea47620 --- /dev/null +++ b/module/scripts/lib/telethon/network/authenticator.py @@ -0,0 +1,212 @@ +""" +This module contains several functions that authenticate the client machine +with Telegram's servers, effectively creating an authorization key. +""" +import os +import time +from hashlib import sha1 + +from ..tl.types import ( + ResPQ, PQInnerData, ServerDHParamsFail, ServerDHParamsOk, + ServerDHInnerData, ClientDHInnerData, DhGenOk, DhGenRetry, DhGenFail +) +from .. import helpers +from ..crypto import AES, AuthKey, Factorization, rsa +from ..errors import SecurityError +from ..extensions import BinaryReader +from ..tl.functions import ( + ReqPqMultiRequest, ReqDHParamsRequest, SetClientDHParamsRequest +) + + +async def do_authentication(sender): + """ + Executes the authentication process with the Telegram servers. + + :param sender: a connected `MTProtoPlainSender`. + :return: returns a (authorization key, time offset) tuple. + """ + # Step 1 sending: PQ Request, endianness doesn't matter since it's random + nonce = int.from_bytes(os.urandom(16), 'big', signed=True) + res_pq = await sender.send(ReqPqMultiRequest(nonce)) + assert isinstance(res_pq, ResPQ), 'Step 1 answer was %s' % res_pq + + if res_pq.nonce != nonce: + raise SecurityError('Step 1 invalid nonce from server') + + pq = get_int(res_pq.pq) + + # Step 2 sending: DH Exchange + p, q = Factorization.factorize(pq) + p, q = rsa.get_byte_array(p), rsa.get_byte_array(q) + new_nonce = int.from_bytes(os.urandom(32), 'little', signed=True) + + pq_inner_data = bytes(PQInnerData( + pq=rsa.get_byte_array(pq), p=p, q=q, + nonce=res_pq.nonce, + server_nonce=res_pq.server_nonce, + new_nonce=new_nonce + )) + + # sha_digest + data + random_bytes + cipher_text, target_fingerprint = None, None + for fingerprint in res_pq.server_public_key_fingerprints: + cipher_text = rsa.encrypt(fingerprint, pq_inner_data) + if cipher_text is not None: + target_fingerprint = fingerprint + break + + if cipher_text is None: + # Second attempt, but now we're allowed to use old keys + for fingerprint in res_pq.server_public_key_fingerprints: + cipher_text = rsa.encrypt(fingerprint, pq_inner_data, use_old=True) + if cipher_text is not None: + target_fingerprint = fingerprint + break + + if cipher_text is None: + raise SecurityError( + 'Step 2 could not find a valid key for fingerprints: {}' + .format(', '.join( + [str(f) for f in res_pq.server_public_key_fingerprints]) + ) + ) + + server_dh_params = await sender.send(ReqDHParamsRequest( + nonce=res_pq.nonce, + server_nonce=res_pq.server_nonce, + p=p, q=q, + public_key_fingerprint=target_fingerprint, + encrypted_data=cipher_text + )) + + assert isinstance( + server_dh_params, (ServerDHParamsOk, ServerDHParamsFail)),\ + 'Step 2.1 answer was %s' % server_dh_params + + if server_dh_params.nonce != res_pq.nonce: + raise SecurityError('Step 2 invalid nonce from server') + + if server_dh_params.server_nonce != res_pq.server_nonce: + raise SecurityError('Step 2 invalid server nonce from server') + + if isinstance(server_dh_params, ServerDHParamsFail): + nnh = int.from_bytes( + sha1(new_nonce.to_bytes(32, 'little', signed=True)).digest()[4:20], + 'little', signed=True + ) + if server_dh_params.new_nonce_hash != nnh: + raise SecurityError('Step 2 invalid DH fail nonce from server') + + assert isinstance(server_dh_params, ServerDHParamsOk),\ + 'Step 2.2 answer was %s' % server_dh_params + + # Step 3 sending: Complete DH Exchange + key, iv = helpers.generate_key_data_from_nonce( + res_pq.server_nonce, new_nonce + ) + if len(server_dh_params.encrypted_answer) % 16 != 0: + # See PR#453 + raise SecurityError('Step 3 AES block size mismatch') + + plain_text_answer = AES.decrypt_ige( + server_dh_params.encrypted_answer, key, iv + ) + + with BinaryReader(plain_text_answer) as reader: + reader.read(20) # hash sum + server_dh_inner = reader.tgread_object() + assert isinstance(server_dh_inner, ServerDHInnerData),\ + 'Step 3 answer was %s' % server_dh_inner + + if server_dh_inner.nonce != res_pq.nonce: + raise SecurityError('Step 3 Invalid nonce in encrypted answer') + + if server_dh_inner.server_nonce != res_pq.server_nonce: + raise SecurityError('Step 3 Invalid server nonce in encrypted answer') + + dh_prime = get_int(server_dh_inner.dh_prime, signed=False) + g = server_dh_inner.g + g_a = get_int(server_dh_inner.g_a, signed=False) + time_offset = server_dh_inner.server_time - int(time.time()) + + b = get_int(os.urandom(256), signed=False) + g_b = pow(g, b, dh_prime) + gab = pow(g_a, b, dh_prime) + + # IMPORTANT: Apart from the conditions on the Diffie-Hellman prime + # dh_prime and generator g, both sides are to check that g, g_a and + # g_b are greater than 1 and less than dh_prime - 1. We recommend + # checking that g_a and g_b are between 2^{2048-64} and + # dh_prime - 2^{2048-64} as well. + # (https://core.telegram.org/mtproto/auth_key#dh-key-exchange-complete) + if not (1 < g < (dh_prime - 1)): + raise SecurityError('g_a is not within (1, dh_prime - 1)') + + if not (1 < g_a < (dh_prime - 1)): + raise SecurityError('g_a is not within (1, dh_prime - 1)') + + if not (1 < g_b < (dh_prime - 1)): + raise SecurityError('g_b is not within (1, dh_prime - 1)') + + safety_range = 2 ** (2048 - 64) + if not (safety_range <= g_a <= (dh_prime - safety_range)): + raise SecurityError('g_a is not within (2^{2048-64}, dh_prime - 2^{2048-64})') + + if not (safety_range <= g_b <= (dh_prime - safety_range)): + raise SecurityError('g_b is not within (2^{2048-64}, dh_prime - 2^{2048-64})') + + # Prepare client DH Inner Data + client_dh_inner = bytes(ClientDHInnerData( + nonce=res_pq.nonce, + server_nonce=res_pq.server_nonce, + retry_id=0, # TODO Actual retry ID + g_b=rsa.get_byte_array(g_b) + )) + + client_dh_inner_hashed = sha1(client_dh_inner).digest() + client_dh_inner + + # Encryption + client_dh_encrypted = AES.encrypt_ige(client_dh_inner_hashed, key, iv) + + # Prepare Set client DH params + dh_gen = await sender.send(SetClientDHParamsRequest( + nonce=res_pq.nonce, + server_nonce=res_pq.server_nonce, + encrypted_data=client_dh_encrypted, + )) + + nonce_types = (DhGenOk, DhGenRetry, DhGenFail) + assert isinstance(dh_gen, nonce_types), 'Step 3.1 answer was %s' % dh_gen + name = dh_gen.__class__.__name__ + if dh_gen.nonce != res_pq.nonce: + raise SecurityError('Step 3 invalid {} nonce from server'.format(name)) + + if dh_gen.server_nonce != res_pq.server_nonce: + raise SecurityError( + 'Step 3 invalid {} server nonce from server'.format(name)) + + auth_key = AuthKey(rsa.get_byte_array(gab)) + nonce_number = 1 + nonce_types.index(type(dh_gen)) + new_nonce_hash = auth_key.calc_new_nonce_hash(new_nonce, nonce_number) + + dh_hash = getattr(dh_gen, 'new_nonce_hash{}'.format(nonce_number)) + if dh_hash != new_nonce_hash: + raise SecurityError('Step 3 invalid new nonce hash') + + if not isinstance(dh_gen, DhGenOk): + raise AssertionError('Step 3.2 answer was %s' % dh_gen) + + return auth_key, time_offset + + +def get_int(byte_array, signed=True): + """ + Gets the specified integer from its byte array. + This should be used by this module alone, as it works with big endian. + + :param byte_array: the byte array representing th integer. + :param signed: whether the number is signed or not. + :return: the integer representing the given byte array. + """ + return int.from_bytes(byte_array, byteorder='big', signed=signed) diff --git a/module/scripts/lib/telethon/network/connection/__init__.py b/module/scripts/lib/telethon/network/connection/__init__.py new file mode 100644 index 0000000..8877186 --- /dev/null +++ b/module/scripts/lib/telethon/network/connection/__init__.py @@ -0,0 +1,12 @@ +from .connection import Connection +from .tcpfull import ConnectionTcpFull +from .tcpintermediate import ConnectionTcpIntermediate +from .tcpabridged import ConnectionTcpAbridged +from .tcpobfuscated import ConnectionTcpObfuscated +from .tcpmtproxy import ( + TcpMTProxy, + ConnectionTcpMTProxyAbridged, + ConnectionTcpMTProxyIntermediate, + ConnectionTcpMTProxyRandomizedIntermediate +) +from .http import ConnectionHttp diff --git a/module/scripts/lib/telethon/network/connection/connection.py b/module/scripts/lib/telethon/network/connection/connection.py new file mode 100644 index 0000000..8236bc0 --- /dev/null +++ b/module/scripts/lib/telethon/network/connection/connection.py @@ -0,0 +1,440 @@ +import abc +import asyncio +import socket +import sys + +try: + import ssl as ssl_mod +except ImportError: + ssl_mod = None + +try: + import python_socks +except ImportError: + python_socks = None + +from ...errors import InvalidChecksumError, InvalidBufferError +from ... import helpers + + +class Connection(abc.ABC): + """ + The `Connection` class is a wrapper around ``asyncio.open_connection``. + + Subclasses will implement different transport modes as atomic operations, + which this class eases doing since the exposed interface simply puts and + gets complete data payloads to and from queues. + + The only error that will raise from send and receive methods is + ``ConnectionError``, which will raise when attempting to send if + the client is disconnected (includes remote disconnections). + """ + # this static attribute should be redefined by `Connection` subclasses and + # should be one of `PacketCodec` implementations + packet_codec = None + + def __init__(self, ip, port, dc_id, *, loggers, proxy=None, local_addr=None): + self._ip = ip + self._port = port + self._dc_id = dc_id # only for MTProxy, it's an abstraction leak + self._log = loggers[__name__] + self._proxy = proxy + self._local_addr = local_addr + self._reader = None + self._writer = None + self._connected = False + self._send_task = None + self._recv_task = None + self._codec = None + self._obfuscation = None # TcpObfuscated and MTProxy + self._send_queue = asyncio.Queue(1) + self._recv_queue = asyncio.Queue(1) + + @staticmethod + def _wrap_socket_ssl(sock): + if ssl_mod is None: + raise RuntimeError( + 'Cannot use proxy that requires SSL ' + 'without the SSL module being available' + ) + + return ssl_mod.wrap_socket( + sock, + do_handshake_on_connect=True, + ssl_version=ssl_mod.PROTOCOL_SSLv23, + ciphers='ADH-AES256-SHA') + + @staticmethod + def _parse_proxy(proxy_type, addr, port, rdns=True, username=None, password=None): + if isinstance(proxy_type, str): + proxy_type = proxy_type.lower() + + # Always prefer `python_socks` when available + if python_socks: + from python_socks import ProxyType + + # We do the check for numerical values here + # to be backwards compatible with PySocks proxy format, + # (since socks.SOCKS5 == 2, socks.SOCKS4 == 1, socks.HTTP == 3) + if proxy_type == ProxyType.SOCKS5 or proxy_type == 2 or proxy_type == "socks5": + protocol = ProxyType.SOCKS5 + elif proxy_type == ProxyType.SOCKS4 or proxy_type == 1 or proxy_type == "socks4": + protocol = ProxyType.SOCKS4 + elif proxy_type == ProxyType.HTTP or proxy_type == 3 or proxy_type == "http": + protocol = ProxyType.HTTP + else: + raise ValueError("Unknown proxy protocol type: {}".format(proxy_type)) + + # This tuple must be compatible with `python_socks`' `Proxy.create()` signature + return protocol, addr, port, username, password, rdns + + else: + from socks import SOCKS5, SOCKS4, HTTP + + if proxy_type == 2 or proxy_type == "socks5": + protocol = SOCKS5 + elif proxy_type == 1 or proxy_type == "socks4": + protocol = SOCKS4 + elif proxy_type == 3 or proxy_type == "http": + protocol = HTTP + else: + raise ValueError("Unknown proxy protocol type: {}".format(proxy_type)) + + # This tuple must be compatible with `PySocks`' `socksocket.set_proxy()` signature + return protocol, addr, port, rdns, username, password + + async def _proxy_connect(self, timeout=None, local_addr=None): + if isinstance(self._proxy, (tuple, list)): + parsed = self._parse_proxy(*self._proxy) + elif isinstance(self._proxy, dict): + parsed = self._parse_proxy(**self._proxy) + else: + raise TypeError("Proxy of unknown format: {}".format(type(self._proxy))) + + # Always prefer `python_socks` when available + if python_socks: + # python_socks internal errors are not inherited from + # builtin IOError (just from Exception). Instead of adding those + # in exceptions clauses everywhere through the code, we + # rather monkey-patch them in place. Keep in mind that + # ProxyError takes error_code as keyword argument. + + class ConnectionErrorExtra(ConnectionError): + def __init__(self, message, error_code=None): + super().__init__(message) + self.error_code = error_code + + python_socks._errors.ProxyError = ConnectionErrorExtra + python_socks._errors.ProxyConnectionError = ConnectionError + python_socks._errors.ProxyTimeoutError = ConnectionError + + from python_socks.async_.asyncio import Proxy + + proxy = Proxy.create(*parsed) + + # WARNING: If `local_addr` is set we use manual socket creation, because, + # unfortunately, `Proxy.connect()` does not expose `local_addr` + # argument, so if we want to bind socket locally, we need to manually + # create, bind and connect socket, and then pass to `Proxy.connect()` method. + + if local_addr is None: + sock = await proxy.connect( + dest_host=self._ip, + dest_port=self._port, + timeout=timeout + ) + else: + # Here we start manual setup of the socket. + # The `address` represents the proxy ip and proxy port, + # not the destination one (!), because the socket + # connects to the proxy server, not destination server. + # IPv family is also checked on proxy address. + if ':' in proxy.proxy_host: + mode, address = socket.AF_INET6, (proxy.proxy_host, proxy.proxy_port, 0, 0) + else: + mode, address = socket.AF_INET, (proxy.proxy_host, proxy.proxy_port) + + # Create a non-blocking socket and bind it (if local address is specified). + sock = socket.socket(mode, socket.SOCK_STREAM) + sock.setblocking(False) + sock.bind(local_addr) + + # Actual TCP connection is performed here. + await asyncio.wait_for( + helpers.get_running_loop().sock_connect(sock=sock, address=address), + timeout=timeout + ) + + # As our socket is already created and connected, + # this call sets the destination host/port and + # starts protocol negotiations with the proxy server. + sock = await proxy.connect( + dest_host=self._ip, + dest_port=self._port, + timeout=timeout, + _socket=sock + ) + + else: + import socks + + # Here `address` represents destination address (not proxy), because of + # the `PySocks` implementation of the connection routine. + # IPv family is checked on proxy address, not destination address. + if ':' in parsed[1]: + mode, address = socket.AF_INET6, (self._ip, self._port, 0, 0) + else: + mode, address = socket.AF_INET, (self._ip, self._port) + + # Setup socket, proxy, timeout and bind it (if necessary). + sock = socks.socksocket(mode, socket.SOCK_STREAM) + sock.set_proxy(*parsed) + sock.settimeout(timeout) + + if local_addr is not None: + sock.bind(local_addr) + + # Actual TCP connection and negotiation performed here. + await asyncio.wait_for( + helpers.get_running_loop().sock_connect(sock=sock, address=address), + timeout=timeout + ) + + sock.setblocking(False) + + return sock + + async def _connect(self, timeout=None, ssl=None): + if self._local_addr is not None: + # NOTE: If port is not specified, we use 0 port + # to notify the OS that port should be chosen randomly + # from the available ones. + if isinstance(self._local_addr, tuple) and len(self._local_addr) == 2: + local_addr = self._local_addr + elif isinstance(self._local_addr, str): + local_addr = (self._local_addr, 0) + else: + raise ValueError("Unknown local address format: {}".format(self._local_addr)) + else: + local_addr = None + + if not self._proxy: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection( + host=self._ip, + port=self._port, + ssl=ssl, + local_addr=local_addr + ), timeout=timeout) + else: + # Proxy setup, connection and negotiation is performed here. + sock = await self._proxy_connect( + timeout=timeout, + local_addr=local_addr + ) + + # Wrap socket in SSL context (if provided) + if ssl: + sock = self._wrap_socket_ssl(sock) + + self._reader, self._writer = await asyncio.open_connection(sock=sock) + + self._codec = self.packet_codec(self) + self._init_conn() + await self._writer.drain() + + async def connect(self, timeout=None, ssl=None): + """ + Establishes a connection with the server. + """ + await self._connect(timeout=timeout, ssl=ssl) + self._connected = True + + loop = helpers.get_running_loop() + self._send_task = loop.create_task(self._send_loop()) + self._recv_task = loop.create_task(self._recv_loop()) + + async def disconnect(self): + """ + Disconnects from the server, and clears + pending outgoing and incoming messages. + """ + if not self._connected: + return + + self._connected = False + + await helpers._cancel( + self._log, + send_task=self._send_task, + recv_task=self._recv_task + ) + + if self._writer: + self._writer.close() + if sys.version_info >= (3, 7): + try: + await asyncio.wait_for(self._writer.wait_closed(), timeout=10) + except asyncio.TimeoutError: + # See issue #3917. For some users, this line was hanging indefinitely. + # The hard timeout is not ideal (connection won't be properly closed), + # but the code will at least be able to procceed. + self._log.warning('Graceful disconnection timed out, forcibly ignoring cleanup') + except Exception as e: + # Disconnecting should never raise. Seen: + # * OSError: No route to host and + # * OSError: [Errno 32] Broken pipe + # * ConnectionResetError + self._log.info('%s during disconnect: %s', type(e), e) + + def send(self, data): + """ + Sends a packet of data through this connection mode. + + This method returns a coroutine. + """ + if not self._connected: + raise ConnectionError('Not connected') + + return self._send_queue.put(data) + + async def recv(self): + """ + Receives a packet of data through this connection mode. + + This method returns a coroutine. + """ + while self._connected: + result, err = await self._recv_queue.get() + if err: + raise err + if result: + return result + + raise ConnectionError('Not connected') + + async def _send_loop(self): + """ + This loop is constantly popping items off the queue to send them. + """ + try: + while self._connected: + self._send(await self._send_queue.get()) + await self._writer.drain() + except asyncio.CancelledError: + pass + except Exception as e: + if isinstance(e, IOError): + self._log.info('The server closed the connection while sending') + else: + self._log.exception('Unexpected exception in the send loop') + + await self.disconnect() + + async def _recv_loop(self): + """ + This loop is constantly putting items on the queue as they're read. + """ + try: + while self._connected: + try: + data = await self._recv() + except asyncio.CancelledError: + break + except (IOError, asyncio.IncompleteReadError) as e: + self._log.warning('Server closed the connection: %s', e) + await self._recv_queue.put((None, e)) + await self.disconnect() + except InvalidChecksumError as e: + self._log.warning('Server response had invalid checksum: %s', e) + await self._recv_queue.put((None, e)) + except InvalidBufferError as e: + self._log.warning('Server response had invalid buffer: %s', e) + await self._recv_queue.put((None, e)) + except Exception as e: + self._log.exception('Unexpected exception in the receive loop') + await self._recv_queue.put((None, e)) + await self.disconnect() + else: + await self._recv_queue.put((data, None)) + finally: + await self.disconnect() + + + def _init_conn(self): + """ + This method will be called after `connect` is called. + After this method finishes, the writer will be drained. + + Subclasses should make use of this if they need to send + data to Telegram to indicate which connection mode will + be used. + """ + if self._codec.tag: + self._writer.write(self._codec.tag) + + def _send(self, data): + self._writer.write(self._codec.encode_packet(data)) + + async def _recv(self): + return await self._codec.read_packet(self._reader) + + def __str__(self): + return '{}:{}/{}'.format( + self._ip, self._port, + self.__class__.__name__.replace('Connection', '') + ) + + +class ObfuscatedConnection(Connection): + """ + Base class for "obfuscated" connections ("obfuscated2", "mtproto proxy") + """ + """ + This attribute should be redefined by subclasses + """ + obfuscated_io = None + + def _init_conn(self): + self._obfuscation = self.obfuscated_io(self) + self._writer.write(self._obfuscation.header) + + def _send(self, data): + self._obfuscation.write(self._codec.encode_packet(data)) + + async def _recv(self): + return await self._codec.read_packet(self._obfuscation) + + +class PacketCodec(abc.ABC): + """ + Base class for packet codecs + """ + + """ + This attribute should be re-defined by subclass to define if some + "magic bytes" should be sent to server right after connection is made to + signal which protocol will be used + """ + tag = None + + def __init__(self, connection): + """ + Codec is created when connection is just made. + """ + self._conn = connection + + @abc.abstractmethod + def encode_packet(self, data): + """ + Encodes single packet and returns encoded bytes. + """ + raise NotImplementedError + + @abc.abstractmethod + async def read_packet(self, reader): + """ + Reads single packet from `reader` object that should have + `readexactly(n)` method. + """ + raise NotImplementedError diff --git a/module/scripts/lib/telethon/network/connection/http.py b/module/scripts/lib/telethon/network/connection/http.py new file mode 100644 index 0000000..e2d976f --- /dev/null +++ b/module/scripts/lib/telethon/network/connection/http.py @@ -0,0 +1,39 @@ +import asyncio + +from .connection import Connection, PacketCodec + + +SSL_PORT = 443 + + +class HttpPacketCodec(PacketCodec): + tag = None + obfuscate_tag = None + + def encode_packet(self, data): + return ('POST /api HTTP/1.1\r\n' + 'Host: {}:{}\r\n' + 'Content-Type: application/x-www-form-urlencoded\r\n' + 'Connection: keep-alive\r\n' + 'Keep-Alive: timeout=100000, max=10000000\r\n' + 'Content-Length: {}\r\n\r\n' + .format(self._conn._ip, self._conn._port, len(data)) + .encode('ascii') + data) + + async def read_packet(self, reader): + while True: + line = await reader.readline() + if not line or line[-1] != b'\n': + raise asyncio.IncompleteReadError(line, None) + + if line.lower().startswith(b'content-length: '): + await reader.readexactly(2) + length = int(line[16:-2]) + return await reader.readexactly(length) + + +class ConnectionHttp(Connection): + packet_codec = HttpPacketCodec + + async def connect(self, timeout=None, ssl=None): + await super().connect(timeout=timeout, ssl=self._port == SSL_PORT) diff --git a/module/scripts/lib/telethon/network/connection/tcpabridged.py b/module/scripts/lib/telethon/network/connection/tcpabridged.py new file mode 100644 index 0000000..171b1d8 --- /dev/null +++ b/module/scripts/lib/telethon/network/connection/tcpabridged.py @@ -0,0 +1,33 @@ +import struct + +from .connection import Connection, PacketCodec + + +class AbridgedPacketCodec(PacketCodec): + tag = b'\xef' + obfuscate_tag = b'\xef\xef\xef\xef' + + def encode_packet(self, data): + length = len(data) >> 2 + if length < 127: + length = struct.pack('B', length) + else: + length = b'\x7f' + int.to_bytes(length, 3, 'little') + return length + data + + async def read_packet(self, reader): + length = struct.unpack('= 127: + length = struct.unpack( + ' 0: + return packet_with_padding[:-pad_size] + return packet_with_padding + + +class ConnectionTcpIntermediate(Connection): + """ + Intermediate mode between `ConnectionTcpFull` and `ConnectionTcpAbridged`. + Always sends 4 extra bytes for the packet length. + """ + packet_codec = IntermediatePacketCodec diff --git a/module/scripts/lib/telethon/network/connection/tcpmtproxy.py b/module/scripts/lib/telethon/network/connection/tcpmtproxy.py new file mode 100644 index 0000000..e3ef98a --- /dev/null +++ b/module/scripts/lib/telethon/network/connection/tcpmtproxy.py @@ -0,0 +1,165 @@ +import asyncio +import hashlib +import base64 +import os + +from .connection import ObfuscatedConnection +from .tcpabridged import AbridgedPacketCodec +from .tcpintermediate import ( + IntermediatePacketCodec, + RandomizedIntermediatePacketCodec +) + +from ...crypto import AESModeCTR + + +class MTProxyIO: + """ + It's very similar to tcpobfuscated.ObfuscatedIO, but the way + encryption keys, protocol tag and dc_id are encoded is different. + """ + header = None + + def __init__(self, connection): + self._reader = connection._reader + self._writer = connection._writer + + (self.header, + self._encrypt, + self._decrypt) = self.init_header( + connection._secret, connection._dc_id, connection.packet_codec) + + @staticmethod + def init_header(secret, dc_id, packet_codec): + # Validate + is_dd = (len(secret) == 17) and (secret[0] == 0xDD) + is_rand_codec = issubclass( + packet_codec, RandomizedIntermediatePacketCodec) + if is_dd and not is_rand_codec: + raise ValueError( + "Only RandomizedIntermediate can be used with dd-secrets") + secret = secret[1:] if is_dd else secret + if len(secret) != 16: + raise ValueError( + "MTProxy secret must be a hex-string representing 16 bytes") + + # Obfuscated messages secrets cannot start with any of these + keywords = (b'PVrG', b'GET ', b'POST', b'\xee\xee\xee\xee') + while True: + random = os.urandom(64) + if (random[0] != 0xef and + random[:4] not in keywords and + random[4:4] != b'\0\0\0\0'): + break + + random = bytearray(random) + random_reversed = random[55:7:-1] # Reversed (8, len=48) + + # Encryption has "continuous buffer" enabled + encrypt_key = hashlib.sha256( + bytes(random[8:40]) + secret).digest() + encrypt_iv = bytes(random[40:56]) + decrypt_key = hashlib.sha256( + bytes(random_reversed[:32]) + secret).digest() + decrypt_iv = bytes(random_reversed[32:48]) + + encryptor = AESModeCTR(encrypt_key, encrypt_iv) + decryptor = AESModeCTR(decrypt_key, decrypt_iv) + + random[56:60] = packet_codec.obfuscate_tag + + dc_id_bytes = dc_id.to_bytes(2, "little", signed=True) + random = random[:60] + dc_id_bytes + random[62:] + random[56:64] = encryptor.encrypt(bytes(random))[56:64] + return (random, encryptor, decryptor) + + async def readexactly(self, n): + return self._decrypt.encrypt(await self._reader.readexactly(n)) + + def write(self, data): + self._writer.write(self._encrypt.encrypt(data)) + + +class TcpMTProxy(ObfuscatedConnection): + """ + Connector which allows user to connect to the Telegram via proxy servers + commonly known as MTProxy. + Implemented very ugly due to the leaky abstractions in Telethon networking + classes that should be refactored later (TODO). + + .. warning:: + + The support for TcpMTProxy classes is **EXPERIMENTAL** and prone to + be changed. You shouldn't be using this class yet. + """ + packet_codec = None + obfuscated_io = MTProxyIO + + # noinspection PyUnusedLocal + def __init__(self, ip, port, dc_id, *, loggers, proxy=None, local_addr=None): + # connect to proxy's host and port instead of telegram's ones + proxy_host, proxy_port = self.address_info(proxy) + self._secret = self.normalize_secret(proxy[2]) + super().__init__( + proxy_host, proxy_port, dc_id, loggers=loggers) + + async def _connect(self, timeout=None, ssl=None): + await super()._connect(timeout=timeout, ssl=ssl) + + # Wait for EOF for 2 seconds (or if _wait_for_data's definition + # is missing or different, just sleep for 2 seconds). This way + # we give the proxy a chance to close the connection if the current + # codec (which the proxy detects with the data we sent) cannot + # be used for this proxy. This is a work around for #1134. + # TODO Sleeping for N seconds may not be the best solution + # TODO This fix could be welcome for HTTP proxies as well + try: + await asyncio.wait_for(self._reader._wait_for_data('proxy'), 2) + except asyncio.TimeoutError: + pass + except Exception: + await asyncio.sleep(2) + + if self._reader.at_eof(): + await self.disconnect() + raise ConnectionError( + 'Proxy closed the connection after sending initial payload') + + @staticmethod + def address_info(proxy_info): + if proxy_info is None: + raise ValueError("No proxy info specified for MTProxy connection") + return proxy_info[:2] + + @staticmethod + def normalize_secret(secret): + if secret[:2] in ("ee", "dd"): # Remove extra bytes + secret = secret[2:] + + try: + secret_bytes = bytes.fromhex(secret) + except ValueError: + secret = secret + '=' * (-len(secret) % 4) + secret_bytes = base64.b64decode(secret.encode()) + + return secret_bytes[:16] # Remove the domain from the secret (until domain support is added) + +class ConnectionTcpMTProxyAbridged(TcpMTProxy): + """ + Connect to proxy using abridged protocol + """ + packet_codec = AbridgedPacketCodec + + +class ConnectionTcpMTProxyIntermediate(TcpMTProxy): + """ + Connect to proxy using intermediate protocol + """ + packet_codec = IntermediatePacketCodec + + +class ConnectionTcpMTProxyRandomizedIntermediate(TcpMTProxy): + """ + Connect to proxy using randomized intermediate protocol (dd-secrets) + """ + packet_codec = RandomizedIntermediatePacketCodec diff --git a/module/scripts/lib/telethon/network/connection/tcpobfuscated.py b/module/scripts/lib/telethon/network/connection/tcpobfuscated.py new file mode 100644 index 0000000..cf2e6af --- /dev/null +++ b/module/scripts/lib/telethon/network/connection/tcpobfuscated.py @@ -0,0 +1,62 @@ +import os + +from .tcpabridged import AbridgedPacketCodec +from .connection import ObfuscatedConnection + +from ...crypto import AESModeCTR + + +class ObfuscatedIO: + header = None + + def __init__(self, connection): + self._reader = connection._reader + self._writer = connection._writer + + (self.header, + self._encrypt, + self._decrypt) = self.init_header(connection.packet_codec) + + @staticmethod + def init_header(packet_codec): + # Obfuscated messages secrets cannot start with any of these + keywords = (b'PVrG', b'GET ', b'POST', b'\xee\xee\xee\xee') + while True: + random = os.urandom(64) + if (random[0] != 0xef and + random[:4] not in keywords and + random[4:8] != b'\0\0\0\0'): + break + + random = bytearray(random) + random_reversed = random[55:7:-1] # Reversed (8, len=48) + + # Encryption has "continuous buffer" enabled + encrypt_key = bytes(random[8:40]) + encrypt_iv = bytes(random[40:56]) + decrypt_key = bytes(random_reversed[:32]) + decrypt_iv = bytes(random_reversed[32:48]) + + encryptor = AESModeCTR(encrypt_key, encrypt_iv) + decryptor = AESModeCTR(decrypt_key, decrypt_iv) + + random[56:60] = packet_codec.obfuscate_tag + random[56:64] = encryptor.encrypt(bytes(random))[56:64] + return (random, encryptor, decryptor) + + async def readexactly(self, n): + return self._decrypt.encrypt(await self._reader.readexactly(n)) + + def write(self, data): + self._writer.write(self._encrypt.encrypt(data)) + + +class ConnectionTcpObfuscated(ObfuscatedConnection): + """ + Mode that Telegram defines as "obfuscated2". Encodes the packet + just like `ConnectionTcpAbridged`, but encrypts every message with + a randomly generated key using the AES-CTR mode so the packets are + harder to discern. + """ + obfuscated_io = ObfuscatedIO + packet_codec = AbridgedPacketCodec diff --git a/module/scripts/lib/telethon/network/mtprotoplainsender.py b/module/scripts/lib/telethon/network/mtprotoplainsender.py new file mode 100644 index 0000000..563affd --- /dev/null +++ b/module/scripts/lib/telethon/network/mtprotoplainsender.py @@ -0,0 +1,56 @@ +""" +This module contains the class used to communicate with Telegram's servers +in plain text, when no authorization key has been created yet. +""" +import struct + +from .mtprotostate import MTProtoState +from ..errors import InvalidBufferError +from ..extensions import BinaryReader + + +class MTProtoPlainSender: + """ + MTProto Mobile Protocol plain sender + (https://core.telegram.org/mtproto/description#unencrypted-messages) + """ + def __init__(self, connection, *, loggers): + """ + Initializes the MTProto plain sender. + + :param connection: the Connection to be used. + """ + self._state = MTProtoState(auth_key=None, loggers=loggers) + self._connection = connection + + async def send(self, request): + """ + Sends and receives the result for the given request. + """ + body = bytes(request) + msg_id = self._state._get_new_msg_id() + await self._connection.send( + struct.pack(' 0, 'Bad length' + # We could read length bytes and use those in a new reader to read + # the next TLObject without including the padding, but since the + # reader isn't used for anything else after this, it's unnecessary. + return reader.tgread_object() diff --git a/module/scripts/lib/telethon/network/mtprotosender.py b/module/scripts/lib/telethon/network/mtprotosender.py new file mode 100644 index 0000000..0a96877 --- /dev/null +++ b/module/scripts/lib/telethon/network/mtprotosender.py @@ -0,0 +1,942 @@ +import asyncio +from asyncio.tasks import Task +import collections +import contextlib +import struct +import datetime +import time +from typing import Any + +from . import authenticator +from ..extensions.messagepacker import MessagePacker +from .mtprotoplainsender import MTProtoPlainSender +from .requeststate import RequestState +from .mtprotostate import MTProtoState +from ..tl.tlobject import TLRequest +from .. import helpers, utils +from ..errors import ( + BadMessageError, InvalidBufferError, AuthKeyNotFound, SecurityError, + TypeNotFoundError, rpc_message_to_error +) +from ..extensions import BinaryReader +from ..tl.core import RpcResult, MessageContainer, GzipPacked +from ..tl.functions.auth import LogOutRequest +from ..tl.functions import PingRequest, DestroySessionRequest, DestroyAuthKeyRequest +from ..tl.types import ( + MsgsAck, Pong, BadServerSalt, BadMsgNotification, FutureSalts, + MsgNewDetailedInfo, NewSessionCreated, MsgDetailedInfo, MsgsStateReq, + MsgsStateInfo, MsgsAllInfo, MsgResendReq, upload, DestroySessionOk, DestroySessionNone, + DestroyAuthKeyOk, DestroyAuthKeyNone, DestroyAuthKeyFail +) +from ..tl import types as _tl +from ..crypto import AuthKey +from ..helpers import retry_range + + +class MTProtoSender: + """ + MTProto Mobile Protocol sender + (https://core.telegram.org/mtproto/description). + + This class is responsible for wrapping requests into `TLMessage`'s, + sending them over the network and receiving them in a safe manner. + + Automatic reconnection due to temporary network issues is a concern + for this class as well, including retry of messages that could not + be sent successfully. + + A new authorization key will be generated on connection if no other + key exists yet. + """ + def __init__(self, auth_key, *, loggers, + retries=5, delay=1, auto_reconnect=True, connect_timeout=None, + auth_key_callback=None, + updates_queue=None, auto_reconnect_callback=None): + self._connection = None + self._loggers = loggers + self._log = loggers[__name__] + self._retries = retries + self._delay = delay + self._auto_reconnect = auto_reconnect + self._connect_timeout = connect_timeout + self._auth_key_callback = auth_key_callback + self._updates_queue = updates_queue + self._auto_reconnect_callback = auto_reconnect_callback + self._connect_lock = asyncio.Lock() + self._ping = None + + # Whether the user has explicitly connected or disconnected. + # + # If a disconnection happens for any other reason and it + # was *not* user action then the pending messages won't + # be cleared but on explicit user disconnection all the + # pending futures should be cancelled. + self._user_connected = False + self._reconnecting = False + self.__disconnected = None + + # We need to join the loops upon disconnection + self._send_loop_handle = None + self._recv_loop_handle = None + + # Preserving the references of the AuthKey and state is important + self.auth_key = auth_key or AuthKey(None) + self._state = MTProtoState(self.auth_key, loggers=self._loggers) + + # Outgoing messages are put in a queue and sent in a batch. + # Note that here we're also storing their ``_RequestState``. + self._send_queue = MessagePacker(self._state, loggers=self._loggers) + + # Sent states are remembered until a response is received. + self._pending_state = {} + + # Responses must be acknowledged, and we can also batch these. + self._pending_ack = set() + + # Similar to pending_messages but only for the last acknowledges. + # These can't go in pending_messages because no acknowledge for them + # is received, but we may still need to resend their state on bad salts. + self._last_acks = collections.deque(maxlen=10) + + # Jump table from response ID to method that handles it + self._handlers = { + RpcResult.CONSTRUCTOR_ID: self._handle_rpc_result, + MessageContainer.CONSTRUCTOR_ID: self._handle_container, + GzipPacked.CONSTRUCTOR_ID: self._handle_gzip_packed, + Pong.CONSTRUCTOR_ID: self._handle_pong, + BadServerSalt.CONSTRUCTOR_ID: self._handle_bad_server_salt, + BadMsgNotification.CONSTRUCTOR_ID: self._handle_bad_notification, + MsgDetailedInfo.CONSTRUCTOR_ID: self._handle_detailed_info, + MsgNewDetailedInfo.CONSTRUCTOR_ID: self._handle_new_detailed_info, + NewSessionCreated.CONSTRUCTOR_ID: self._handle_new_session_created, + MsgsAck.CONSTRUCTOR_ID: self._handle_ack, + FutureSalts.CONSTRUCTOR_ID: self._handle_future_salts, + MsgsStateReq.CONSTRUCTOR_ID: self._handle_state_forgotten, + MsgResendReq.CONSTRUCTOR_ID: self._handle_state_forgotten, + MsgsAllInfo.CONSTRUCTOR_ID: self._handle_msg_all, + DestroySessionOk.CONSTRUCTOR_ID: self._handle_destroy_session, + DestroySessionNone.CONSTRUCTOR_ID: self._handle_destroy_session, + DestroyAuthKeyOk.CONSTRUCTOR_ID: self._handle_destroy_auth_key, + DestroyAuthKeyNone.CONSTRUCTOR_ID: self._handle_destroy_auth_key, + DestroyAuthKeyFail.CONSTRUCTOR_ID: self._handle_destroy_auth_key, + } + + self._reconnect_task: Task[Any] | None = None + + # Public API + + async def connect(self, connection): + """ + Connects to the specified given connection using the given auth key. + """ + async with self._connect_lock: + if self._user_connected: + self._log.info('User is already connected!') + return False + + self._connection = connection + await self._connect() + self._user_connected = True + return True + + def is_connected(self): + return self._user_connected + + def _transport_connected(self): + return ( + not self._reconnecting + and self._connection is not None + and self._connection._connected + ) + + async def disconnect(self): + """ + Cleanly disconnects the instance from the network, cancels + all pending requests, and closes the send and receive loops. + """ + if self._reconnect_task: + _ = self._reconnect_task.cancel() + + with contextlib.suppress(asyncio.CancelledError): + await self._reconnect_task + + await self._disconnect() + + def send(self, request, ordered=False): + """ + This method enqueues the given request to be sent. Its send + state will be saved until a response arrives, and a ``Future`` + that will be resolved when the response arrives will be returned: + + .. code-block:: python + + async def method(): + # Sending (enqueued for the send loop) + future = sender.send(request) + # Receiving (waits for the receive loop to read the result) + result = await future + + Designed like this because Telegram may send the response at + any point, and it can send other items while one waits for it. + Once the response for this future arrives, it is set with the + received result, quite similar to how a ``receive()`` call + would otherwise work. + + Since the receiving part is "built in" the future, it's + impossible to await receive a result that was never sent. + """ + if not self._user_connected: + raise ConnectionError('Cannot send requests while disconnected') + + if not utils.is_list_like(request): + try: + state = RequestState(request) + except struct.error as e: + # "struct.error: required argument is not an integer" is not + # very helpful; log the request to find out what wasn't int. + self._log.error('Request caused struct.error: %s: %s', e, request) + raise + + self._send_queue.append(state) + return state.future + else: + states = [] + futures = [] + state = None + for req in request: + try: + state = RequestState(req, after=ordered and state) + except struct.error as e: + self._log.error('Request caused struct.error: %s: %s', e, request) + raise + + states.append(state) + futures.append(state.future) + + self._send_queue.extend(states) + return futures + + @property + def disconnected(self): + """ + Future that resolves when the connection to Telegram + ends, either by user action or in the background. + + Note that it may resolve in either a ``ConnectionError`` + or any other unexpected error that could not be handled. + """ + return asyncio.shield(self._disconnected) + + @property + def _disconnected(self): + if self.__disconnected is None: + self.__disconnected = helpers.get_running_loop().create_future() + self.__disconnected.set_result(None) + return self.__disconnected + + # Private methods + + async def _connect(self): + """ + Performs the actual connection, retrying, generating the + authorization key if necessary, and starting the send and + receive loops. + """ + self._log.info('Connecting to %s...', self._connection) + + connected = False + + for attempt in retry_range(self._retries): + if not connected: + connected = await self._try_connect(attempt) + if not connected: + continue # skip auth key generation until we're connected + + if not self.auth_key: + try: + if not await self._try_gen_auth_key(attempt): + continue # keep retrying until we have the auth key + except (IOError, asyncio.TimeoutError) as e: + # Sometimes, specially during user-DC migrations, + # Telegram may close the connection during auth_key + # generation. If that's the case, we will need to + # connect again. + self._log.warning('Connection error %d during auth_key gen: %s: %s', + attempt, type(e).__name__, e) + + # Whatever the IOError was, make sure to disconnect so we can + # reconnect cleanly after. + await self._connection.disconnect() + connected = False + await asyncio.sleep(self._delay) + continue # next iteration we will try to reconnect + + break # all steps done, break retry loop + else: + if not connected: + raise ConnectionError('Connection to Telegram failed {} time(s)'.format(self._retries)) + + e = ConnectionError('auth_key generation failed {} time(s)'.format(self._retries)) + await self._disconnect(error=e) + raise e + + loop = helpers.get_running_loop() + self._log.debug('Starting send loop') + self._send_loop_handle = loop.create_task(self._send_loop()) + + self._log.debug('Starting receive loop') + self._recv_loop_handle = loop.create_task(self._recv_loop()) + + # _disconnected only completes after manual disconnection + # or errors after which the sender cannot continue such + # as failing to reconnect or any unexpected error. + if self._disconnected.done(): + self.__disconnected = loop.create_future() + + self._log.info('Connection to %s complete!', self._connection) + + async def _try_connect(self, attempt): + try: + self._log.debug('Connection attempt %d...', attempt) + await self._connection.connect(timeout=self._connect_timeout) + self._log.debug('Connection success!') + return True + except (IOError, asyncio.TimeoutError) as e: + self._log.warning('Attempt %d at connecting failed: %s: %s', + attempt, type(e).__name__, e) + await asyncio.sleep(self._delay) + return False + + async def _try_gen_auth_key(self, attempt): + plain = MTProtoPlainSender(self._connection, loggers=self._loggers) + try: + self._log.debug('New auth_key attempt %d...', attempt) + self.auth_key.key, self._state.time_offset = \ + await authenticator.do_authentication(plain) + + # This is *EXTREMELY* important since we don't control + # external references to the authorization key, we must + # notify whenever we change it. This is crucial when we + # switch to different data centers. + if self._auth_key_callback: + await self._auth_key_callback(self.auth_key) + + self._log.debug('auth_key generation success!') + return True + except (SecurityError, AssertionError) as e: + self._log.warning('Attempt %d at new auth_key failed: %s', attempt, e) + await asyncio.sleep(self._delay) + return False + + async def _disconnect(self, error=None): + if self._connection is None: + self._log.info('Not disconnecting (already have no connection)') + return + + self._log.info('Disconnecting from %s...', self._connection) + self._user_connected = False + try: + self._log.debug('Closing current connection...') + await self._connection.disconnect() + finally: + self._log.debug('Cancelling %d pending message(s)...', len(self._pending_state)) + for state in self._pending_state.values(): + if error and not state.future.done(): + state.future.set_exception(error) + else: + state.future.cancel() + + self._pending_state.clear() + await helpers._cancel( + self._log, + send_loop_handle=self._send_loop_handle, + recv_loop_handle=self._recv_loop_handle + ) + + self._log.info('Disconnection from %s complete!', self._connection) + self._connection = None + + if self._disconnected and not self._disconnected.done(): + if error: + self._disconnected.set_exception(error) + else: + self._disconnected.set_result(None) + + async def _reconnect(self, last_error): + """ + Cleanly disconnects and then reconnects. + """ + self._log.info('Closing current connection to begin reconnect...') + if self._connection is not None: + await self._connection.disconnect() + + await helpers._cancel( + self._log, + send_loop_handle=self._send_loop_handle, + recv_loop_handle=self._recv_loop_handle + ) + + # TODO See comment in `_start_reconnect` + # Perhaps this should be the last thing to do? + # But _connect() creates tasks which may run and, + # if they see that reconnecting is True, they will end. + # Perhaps that task creation should not belong in connect? + self._reconnecting = False + + # Start with a clean state (and thus session ID) to avoid old msgs + self._state.reset() + + retries = self._retries if self._auto_reconnect else 0 + + attempt = 0 + ok = True + # We're already "retrying" to connect, so we don't want to force retries + for attempt in retry_range(retries, force_retry=False): + if not self._user_connected: + ok = False + break + try: + await self._connect() + except (IOError, asyncio.TimeoutError) as e: + last_error = e + self._log.info('Failed reconnection attempt %d with %s', + attempt, e.__class__.__name__) + await asyncio.sleep(self._delay) + except BufferError as e: + # TODO there should probably only be one place to except all these errors + if isinstance(e, InvalidBufferError) and e.code == 404: + self._log.info('Server does not know about the current auth key; the session may need to be recreated') + last_error = AuthKeyNotFound() + ok = False + break + else: + self._log.warning('Invalid buffer %s', e) + + except Exception as e: + last_error = e + self._log.exception('Unexpected exception reconnecting on ' + 'attempt %d', attempt) + + await asyncio.sleep(self._delay) + else: + self._send_queue.extend(self._pending_state.values()) + self._pending_state.clear() + + if self._auto_reconnect_callback: + helpers.get_running_loop().create_task(self._auto_reconnect_callback()) + + break + else: + ok = False + + if not ok: + self._log.error('Automatic reconnection failed %d time(s)', attempt) + # There may be no error (e.g. automatic reconnection was turned off). + error = last_error.with_traceback(None) if last_error else None + await self._disconnect(error=error) + + def _start_reconnect(self, error): + """Starts a reconnection in the background.""" + if self._user_connected and not self._reconnecting: + # We set reconnecting to True here and not inside the new task + # because it may happen that send/recv loop calls this again + # while the new task hasn't had a chance to run yet. This race + # condition puts `self.connection` in a bad state with two calls + # to its `connect` without disconnecting, so it creates a second + # receive loop. There can't be two tasks receiving data from + # the reader, since that causes an error, and the library just + # gets stuck. + # TODO It still gets stuck? Investigate where and why. + self._reconnecting = True + self._reconnect_task = helpers.get_running_loop().create_task(self._reconnect(error)) + + def _keepalive_ping(self, rnd_id): + """ + Send a keep-alive ping. If a pong for the last ping was not received + yet, this means we're probably not connected. + """ + # TODO this is ugly, update loop shouldn't worry about this, sender should + if self._ping is None: + self._ping = rnd_id + self.send(PingRequest(rnd_id)) + else: + self._start_reconnect(None) + + # Loops + + async def _send_loop(self): + """ + This loop is responsible for popping items off the send + queue, encrypting them, and sending them over the network. + + Besides `connect`, only this method ever sends data. + """ + while self._user_connected and not self._reconnecting: + if self._pending_ack: + ack = RequestState(MsgsAck(list(self._pending_ack))) + self._send_queue.append(ack) + self._last_acks.append(ack) + self._pending_ack.clear() + + self._log.debug('Waiting for messages to send...') + # TODO Wait for the connection send queue to be empty? + # This means that while it's not empty we can wait for + # more messages to be added to the send queue. + batch, data = await self._send_queue.get() + + if not data: + continue + + self._log.debug('Encrypting %d message(s) in %d bytes for sending', + len(batch), len(data)) + + data = self._state.encrypt_message_data(data) + + # Whether sending succeeds or not, the popped requests are now + # pending because they're removed from the queue. If a reconnect + # occurs, they will be removed from pending state and re-enqueued + # so even if the network fails they won't be lost. If they were + # never re-enqueued, the future waiting for a response "locks". + for state in batch: + if not isinstance(state, list): + if isinstance(state.request, TLRequest): + self._pending_state[state.msg_id] = state + else: + for s in state: + if isinstance(s.request, TLRequest): + self._pending_state[s.msg_id] = s + + try: + await self._connection.send(data) + except IOError as e: + self._log.info('Connection closed while sending data') + self._start_reconnect(e) + return + + self._log.debug('Encrypted messages put in a queue to be sent') + + async def _recv_loop(self): + """ + This loop is responsible for reading all incoming responses + from the network, decrypting and handling or dispatching them. + + Besides `connect`, only this method ever receives data. + """ + while self._user_connected and not self._reconnecting: + self._log.debug('Receiving items from the network...') + try: + body = await self._connection.recv() + except asyncio.CancelledError: + raise # bypass except Exception + except (IOError, asyncio.IncompleteReadError) as e: + self._log.info('Connection closed while receiving data: %s', e) + self._start_reconnect(e) + return + except InvalidBufferError as e: + if e.code == 429: + self._log.warning('Server indicated flood error at transport level: %s', e) + await self._disconnect(error=e) + else: + self._log.exception('Server sent invalid buffer') + self._start_reconnect(e) + return + except Exception as e: + self._log.exception('Unhandled error while receiving data') + self._start_reconnect(e) + return + + try: + message = self._state.decrypt_message_data(body) + if message is None: + continue # this message is to be ignored + except TypeNotFoundError as e: + # Received object which we don't know how to deserialize + self._log.info('Type %08x not found, remaining data %r', + e.invalid_constructor_id, e.remaining) + continue + except SecurityError as e: + # A step while decoding had the incorrect data. This message + # should not be considered safe and it should be ignored. + self._log.warning('Security error while unpacking a ' + 'received message: %s', e) + continue + except BufferError as e: + if isinstance(e, InvalidBufferError) and e.code == 404: + self._log.info('Server does not know about the current auth key; the session may need to be recreated') + await self._disconnect(error=AuthKeyNotFound()) + else: + self._log.warning('Invalid buffer %s', e) + self._start_reconnect(e) + return + except Exception as e: + self._log.exception('Unhandled error while decrypting data') + self._start_reconnect(e) + return + + try: + await self._process_message(message) + except Exception: + self._log.exception('Unhandled error while processing msgs') + + # Response Handlers + + async def _process_message(self, message): + """ + Adds the given message to the list of messages that must be + acknowledged and dispatches control to different ``_handle_*`` + method based on its type. + """ + self._pending_ack.add(message.msg_id) + handler = self._handlers.get(message.obj.CONSTRUCTOR_ID, + self._handle_update) + await handler(message) + + def _pop_states(self, msg_id): + """ + Pops the states known to match the given ID from pending messages. + + This method should be used when the response isn't specific. + """ + state = self._pending_state.pop(msg_id, None) + if state: + return [state] + + to_pop = [] + for state in self._pending_state.values(): + if state.container_id == msg_id: + to_pop.append(state.msg_id) + + if to_pop: + return [self._pending_state.pop(x) for x in to_pop] + + for ack in self._last_acks: + if ack.msg_id == msg_id: + return [ack] + + return [] + + async def _handle_rpc_result(self, message): + """ + Handles the result for Remote Procedure Calls: + + rpc_result#f35c6d01 req_msg_id:long result:bytes = RpcResult; + + This is where the future results for sent requests are set. + """ + rpc_result = message.obj + state = self._pending_state.pop(rpc_result.req_msg_id, None) + self._log.debug('Handling RPC result for message %d', + rpc_result.req_msg_id) + + if not state: + # TODO We should not get responses to things we never sent + # However receiving a File() with empty bytes is "common". + # See #658, #759 and #958. They seem to happen in a container + # which contain the real response right after. + # + # But, it might also happen that we get an *error* for no parent request. + # If that's the case attempting to read from body which is None would fail with: + # "BufferError: No more data left to read (need 4, got 0: b''); last read None". + # This seems to be particularly common for "RpcError(error_code=-500, error_message='No workers running')". + if rpc_result.error: + self._log.info('Received error without parent request: %s', rpc_result.error) + else: + try: + with BinaryReader(rpc_result.body) as reader: + if not isinstance(reader.tgread_object(), upload.File): + raise ValueError('Not an upload.File') + except (TypeNotFoundError, ValueError): + self._log.info('Received response without parent request: %s', rpc_result.body) + return + + if rpc_result.error: + error = rpc_message_to_error(rpc_result.error, state.request) + self._send_queue.append( + RequestState(MsgsAck([state.msg_id]))) + + if not state.future.cancelled(): + state.future.set_exception(error) + else: + try: + with BinaryReader(rpc_result.body) as reader: + result = state.request.read_result(reader) + except Exception as e: + # e.g. TypeNotFoundError, should be propagated to caller + if not state.future.cancelled(): + state.future.set_exception(e) + else: + self._store_own_updates(result) + if not state.future.cancelled(): + state.future.set_result(result) + + async def _handle_container(self, message): + """ + Processes the inner messages of a container with many of them: + + msg_container#73f1f8dc messages:vector<%Message> = MessageContainer; + """ + self._log.debug('Handling container') + for inner_message in message.obj.messages: + await self._process_message(inner_message) + + async def _handle_gzip_packed(self, message): + """ + Unpacks the data from a gzipped object and processes it: + + gzip_packed#3072cfa1 packed_data:bytes = Object; + """ + self._log.debug('Handling gzipped data') + with BinaryReader(message.obj.data) as reader: + message.obj = reader.tgread_object() + await self._process_message(message) + + async def _handle_update(self, message): + try: + assert message.obj.SUBCLASS_OF_ID == 0x8af52aac # crc32(b'Updates') + except AssertionError: + self._log.warning( + 'Note: %s is not an update, not dispatching it %s', + message.obj.__class__.__name__, + message.obj + ) + return + + self._log.debug('Handling update %s', message.obj.__class__.__name__) + self._updates_queue.put_nowait(message.obj) + + def _store_own_updates(self, obj, *, _update_ids=frozenset(( + _tl.UpdateShortMessage.CONSTRUCTOR_ID, + _tl.UpdateShortChatMessage.CONSTRUCTOR_ID, + _tl.UpdateShort.CONSTRUCTOR_ID, + _tl.UpdatesCombined.CONSTRUCTOR_ID, + _tl.Updates.CONSTRUCTOR_ID, + _tl.UpdateShortSentMessage.CONSTRUCTOR_ID, + )), _update_like_ids=frozenset(( + _tl.messages.AffectedHistory.CONSTRUCTOR_ID, + _tl.messages.AffectedMessages.CONSTRUCTOR_ID, + _tl.messages.AffectedFoundMessages.CONSTRUCTOR_ID, + ))): + try: + if obj.CONSTRUCTOR_ID in _update_ids: + obj._self_outgoing = True # flag to only process, but not dispatch these + self._updates_queue.put_nowait(obj) + elif obj.CONSTRUCTOR_ID in _update_like_ids: + # Ugly "hack" (?) - otherwise bots reliably detect gaps when deleting messages. + # + # Note: the `date` being `None` is used to check for `updatesTooLong`, so epoch + # is used instead. It is still not read, because `updateShort` has no `seq`. + # + # Some requests, such as `readHistory`, also return these types. But the `pts_count` + # seems to be zero, so while this will produce some bogus `updateDeleteMessages`, + # it's still one of the "cleaner" approaches to handling the new `pts`. + # `updateDeleteMessages` is probably the "least-invasive" update that can be used. + upd = _tl.UpdateShort( + _tl.UpdateDeleteMessages([], obj.pts, obj.pts_count), + datetime.datetime(*time.gmtime(0)[:6]).replace(tzinfo=datetime.timezone.utc) + ) + upd._self_outgoing = True + self._updates_queue.put_nowait(upd) + elif obj.CONSTRUCTOR_ID == _tl.messages.InvitedUsers.CONSTRUCTOR_ID: + obj.updates._self_outgoing = True + self._updates_queue.put_nowait(obj.updates) + elif obj.CONSTRUCTOR_ID == _tl.messages.ChatInviteJoinResultOk.CONSTRUCTOR_ID: + obj.updates._self_outgoing = True + self._updates_queue.put_nowait(obj.updates) + + except AttributeError: + pass + + async def _handle_pong(self, message): + """ + Handles pong results, which don't come inside a ``rpc_result`` + but are still sent through a request: + + pong#347773c5 msg_id:long ping_id:long = Pong; + """ + pong = message.obj + self._log.debug('Handling pong for message %d', pong.msg_id) + if self._ping == pong.ping_id: + self._ping = None + + state = self._pending_state.pop(pong.msg_id, None) + if state: + state.future.set_result(pong) + + async def _handle_bad_server_salt(self, message): + """ + Corrects the currently used server salt to use the right value + before enqueuing the rejected message to be re-sent: + + bad_server_salt#edab447b bad_msg_id:long bad_msg_seqno:int + error_code:int new_server_salt:long = BadMsgNotification; + """ + bad_salt = message.obj + self._log.debug('Handling bad salt for message %d', bad_salt.bad_msg_id) + self._state.salt = bad_salt.new_server_salt + states = self._pop_states(bad_salt.bad_msg_id) + self._send_queue.extend(states) + + self._log.debug('%d message(s) will be resent', len(states)) + + async def _handle_bad_notification(self, message): + """ + Adjusts the current state to be correct based on the + received bad message notification whenever possible: + + bad_msg_notification#a7eff811 bad_msg_id:long bad_msg_seqno:int + error_code:int = BadMsgNotification; + """ + bad_msg = message.obj + states = self._pop_states(bad_msg.bad_msg_id) + + self._log.debug('Handling bad msg %s', bad_msg) + if bad_msg.error_code in (16, 17): + # Sent msg_id too low or too high (respectively). + # Use the current msg_id to determine the right time offset. + to = self._state.update_time_offset( + correct_msg_id=message.msg_id) + self._log.info('System clock is wrong, set time offset to %ds', to) + elif bad_msg.error_code == 32: + # msg_seqno too low, so just pump it up by some "large" amount + # TODO A better fix would be to start with a new fresh session ID + self._state._sequence += 64 + elif bad_msg.error_code == 33: + # msg_seqno too high never seems to happen but just in case + self._state._sequence -= 16 + else: + for state in states: + state.future.set_exception( + BadMessageError(state.request, bad_msg.error_code)) + return + + # Messages are to be re-sent once we've corrected the issue + self._send_queue.extend(states) + self._log.debug('%d messages will be resent due to bad msg', + len(states)) + + async def _handle_detailed_info(self, message): + """ + Updates the current status with the received detailed information: + + msg_detailed_info#276d3ec6 msg_id:long answer_msg_id:long + bytes:int status:int = MsgDetailedInfo; + """ + # TODO https://goo.gl/VvpCC6 + msg_id = message.obj.answer_msg_id + self._log.debug('Handling detailed info for message %d', msg_id) + self._pending_ack.add(msg_id) + + async def _handle_new_detailed_info(self, message): + """ + Updates the current status with the received detailed information: + + msg_new_detailed_info#809db6df answer_msg_id:long + bytes:int status:int = MsgDetailedInfo; + """ + # TODO https://goo.gl/G7DPsR + msg_id = message.obj.answer_msg_id + self._log.debug('Handling new detailed info for message %d', msg_id) + self._pending_ack.add(msg_id) + + async def _handle_new_session_created(self, message): + """ + Updates the current status with the received session information: + + new_session_created#9ec20908 first_msg_id:long unique_id:long + server_salt:long = NewSession; + """ + # TODO https://goo.gl/LMyN7A + self._log.debug('Handling new session created') + self._state.salt = message.obj.server_salt + + async def _handle_ack(self, message): + """ + Handles a server acknowledge about our messages. Normally + these can be ignored except in the case of ``auth.logOut``: + + auth.logOut#5717da40 = Bool; + + Telegram doesn't seem to send its result so we need to confirm + it manually. No other request is known to have this behaviour. + + Since the ID of sent messages consisting of a container is + never returned (unless on a bad notification), this method + also removes containers messages when any of their inner + messages are acknowledged. + """ + ack = message.obj + self._log.debug('Handling acknowledge for %s', str(ack.msg_ids)) + for msg_id in ack.msg_ids: + state = self._pending_state.get(msg_id) + if state and isinstance(state.request, LogOutRequest): + del self._pending_state[msg_id] + if not state.future.cancelled(): + state.future.set_result(True) + + async def _handle_future_salts(self, message): + """ + Handles future salt results, which don't come inside a + ``rpc_result`` but are still sent through a request: + + future_salts#ae500895 req_msg_id:long now:int + salts:vector = FutureSalts; + """ + # TODO save these salts and automatically adjust to the + # correct one whenever the salt in use expires. + self._log.debug('Handling future salts for message %d', message.msg_id) + state = self._pending_state.pop(message.obj.req_msg_id, None) + if state: + state.future.set_result(message.obj) + + async def _handle_state_forgotten(self, message): + """ + Handles both :tl:`MsgsStateReq` and :tl:`MsgResendReq` by + enqueuing a :tl:`MsgsStateInfo` to be sent at a later point. + """ + self._send_queue.append(RequestState(MsgsStateInfo( + req_msg_id=message.msg_id, info=chr(1) * len(message.obj.msg_ids) + ))) + + async def _handle_msg_all(self, message): + """ + Handles :tl:`MsgsAllInfo` by doing nothing (yet). + """ + + async def _handle_destroy_session(self, message): + """ + Handles both :tl:`DestroySessionOk` and :tl:`DestroySessionNone`. + It behaves pretty much like handling an RPC result. + """ + for msg_id, state in self._pending_state.items(): + if isinstance(state.request, DestroySessionRequest)\ + and state.request.session_id == message.obj.session_id: + break + else: + return + + del self._pending_state[msg_id] + if not state.future.cancelled(): + state.future.set_result(message.obj) + + async def _handle_destroy_auth_key(self, message): + """ + Handles :tl:`DestroyAuthKeyFail`, :tl:`DestroyAuthKeyNone`, and :tl:`DestroyAuthKeyOk`. + + :tl:`DestroyAuthKey` is not intended for users to use, but they still + might, and the response won't come in `rpc_result`, so thhat's worked + around here. + """ + self._log.debug('Handling destroy auth key %s', message.obj) + for msg_id, state in list(self._pending_state.items()): + if isinstance(state.request, DestroyAuthKeyRequest): + del self._pending_state[msg_id] + if not state.future.cancelled(): + state.future.set_result(message.obj) + + # If the auth key has been destroyed, that pretty much means the + # library can't continue as our auth key will no longer be found + # on the server. + # Even if the library didn't disconnect, the server would (and then + # the library would reconnect and learn about auth key being invalid). + if isinstance(message.obj, DestroyAuthKeyOk): + await self._disconnect(error=AuthKeyNotFound()) diff --git a/module/scripts/lib/telethon/network/mtprotostate.py b/module/scripts/lib/telethon/network/mtprotostate.py new file mode 100644 index 0000000..49fb747 --- /dev/null +++ b/module/scripts/lib/telethon/network/mtprotostate.py @@ -0,0 +1,285 @@ +import os +import struct +import time +from hashlib import sha256 +from collections import deque + +from ..crypto import AES +from ..errors import SecurityError, InvalidBufferError +from ..extensions import BinaryReader +from ..tl.core import TLMessage +from ..tl.tlobject import TLRequest +from ..tl.functions import InvokeAfterMsgRequest +from ..tl.core.gzippacked import GzipPacked +from ..tl.types import BadServerSalt, BadMsgNotification + + +# N is not specified in https://core.telegram.org/mtproto/security_guidelines#checking-msg-id, but 500 is reasonable +MAX_RECENT_MSG_IDS = 500 + +MSG_TOO_NEW_DELTA = 30 +MSG_TOO_OLD_DELTA = 300 + +# Something must be wrong if we ignore too many messages at the same time +MAX_CONSECUTIVE_IGNORED = 10 + + +class _OpaqueRequest(TLRequest): + """ + Wraps a serialized request into a type that can be serialized again. + """ + def __init__(self, data: bytes): + self.data = data + + def _bytes(self): + return self.data + + + +class MTProtoState: + """ + `telethon.network.mtprotosender.MTProtoSender` needs to hold a state + in order to be able to encrypt and decrypt incoming/outgoing messages, + as well as generating the message IDs. Instances of this class hold + together all the required information. + + It doesn't make sense to use `telethon.sessions.abstract.Session` for + the sender because the sender should *not* be concerned about storing + this information to disk, as one may create as many senders as they + desire to any other data center, or some CDN. Using the same session + for all these is not a good idea as each need their own authkey, and + the concept of "copying" sessions with the unnecessary entities or + updates state for these connections doesn't make sense. + + While it would be possible to have a `MTProtoPlainState` that does no + encryption so that it was usable through the `MTProtoLayer` and thus + avoid the need for a `MTProtoPlainSender`, the `MTProtoLayer` is more + focused to efficiency and this state is also more advanced (since it + supports gzipping and invoking after other message IDs). There are too + many methods that would be needed to make it convenient to use for the + authentication process, at which point the `MTProtoPlainSender` is better. + """ + def __init__(self, auth_key, loggers): + self.auth_key = auth_key + self._log = loggers[__name__] + self.time_offset = 0 + self.salt = 0 + + self.id = self._sequence = self._last_msg_id = None + self._recent_remote_ids = deque(maxlen=MAX_RECENT_MSG_IDS) + self._highest_remote_id = 0 + self._ignore_count = 0 + self.reset() + + def reset(self): + """ + Resets the state. + """ + # Session IDs can be random on every connection + self.id = struct.unpack('q', os.urandom(8))[0] + self._sequence = 0 + self._last_msg_id = 0 + self._recent_remote_ids.clear() + self._highest_remote_id = 0 + self._ignore_count = 0 + + def update_message_id(self, message): + """ + Updates the message ID to a new one, + used when the time offset changed. + """ + message.msg_id = self._get_new_msg_id() + + @staticmethod + def _calc_key(auth_key, msg_key, client): + """ + Calculate the key based on Telegram guidelines for MTProto 2, + specifying whether it's the client or not. See + https://core.telegram.org/mtproto/description#defining-aes-key-and-initialization-vector + """ + x = 0 if client else 8 + sha256a = sha256(msg_key + auth_key[x: x + 36]).digest() + sha256b = sha256(auth_key[x + 40:x + 76] + msg_key).digest() + + aes_key = sha256a[:8] + sha256b[8:24] + sha256a[24:32] + aes_iv = sha256b[:8] + sha256a[8:24] + sha256b[24:32] + + return aes_key, aes_iv + + def write_data_as_message(self, buffer, data, content_related, + *, after_id=None): + """ + Writes a message containing the given data into buffer. + + Returns the message id. + """ + msg_id = self._get_new_msg_id() + seq_no = self._get_seq_no(content_related) + if after_id is None: + body = GzipPacked.gzip_if_smaller(content_related, data) + else: + # The `RequestState` stores `bytes(request)`, not the request itself. + # `invokeAfterMsg` wants a `TLRequest` though, hence the wrapping. + body = GzipPacked.gzip_if_smaller(content_related, + bytes(InvokeAfterMsgRequest(after_id, _OpaqueRequest(data)))) + + buffer.write(struct.pack('> 32 + time_delta = (now + self.time_offset) - remote_msg_time + + if time_delta > MSG_TOO_OLD_DELTA: + self._log.warning('Server sent a very old message with ID %d, ignoring (see FAQ for details)', remote_msg_id) + self._count_ignored() + return None + + if -time_delta > MSG_TOO_NEW_DELTA: + self._log.warning('Server sent a very new message with ID %d, ignoring (see FAQ for details)', remote_msg_id) + self._count_ignored() + return None + + self._recent_remote_ids.append(remote_msg_id) + self._highest_remote_id = remote_msg_id + self._ignore_count = 0 + + return TLMessage(remote_msg_id, remote_sequence, obj) + + def _count_ignored(self): + # It's possible that ignoring a message "bricks" the connection, + # but this should not happen unless there's something else wrong. + self._ignore_count += 1 + if self._ignore_count >= MAX_CONSECUTIVE_IGNORED: + raise SecurityError('Too many messages had to be ignored consecutively') + + def _get_new_msg_id(self): + """ + Generates a new unique message ID based on the current + time (in ms) since epoch, applying a known time offset. + """ + now = time.time() + self.time_offset + nanoseconds = int((now - int(now)) * 1e+9) + new_msg_id = (int(now) << 32) | (nanoseconds << 2) + + if self._last_msg_id >= new_msg_id: + new_msg_id = self._last_msg_id + 4 + + self._last_msg_id = new_msg_id + return new_msg_id + + def update_time_offset(self, correct_msg_id): + """ + Updates the time offset to the correct + one given a known valid message ID. + """ + bad = self._get_new_msg_id() + old = self.time_offset + + now = int(time.time()) + correct = correct_msg_id >> 32 + self.time_offset = correct - now + + if self.time_offset != old: + self._last_msg_id = 0 + self._log.debug( + 'Updated time offset (old offset %d, bad %d, good %d, new %d)', + old, bad, correct_msg_id, self.time_offset + ) + + return self.time_offset + + def _get_seq_no(self, content_related): + """ + Generates the next sequence number depending on whether + it should be for a content-related query or not. + """ + if content_related: + result = self._sequence * 2 + 1 + self._sequence += 1 + return result + else: + return self._sequence * 2 diff --git a/module/scripts/lib/telethon/network/requeststate.py b/module/scripts/lib/telethon/network/requeststate.py new file mode 100644 index 0000000..21b5efd --- /dev/null +++ b/module/scripts/lib/telethon/network/requeststate.py @@ -0,0 +1,19 @@ +import asyncio + + +class RequestState: + """ + This request state holds several information relevant to sent messages, + in particular the message ID assigned to the request, the container ID + it belongs to, the request itself, the request as bytes, and the future + result that will eventually be resolved. + """ + __slots__ = ('container_id', 'msg_id', 'request', 'data', 'future', 'after') + + def __init__(self, request, after=None): + self.container_id = None + self.msg_id = None + self.request = request + self.data = bytes(request) + self.future = asyncio.Future() + self.after = after diff --git a/module/scripts/lib/telethon/password.py b/module/scripts/lib/telethon/password.py new file mode 100644 index 0000000..0f95025 --- /dev/null +++ b/module/scripts/lib/telethon/password.py @@ -0,0 +1,194 @@ +import hashlib +import os + +from .crypto import factorization +from .tl import types + + +def check_prime_and_good_check(prime: int, g: int): + good_prime_bits_count = 2048 + if prime < 0 or prime.bit_length() != good_prime_bits_count: + raise ValueError('bad prime count {}, expected {}' + .format(prime.bit_length(), good_prime_bits_count)) + + # TODO This is awfully slow + if factorization.Factorization.factorize(prime)[0] != 1: + raise ValueError('given "prime" is not prime') + + if g == 2: + if prime % 8 != 7: + raise ValueError('bad g {}, mod8 {}'.format(g, prime % 8)) + elif g == 3: + if prime % 3 != 2: + raise ValueError('bad g {}, mod3 {}'.format(g, prime % 3)) + elif g == 4: + pass + elif g == 5: + if prime % 5 not in (1, 4): + raise ValueError('bad g {}, mod5 {}'.format(g, prime % 5)) + elif g == 6: + if prime % 24 not in (19, 23): + raise ValueError('bad g {}, mod24 {}'.format(g, prime % 24)) + elif g == 7: + if prime % 7 not in (3, 5, 6): + raise ValueError('bad g {}, mod7 {}'.format(g, prime % 7)) + else: + raise ValueError('bad g {}'.format(g)) + + prime_sub1_div2 = (prime - 1) // 2 + if factorization.Factorization.factorize(prime_sub1_div2)[0] != 1: + raise ValueError('(prime - 1) // 2 is not prime') + + # Else it's good + + +def check_prime_and_good(prime_bytes: bytes, g: int): + good_prime = bytes(( + 0xC7, 0x1C, 0xAE, 0xB9, 0xC6, 0xB1, 0xC9, 0x04, 0x8E, 0x6C, 0x52, 0x2F, 0x70, 0xF1, 0x3F, 0x73, + 0x98, 0x0D, 0x40, 0x23, 0x8E, 0x3E, 0x21, 0xC1, 0x49, 0x34, 0xD0, 0x37, 0x56, 0x3D, 0x93, 0x0F, + 0x48, 0x19, 0x8A, 0x0A, 0xA7, 0xC1, 0x40, 0x58, 0x22, 0x94, 0x93, 0xD2, 0x25, 0x30, 0xF4, 0xDB, + 0xFA, 0x33, 0x6F, 0x6E, 0x0A, 0xC9, 0x25, 0x13, 0x95, 0x43, 0xAE, 0xD4, 0x4C, 0xCE, 0x7C, 0x37, + 0x20, 0xFD, 0x51, 0xF6, 0x94, 0x58, 0x70, 0x5A, 0xC6, 0x8C, 0xD4, 0xFE, 0x6B, 0x6B, 0x13, 0xAB, + 0xDC, 0x97, 0x46, 0x51, 0x29, 0x69, 0x32, 0x84, 0x54, 0xF1, 0x8F, 0xAF, 0x8C, 0x59, 0x5F, 0x64, + 0x24, 0x77, 0xFE, 0x96, 0xBB, 0x2A, 0x94, 0x1D, 0x5B, 0xCD, 0x1D, 0x4A, 0xC8, 0xCC, 0x49, 0x88, + 0x07, 0x08, 0xFA, 0x9B, 0x37, 0x8E, 0x3C, 0x4F, 0x3A, 0x90, 0x60, 0xBE, 0xE6, 0x7C, 0xF9, 0xA4, + 0xA4, 0xA6, 0x95, 0x81, 0x10, 0x51, 0x90, 0x7E, 0x16, 0x27, 0x53, 0xB5, 0x6B, 0x0F, 0x6B, 0x41, + 0x0D, 0xBA, 0x74, 0xD8, 0xA8, 0x4B, 0x2A, 0x14, 0xB3, 0x14, 0x4E, 0x0E, 0xF1, 0x28, 0x47, 0x54, + 0xFD, 0x17, 0xED, 0x95, 0x0D, 0x59, 0x65, 0xB4, 0xB9, 0xDD, 0x46, 0x58, 0x2D, 0xB1, 0x17, 0x8D, + 0x16, 0x9C, 0x6B, 0xC4, 0x65, 0xB0, 0xD6, 0xFF, 0x9C, 0xA3, 0x92, 0x8F, 0xEF, 0x5B, 0x9A, 0xE4, + 0xE4, 0x18, 0xFC, 0x15, 0xE8, 0x3E, 0xBE, 0xA0, 0xF8, 0x7F, 0xA9, 0xFF, 0x5E, 0xED, 0x70, 0x05, + 0x0D, 0xED, 0x28, 0x49, 0xF4, 0x7B, 0xF9, 0x59, 0xD9, 0x56, 0x85, 0x0C, 0xE9, 0x29, 0x85, 0x1F, + 0x0D, 0x81, 0x15, 0xF6, 0x35, 0xB1, 0x05, 0xEE, 0x2E, 0x4E, 0x15, 0xD0, 0x4B, 0x24, 0x54, 0xBF, + 0x6F, 0x4F, 0xAD, 0xF0, 0x34, 0xB1, 0x04, 0x03, 0x11, 0x9C, 0xD8, 0xE3, 0xB9, 0x2F, 0xCC, 0x5B)) + + if good_prime == prime_bytes: + if g in (3, 4, 5, 7): + return # It's good + + check_prime_and_good_check(int.from_bytes(prime_bytes, 'big'), g) + + +def is_good_large(number: int, p: int) -> bool: + return number > 0 and p - number > 0 + + +SIZE_FOR_HASH = 256 + + +def num_bytes_for_hash(number: bytes) -> bytes: + return bytes(SIZE_FOR_HASH - len(number)) + number + + +def big_num_for_hash(g: int) -> bytes: + return g.to_bytes(SIZE_FOR_HASH, 'big') + + +def sha256(*p: bytes) -> bytes: + hash = hashlib.sha256() + for q in p: + hash.update(q) + return hash.digest() + + +def is_good_mod_exp_first(modexp, prime) -> bool: + diff = prime - modexp + min_diff_bits_count = 2048 - 64 + max_mod_exp_size = 256 + if diff < 0 or \ + diff.bit_length() < min_diff_bits_count or \ + modexp.bit_length() < min_diff_bits_count or \ + (modexp.bit_length() + 7) // 8 > max_mod_exp_size: + return False + return True + + +def xor(a: bytes, b: bytes) -> bytes: + return bytes(x ^ y for x, y in zip(a, b)) + + +def pbkdf2sha512(password: bytes, salt: bytes, iterations: int): + return hashlib.pbkdf2_hmac('sha512', password, salt, iterations) + + +def compute_hash(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, + password: str): + hash1 = sha256(algo.salt1, password.encode('utf-8'), algo.salt1) + hash2 = sha256(algo.salt2, hash1, algo.salt2) + hash3 = pbkdf2sha512(hash2, algo.salt1, 100000) + return sha256(algo.salt2, hash3, algo.salt2) + + +def compute_digest(algo: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, + password: str): + try: + check_prime_and_good(algo.p, algo.g) + except ValueError: + raise ValueError('bad p/g in password') + + value = pow(algo.g, + int.from_bytes(compute_hash(algo, password), 'big'), + int.from_bytes(algo.p, 'big')) + + return big_num_for_hash(value) + + +# https://github.com/telegramdesktop/tdesktop/blob/18b74b90451a7db2379a9d753c9cbaf8734b4d5d/Telegram/SourceFiles/core/core_cloud_password.cpp +def compute_check(request: types.account.Password, password: str): + algo = request.current_algo + if not isinstance(algo, types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow): + raise ValueError('unsupported password algorithm {}' + .format(algo.__class__.__name__)) + + pw_hash = compute_hash(algo, password) + + p = int.from_bytes(algo.p, 'big') + g = algo.g + B = int.from_bytes(request.srp_B, 'big') + try: + check_prime_and_good(algo.p, g) + except ValueError: + raise ValueError('bad p/g in password') + + if not is_good_large(B, p): + raise ValueError('bad b in check') + + x = int.from_bytes(pw_hash, 'big') + p_for_hash = num_bytes_for_hash(algo.p) + g_for_hash = big_num_for_hash(g) + b_for_hash = num_bytes_for_hash(request.srp_B) + g_x = pow(g, x, p) + k = int.from_bytes(sha256(p_for_hash, g_for_hash), 'big') + kg_x = (k * g_x) % p + + def generate_and_check_random(): + random_size = 256 + while True: + random = os.urandom(random_size) + a = int.from_bytes(random, 'big') + A = pow(g, a, p) + if is_good_mod_exp_first(A, p): + a_for_hash = big_num_for_hash(A) + u = int.from_bytes(sha256(a_for_hash, b_for_hash), 'big') + if u > 0: + return (a, a_for_hash, u) + + a, a_for_hash, u = generate_and_check_random() + g_b = (B - kg_x) % p + if not is_good_mod_exp_first(g_b, p): + raise ValueError('bad g_b') + + ux = u * x + a_ux = a + ux + S = pow(g_b, a_ux, p) + K = sha256(big_num_for_hash(S)) + M1 = sha256( + xor(sha256(p_for_hash), sha256(g_for_hash)), + sha256(algo.salt1), + sha256(algo.salt2), + a_for_hash, + b_for_hash, + K + ) + + return types.InputCheckPasswordSRP( + request.srp_id, bytes(a_for_hash), bytes(M1)) diff --git a/module/scripts/lib/telethon/requestiter.py b/module/scripts/lib/telethon/requestiter.py new file mode 100644 index 0000000..fd28419 --- /dev/null +++ b/module/scripts/lib/telethon/requestiter.py @@ -0,0 +1,134 @@ +import abc +import asyncio +import time + +from . import helpers + + +class RequestIter(abc.ABC): + """ + Helper class to deal with requests that need offsets to iterate. + + It has some facilities, such as automatically sleeping a desired + amount of time between requests if needed (but not more). + + Can be used synchronously if the event loop is not running and + as an asynchronous iterator otherwise. + + `limit` is the total amount of items that the iterator should return. + This is handled on this base class, and will be always ``>= 0``. + + `left` will be reset every time the iterator is used and will indicate + the amount of items that should be emitted left, so that subclasses can + be more efficient and fetch only as many items as they need. + + Iterators may be used with ``reversed``, and their `reverse` flag will + be set to `True` if that's the case. Note that if this flag is set, + `buffer` should be filled in reverse too. + """ + def __init__(self, client, limit, *, reverse=False, wait_time=None, **kwargs): + self.client = client + self.reverse = reverse + self.wait_time = wait_time + self.kwargs = kwargs + self.limit = max(float('inf') if limit is None else limit, 0) + self.left = self.limit + self.buffer = None + self.index = 0 + self.total = None + self.last_load = 0 + + async def _init(self, **kwargs): + """ + Called when asynchronous initialization is necessary. All keyword + arguments passed to `__init__` will be forwarded here, and it's + preferable to use named arguments in the subclasses without defaults + to avoid forgetting or misspelling any of them. + + This method may ``raise StopAsyncIteration`` if it cannot continue. + + This method may actually fill the initial buffer if it needs to, + and similarly to `_load_next_chunk`, ``return True`` to indicate + that this is the last iteration (just the initial load). + """ + + async def __anext__(self): + if self.buffer is None: + self.buffer = [] + if await self._init(**self.kwargs): + self.left = len(self.buffer) + + if self.left <= 0: # <= 0 because subclasses may change it + raise StopAsyncIteration + + if self.index == len(self.buffer): + # asyncio will handle times <= 0 to sleep 0 seconds + if self.wait_time: + await asyncio.sleep( + self.wait_time - (time.time() - self.last_load) + ) + self.last_load = time.time() + + self.index = 0 + self.buffer = [] + if await self._load_next_chunk(): + self.left = len(self.buffer) + + if not self.buffer: + raise StopAsyncIteration + + result = self.buffer[self.index] + self.left -= 1 + self.index += 1 + return result + + def __next__(self): + try: + return self.client.loop.run_until_complete(self.__anext__()) + except StopAsyncIteration: + raise StopIteration + + def __aiter__(self): + self.buffer = None + self.index = 0 + self.last_load = 0 + self.left = self.limit + return self + + def __iter__(self): + if self.client.loop.is_running(): + raise RuntimeError( + 'You must use "async for" if the event loop ' + 'is running (i.e. you are inside an "async def")' + ) + + return self.__aiter__() + + async def collect(self): + """ + Create a `self` iterator and collect it into a `TotalList` + (a normal list with a `.total` attribute). + """ + result = helpers.TotalList() + async for message in self: + result.append(message) + + result.total = self.total + return result + + @abc.abstractmethod + async def _load_next_chunk(self): + """ + Called when the next chunk is necessary. + + It should extend the `buffer` with new items. + + It should return `True` if it's the last chunk, + after which moment the method won't be called again + during the same iteration. + """ + raise NotImplementedError + + def __reversed__(self): + self.reverse = not self.reverse + return self # __aiter__ will be called after, too diff --git a/module/scripts/lib/telethon/sessions/__init__.py b/module/scripts/lib/telethon/sessions/__init__.py new file mode 100644 index 0000000..d10c76e --- /dev/null +++ b/module/scripts/lib/telethon/sessions/__init__.py @@ -0,0 +1,4 @@ +from .abstract import Session +from .memory import MemorySession +from .sqlite import SQLiteSession +from .string import StringSession diff --git a/module/scripts/lib/telethon/sessions/abstract.py b/module/scripts/lib/telethon/sessions/abstract.py new file mode 100644 index 0000000..11cc63a --- /dev/null +++ b/module/scripts/lib/telethon/sessions/abstract.py @@ -0,0 +1,172 @@ +from abc import ABC, abstractmethod + + +class Session(ABC): + def __init__(self): + pass + + def clone(self, to_instance=None): + """ + Creates a clone of this session file. + """ + return to_instance or self.__class__() + + @abstractmethod + def set_dc(self, dc_id, server_address, port): + """ + Sets the information of the data center address and port that + the library should connect to, as well as the data center ID, + which is currently unused. + """ + raise NotImplementedError + + @property + @abstractmethod + def dc_id(self): + """ + Returns the currently-used data center ID. + """ + raise NotImplementedError + + @property + @abstractmethod + def server_address(self): + """ + Returns the server address where the library should connect to. + """ + raise NotImplementedError + + @property + @abstractmethod + def port(self): + """ + Returns the port to which the library should connect to. + """ + raise NotImplementedError + + @property + @abstractmethod + def auth_key(self): + """ + Returns an ``AuthKey`` instance associated with the saved + data center, or `None` if a new one should be generated. + """ + raise NotImplementedError + + @auth_key.setter + @abstractmethod + def auth_key(self, value): + """ + Sets the ``AuthKey`` to be used for the saved data center. + """ + raise NotImplementedError + + @property + @abstractmethod + def takeout_id(self): + """ + Returns an ID of the takeout process initialized for this session, + or `None` if there's no were any unfinished takeout requests. + """ + raise NotImplementedError + + @takeout_id.setter + @abstractmethod + def takeout_id(self, value): + """ + Sets the ID of the unfinished takeout process for this session. + """ + raise NotImplementedError + + @abstractmethod + def get_update_state(self, entity_id): + """ + Returns the ``UpdateState`` associated with the given `entity_id`. + If the `entity_id` is 0, it should return the ``UpdateState`` for + no specific channel (the "general" state). If no state is known + it should ``return None``. + """ + raise NotImplementedError + + @abstractmethod + def set_update_state(self, entity_id, state): + """ + Sets the given ``UpdateState`` for the specified `entity_id`, which + should be 0 if the ``UpdateState`` is the "general" state (and not + for any specific channel). + """ + raise NotImplementedError + + @abstractmethod + def get_update_states(self): + """ + Returns an iterable over all known pairs of ``(entity ID, update state)``. + """ + + def close(self): + """ + Called on client disconnection. Should be used to + free any used resources. Can be left empty if none. + """ + + @abstractmethod + def save(self): + """ + Called whenever important properties change. It should + make persist the relevant session information to disk. + """ + raise NotImplementedError + + @abstractmethod + def delete(self): + """ + Called upon client.log_out(). Should delete the stored + information from disk since it's not valid anymore. + """ + raise NotImplementedError + + @classmethod + def list_sessions(cls): + """ + Lists available sessions. Not used by the library itself. + """ + return [] + + @abstractmethod + def process_entities(self, tlo): + """ + Processes the input ``TLObject`` or ``list`` and saves + whatever information is relevant (e.g., ID or access hash). + """ + raise NotImplementedError + + @abstractmethod + def get_input_entity(self, key): + """ + Turns the given key into an ``InputPeer`` (e.g. ``InputPeerUser``). + The library uses this method whenever an ``InputPeer`` is needed + to suit several purposes (e.g. user only provided its ID or wishes + to use a cached username to avoid extra RPC). + """ + raise NotImplementedError + + @abstractmethod + def cache_file(self, md5_digest, file_size, instance): + """ + Caches the given file information persistently, so that it + doesn't need to be re-uploaded in case the file is used again. + + The ``instance`` will be either an ``InputPhoto`` or ``InputDocument``, + both with an ``.id`` and ``.access_hash`` attributes. + """ + raise NotImplementedError + + @abstractmethod + def get_file(self, md5_digest, file_size, cls): + """ + Returns an instance of ``cls`` if the ``md5_digest`` and ``file_size`` + match an existing saved record. The class will either be an + ``InputPhoto`` or ``InputDocument``, both with two parameters + ``id`` and ``access_hash`` in that order. + """ + raise NotImplementedError diff --git a/module/scripts/lib/telethon/sessions/memory.py b/module/scripts/lib/telethon/sessions/memory.py new file mode 100644 index 0000000..b72116c --- /dev/null +++ b/module/scripts/lib/telethon/sessions/memory.py @@ -0,0 +1,260 @@ +from enum import Enum + +from .abstract import Session +from .. import utils +from ..tl import TLObject +from ..tl.types import ( + PeerUser, PeerChat, PeerChannel, + InputPeerUser, InputPeerChat, InputPeerChannel, + InputPhoto, InputDocument +) + + +class _SentFileType(Enum): + DOCUMENT = 0 + PHOTO = 1 + + @staticmethod + def from_type(cls): + if cls == InputDocument: + return _SentFileType.DOCUMENT + elif cls == InputPhoto: + return _SentFileType.PHOTO + else: + raise ValueError('The cls must be either InputDocument/InputPhoto') + + +class MemorySession(Session): + def __init__(self): + super().__init__() + + self._dc_id = 0 + self._server_address = None + self._port = None + self._auth_key = None + self._tmp_auth_key = None + self._takeout_id = None + + self._files = {} + self._entities = set() + self._update_states = {} + + def set_dc(self, dc_id, server_address, port): + self._dc_id = dc_id or 0 + self._server_address = server_address + self._port = port + + @property + def dc_id(self): + return self._dc_id + + @property + def server_address(self): + return self._server_address + + @property + def port(self): + return self._port + + @property + def auth_key(self): + return self._auth_key + + @property + def tmp_auth_key(self): + return self._tmp_auth_key + + @auth_key.setter + def auth_key(self, value): + self._auth_key = value + + @auth_key.setter + def tmp_auth_key(self, value): + self._tmp_auth_key = value + + @property + def takeout_id(self): + return self._takeout_id + + @takeout_id.setter + def takeout_id(self, value): + self._takeout_id = value + + def get_update_state(self, entity_id): + return self._update_states.get(entity_id, None) + + def set_update_state(self, entity_id, state): + self._update_states[entity_id] = state + + def get_update_states(self): + return self._update_states.items() + + def close(self): + pass + + def save(self): + pass + + def delete(self): + pass + + @staticmethod + def _entity_values_to_row(id, hash, username, phone, name): + # While this is a simple implementation it might be overrode by, + # other classes so they don't need to implement the plural form + # of the method. Don't remove. + return id, hash, username, phone, name + + def _entity_to_row(self, e): + if not isinstance(e, TLObject): + return + try: + p = utils.get_input_peer(e, allow_self=False) + marked_id = utils.get_peer_id(p) + except TypeError: + # Note: `get_input_peer` already checks for non-zero `access_hash`. + # See issues #354 and #392. It also checks that the entity + # is not `min`, because its `access_hash` cannot be used + # anywhere (since layer 102, there are two access hashes). + return + + if isinstance(p, (InputPeerUser, InputPeerChannel)): + p_hash = p.access_hash + elif isinstance(p, InputPeerChat): + p_hash = 0 + else: + return + + username = getattr(e, 'username', None) or None + if username is not None: + username = username.lower() + phone = getattr(e, 'phone', None) + name = utils.get_display_name(e) or None + return self._entity_values_to_row( + marked_id, p_hash, username, phone, name + ) + + def _entities_to_rows(self, tlo): + if not isinstance(tlo, TLObject) and utils.is_list_like(tlo): + # This may be a list of users already for instance + entities = tlo + else: + entities = [] + if hasattr(tlo, 'user'): + entities.append(tlo.user) + if hasattr(tlo, 'chat'): + entities.append(tlo.chat) + if hasattr(tlo, 'chats') and utils.is_list_like(tlo.chats): + entities.extend(tlo.chats) + if hasattr(tlo, 'users') and utils.is_list_like(tlo.users): + entities.extend(tlo.users) + + rows = [] # Rows to add (id, hash, username, phone, name) + for e in entities: + row = self._entity_to_row(e) + if row: + rows.append(row) + return rows + + def process_entities(self, tlo): + self._entities |= set(self._entities_to_rows(tlo)) + + def get_entity_rows_by_phone(self, phone): + try: + return next((id, hash) for id, hash, _, found_phone, _ + in self._entities if found_phone == phone) + except StopIteration: + pass + + def get_entity_rows_by_username(self, username): + try: + return next((id, hash) for id, hash, found_username, _, _ + in self._entities if found_username == username) + except StopIteration: + pass + + def get_entity_rows_by_name(self, name): + try: + return next((id, hash) for id, hash, _, _, found_name + in self._entities if found_name == name) + except StopIteration: + pass + + def get_entity_rows_by_id(self, id, exact=True): + try: + if exact: + return next((found_id, hash) for found_id, hash, _, _, _ + in self._entities if found_id == id) + else: + ids = ( + utils.get_peer_id(PeerUser(id)), + utils.get_peer_id(PeerChat(id)), + utils.get_peer_id(PeerChannel(id)) + ) + return next((found_id, hash) for found_id, hash, _, _, _ + in self._entities if found_id in ids) + except StopIteration: + pass + + def get_input_entity(self, key): + try: + if key.SUBCLASS_OF_ID in (0xc91c90b6, 0xe669bf46, 0x40f202fd): + # hex(crc32(b'InputPeer', b'InputUser' and b'InputChannel')) + # We already have an Input version, so nothing else required + return key + # Try to early return if this key can be casted as input peer + return utils.get_input_peer(key) + except (AttributeError, TypeError): + # Not a TLObject or can't be cast into InputPeer + if isinstance(key, TLObject): + key = utils.get_peer_id(key) + exact = True + else: + exact = not isinstance(key, int) or key < 0 + + result = None + if isinstance(key, str): + phone = utils.parse_phone(key) + if phone: + result = self.get_entity_rows_by_phone(phone) + else: + username, invite = utils.parse_username(key) + if username and not invite: + result = self.get_entity_rows_by_username(username) + else: + tup = utils.resolve_invite_link(key)[1] + if tup: + result = self.get_entity_rows_by_id(tup, exact=False) + + elif isinstance(key, int): + result = self.get_entity_rows_by_id(key, exact) + + if not result and isinstance(key, str): + result = self.get_entity_rows_by_name(key) + + if result: + entity_id, entity_hash = result # unpack resulting tuple + entity_id, kind = utils.resolve_id(entity_id) + # removes the mark and returns type of entity + if kind == PeerUser: + return InputPeerUser(entity_id, entity_hash) + elif kind == PeerChat: + return InputPeerChat(entity_id) + elif kind == PeerChannel: + return InputPeerChannel(entity_id, entity_hash) + else: + raise ValueError('Could not find input entity with key ', key) + + def cache_file(self, md5_digest, file_size, instance): + if not isinstance(instance, (InputDocument, InputPhoto)): + raise TypeError('Cannot cache %s instance' % type(instance)) + key = (md5_digest, file_size, _SentFileType.from_type(type(instance))) + value = (instance.id, instance.access_hash) + self._files[key] = value + + def get_file(self, md5_digest, file_size, cls): + key = (md5_digest, file_size, _SentFileType.from_type(cls)) + try: + return cls(*self._files[key]) + except KeyError: + return None diff --git a/module/scripts/lib/telethon/sessions/sqlite.py b/module/scripts/lib/telethon/sessions/sqlite.py new file mode 100644 index 0000000..4231fb4 --- /dev/null +++ b/module/scripts/lib/telethon/sessions/sqlite.py @@ -0,0 +1,385 @@ +import datetime +import os +import time + +from ..tl import types +from .memory import MemorySession, _SentFileType +from .. import utils +from ..crypto import AuthKey +from ..tl.types import ( + InputPhoto, InputDocument, PeerUser, PeerChat, PeerChannel +) + +try: + import sqlite3 + sqlite3_err = None +except ImportError as e: + sqlite3 = None + sqlite3_err = type(e) + +EXTENSION = '.session' +CURRENT_VERSION = 8 # database version + + +class SQLiteSession(MemorySession): + """This session contains the required information to login into your + Telegram account. NEVER give the saved session file to anyone, since + they would gain instant access to all your messages and contacts. + + If you think the session has been compromised, close all the sessions + through an official Telegram client to revoke the authorization. + """ + + def __init__(self, session_id=None, store_tmp_auth_key_on_disk:bool=False): + if sqlite3 is None: + raise sqlite3_err + + super().__init__() + self.filename = ':memory:' + self.save_entities = True + self.store_tmp_auth_key_on_disk = store_tmp_auth_key_on_disk + + if session_id: + self.filename = session_id + if not self.filename.endswith(EXTENSION): + self.filename += EXTENSION + + self._conn = None + c = self._cursor() + c.execute("select name from sqlite_master " + "where type='table' and name='version'") + if c.fetchone(): + # Tables already exist, check for the version + c.execute("select version from version") + version = c.fetchone()[0] + if version < CURRENT_VERSION: + self._upgrade_database(old=version) + c.execute("delete from version") + c.execute("insert into version values (?)", (CURRENT_VERSION,)) + self.save() + + # These values will be saved + c.execute('select * from sessions') + tuple_ = c.fetchone() + if tuple_: + self._dc_id, self._server_address, self._port, key, self._takeout_id, \ + tmp_key = tuple_ + self._auth_key = AuthKey(data=key) + self._tmp_auth_key = AuthKey(data=tmp_key) + + c.close() + else: + # Tables don't exist, create new ones + self._create_table( + c, + "version (version integer primary key)" + , + """sessions ( + dc_id integer primary key, + server_address text, + port integer, + auth_key blob, + takeout_id integer, + tmp_auth_key blob + )""" + , + """entities ( + id integer primary key, + hash integer not null, + username text, + phone integer, + name text, + date integer + )""" + , + """sent_files ( + md5_digest blob, + file_size integer, + type integer, + id integer, + hash integer, + primary key(md5_digest, file_size, type) + )""" + , + """update_state ( + id integer primary key, + pts integer, + qts integer, + date integer, + seq integer + )""" + ) + c.execute("insert into version values (?)", (CURRENT_VERSION,)) + self._update_session_table() + c.close() + self.save() + + def clone(self, to_instance=None): + cloned = super().clone(to_instance) + cloned.save_entities = self.save_entities + return cloned + + def _upgrade_database(self, old): + c = self._cursor() + if old == 1: + old += 1 + # old == 1 doesn't have the old sent_files so no need to drop + if old == 2: + old += 1 + # Old cache from old sent_files lasts then a day anyway, drop + c.execute('drop table sent_files') + self._create_table(c, """sent_files ( + md5_digest blob, + file_size integer, + type integer, + id integer, + hash integer, + primary key(md5_digest, file_size, type) + )""") + if old == 3: + old += 1 + self._create_table(c, """update_state ( + id integer primary key, + pts integer, + qts integer, + date integer, + seq integer + )""") + if old == 4: + old += 1 + c.execute("alter table sessions add column takeout_id integer") + if old == 5: + # Not really any schema upgrade, but potentially all access + # hashes for User and Channel are wrong, so drop them off. + old += 1 + c.execute('delete from entities') + if old == 6: + old += 1 + c.execute("alter table entities add column date integer") + if old == 7: + old += 1 + c.execute("alter table sessions add column tmp_auth_key blob") + + c.close() + + @staticmethod + def _create_table(c, *definitions): + for definition in definitions: + c.execute('create table {}'.format(definition)) + + # Data from sessions should be kept as properties + # not to fetch the database every time we need it + def set_dc(self, dc_id, server_address, port): + super().set_dc(dc_id, server_address, port) + self._update_session_table() + + # Fetch the auth_key corresponding to this data center + row = self._execute('select auth_key, tmp_auth_key from sessions') + if row and row[0]: + self._auth_key = AuthKey(data=row[0]) + else: + self._auth_key = None + + if row and row[1]: + self._tmp_auth_key = AuthKey(data=row[1]) + else: + self._tmp_auth_key = None + + @MemorySession.auth_key.setter + def auth_key(self, value): + self._auth_key = value + self._update_session_table() + + @MemorySession.tmp_auth_key.setter + def tmp_auth_key(self, value): + self._tmp_auth_key = value + self._update_session_table() + + @MemorySession.takeout_id.setter + def takeout_id(self, value): + self._takeout_id = value + self._update_session_table() + + def _update_session_table(self): + c = self._cursor() + # While we can save multiple rows into the sessions table + # currently we only want to keep ONE as the tables don't + # tell us which auth_key's are usable and will work. Needs + # some more work before being able to save auth_key's for + # multiple DCs. Probably done differently. + c.execute('delete from sessions') + c.execute('insert or replace into sessions values (?,?,?,?,?,?)', ( + self._dc_id, + self._server_address, + self._port, + self._auth_key.key if self._auth_key else b'', + self._takeout_id, + self._tmp_auth_key.key if (self.store_tmp_auth_key_on_disk and self._tmp_auth_key) else b'' + )) + c.close() + + def get_update_state(self, entity_id): + row = self._execute('select pts, qts, date, seq from update_state ' + 'where id = ?', entity_id) + if row: + pts, qts, date, seq = row + date = datetime.datetime.fromtimestamp( + date, tz=datetime.timezone.utc) + return types.updates.State(pts, qts, date, seq, unread_count=0) + + def set_update_state(self, entity_id, state): + self._execute('insert or replace into update_state values (?,?,?,?,?)', + entity_id, state.pts, state.qts, + state.date.timestamp(), state.seq) + + def get_update_states(self): + c = self._cursor() + try: + rows = c.execute('select id, pts, qts, date, seq from update_state').fetchall() + return ((row[0], types.updates.State( + pts=row[1], + qts=row[2], + date=datetime.datetime.fromtimestamp(row[3], tz=datetime.timezone.utc), + seq=row[4], + unread_count=0) + ) for row in rows) + finally: + c.close() + + def save(self): + """Saves the current session object as session_user_id.session""" + # This is a no-op if there are no changes to commit, so there's + # no need for us to keep track of an "unsaved changes" variable. + if self._conn is not None: + self._conn.commit() + + def _cursor(self): + """Asserts that the connection is open and returns a cursor""" + if self._conn is None: + self._conn = sqlite3.connect(self.filename, + check_same_thread=False) + return self._conn.cursor() + + def _execute(self, stmt, *values): + """ + Gets a cursor, executes `stmt` and closes the cursor, + fetching one row afterwards and returning its result. + """ + c = self._cursor() + try: + return c.execute(stmt, values).fetchone() + finally: + c.close() + + def close(self): + """Closes the connection unless we're working in-memory""" + if self.filename != ':memory:': + if self._conn is not None: + self._conn.commit() + self._conn.close() + self._conn = None + + def delete(self): + """Deletes the current session file""" + if self.filename == ':memory:': + return True + try: + os.remove(self.filename) + return True + except OSError: + return False + + @classmethod + def list_sessions(cls): + """Lists all the sessions of the users who have ever connected + using this client and never logged out + """ + return [os.path.splitext(os.path.basename(f))[0] + for f in os.listdir('.') if f.endswith(EXTENSION)] + + # Entity processing + + def process_entities(self, tlo): + """ + Processes all the found entities on the given TLObject, + unless .save_entities is False. + """ + if not self.save_entities: + return + + rows = self._entities_to_rows(tlo) + if not rows: + return + + c = self._cursor() + try: + now_tup = (int(time.time()),) + rows = [row + now_tup for row in rows] + c.executemany( + 'insert or replace into entities values (?,?,?,?,?,?)', rows) + finally: + c.close() + + def get_entity_rows_by_phone(self, phone): + return self._execute( + 'select id, hash from entities where phone = ?', phone) + + def get_entity_rows_by_username(self, username): + c = self._cursor() + try: + results = c.execute( + 'select id, hash, date from entities where username = ?', + (username,) + ).fetchall() + + if not results: + return None + + # If there is more than one result for the same username, evict the oldest one + if len(results) > 1: + results.sort(key=lambda t: t[2] or 0) + c.executemany('update entities set username = null where id = ?', + [(t[0],) for t in results[:-1]]) + + return results[-1][0], results[-1][1] + finally: + c.close() + + def get_entity_rows_by_name(self, name): + return self._execute( + 'select id, hash from entities where name = ?', name) + + def get_entity_rows_by_id(self, id, exact=True): + if exact: + return self._execute( + 'select id, hash from entities where id = ?', id) + else: + return self._execute( + 'select id, hash from entities where id in (?,?,?)', + utils.get_peer_id(PeerUser(id)), + utils.get_peer_id(PeerChat(id)), + utils.get_peer_id(PeerChannel(id)) + ) + + # File processing + + def get_file(self, md5_digest, file_size, cls): + row = self._execute( + 'select id, hash from sent_files ' + 'where md5_digest = ? and file_size = ? and type = ?', + md5_digest, file_size, _SentFileType.from_type(cls).value + ) + if row: + # Both allowed classes have (id, access_hash) as parameters + return cls(row[0], row[1]) + + def cache_file(self, md5_digest, file_size, instance): + if not isinstance(instance, (InputDocument, InputPhoto)): + raise TypeError('Cannot cache %s instance' % type(instance)) + + self._execute( + 'insert or replace into sent_files values (?,?,?,?,?)', + md5_digest, file_size, + _SentFileType.from_type(type(instance)).value, + instance.id, instance.access_hash + ) diff --git a/module/scripts/lib/telethon/sessions/string.py b/module/scripts/lib/telethon/sessions/string.py new file mode 100644 index 0000000..fb971d8 --- /dev/null +++ b/module/scripts/lib/telethon/sessions/string.py @@ -0,0 +1,63 @@ +import base64 +import ipaddress +import struct + +from .abstract import Session +from .memory import MemorySession +from ..crypto import AuthKey + +_STRUCT_PREFORMAT = '>B{}sH256s' + +CURRENT_VERSION = '1' + + +class StringSession(MemorySession): + """ + This session file can be easily saved and loaded as a string. According + to the initial design, it contains only the data that is necessary for + successful connection and authentication, so takeout ID is not stored. + + It is thought to be used where you don't want to create any on-disk + files but would still like to be able to save and load existing sessions + by other means. + + You can use custom `encode` and `decode` functions, if present: + + * `encode` definition must be ``def encode(value: bytes) -> str:``. + * `decode` definition must be ``def decode(value: str) -> bytes:``. + """ + def __init__(self, string: str = None): + super().__init__() + if string: + if string[0] != CURRENT_VERSION: + raise ValueError('Not a valid string') + + string = string[1:] + ip_len = 4 if len(string) == 352 else 16 + self._dc_id, ip, self._port, key = struct.unpack( + _STRUCT_PREFORMAT.format(ip_len), StringSession.decode(string)) + + self._server_address = ipaddress.ip_address(ip).compressed + if any(key): + self._auth_key = AuthKey(key) + + @staticmethod + def encode(x: bytes) -> str: + return base64.urlsafe_b64encode(x).decode('ascii') + + @staticmethod + def decode(x: str) -> bytes: + return base64.urlsafe_b64decode(x) + + def save(self: Session): + if not self.auth_key: + return '' + + ip = ipaddress.ip_address(self.server_address).packed + return CURRENT_VERSION + StringSession.encode(struct.pack( + _STRUCT_PREFORMAT.format(len(ip)), + self.dc_id, + ip, + self.port, + self.auth_key.key + )) diff --git a/module/scripts/lib/telethon/sync.py b/module/scripts/lib/telethon/sync.py new file mode 100644 index 0000000..f647670 --- /dev/null +++ b/module/scripts/lib/telethon/sync.py @@ -0,0 +1,74 @@ +""" +This magical module will rewrite all public methods in the public interface +of the library so they can run the loop on their own if it's not already +running. This rewrite may not be desirable if the end user always uses the +methods they way they should be ran, but it's incredibly useful for quick +scripts and the runtime overhead is relatively low. + +Some really common methods which are hardly used offer this ability by +default, such as ``.start()`` and ``.run_until_disconnected()`` (since +you may want to start, and then run until disconnected while using async +event handlers). +""" +import asyncio +import functools +import inspect + +from . import events, errors, utils, connection, helpers +from .client.account import _TakeoutClient +from .client.telegramclient import TelegramClient +from .tl import types, functions, custom +from .tl.custom import ( + Draft, Dialog, MessageButton, Forward, Button, + Message, InlineResult, Conversation +) +from .tl.custom.chatgetter import ChatGetter +from .tl.custom.sendergetter import SenderGetter + + +def _syncify_wrap(t, method_name): + method = getattr(t, method_name) + + @functools.wraps(method) + def syncified(*args, **kwargs): + coro = method(*args, **kwargs) + loop = helpers.get_running_loop() + if loop.is_running(): + return coro + else: + return loop.run_until_complete(coro) + + # Save an accessible reference to the original method + setattr(syncified, '__tl.sync', method) + setattr(t, method_name, syncified) + + +def syncify(*types): + """ + Converts all the methods in the given types (class definitions) + into synchronous, which return either the coroutine or the result + based on whether ``asyncio's`` event loop is running. + """ + # Our asynchronous generators all are `RequestIter`, which already + # provide a synchronous iterator variant, so we don't need to worry + # about asyncgenfunction's here. + for t in types: + for name in dir(t): + if not name.startswith('_') or name == '__call__': + if inspect.iscoroutinefunction(getattr(t, name)): + _syncify_wrap(t, name) + + +syncify(TelegramClient, _TakeoutClient, Draft, Dialog, MessageButton, + ChatGetter, SenderGetter, Forward, Message, InlineResult, Conversation) + + +# Private special case, since a conversation's methods return +# futures (but the public function themselves are synchronous). +_syncify_wrap(Conversation, '_get_result') + +__all__ = [ + 'TelegramClient', 'Button', + 'types', 'functions', 'custom', 'errors', + 'events', 'utils', 'connection' +] diff --git a/module/scripts/lib/telethon/tl/__init__.py b/module/scripts/lib/telethon/tl/__init__.py new file mode 100644 index 0000000..e187537 --- /dev/null +++ b/module/scripts/lib/telethon/tl/__init__.py @@ -0,0 +1 @@ +from .tlobject import TLObject, TLRequest diff --git a/module/scripts/lib/telethon/tl/alltlobjects.py b/module/scripts/lib/telethon/tl/alltlobjects.py new file mode 100644 index 0000000..e6ef2ca --- /dev/null +++ b/module/scripts/lib/telethon/tl/alltlobjects.py @@ -0,0 +1,2531 @@ +"""File generated by TLObjects' generator. All changes will be ERASED""" + +from . import types, functions + +LAYER = 229 + +tlobjects = { + 0xf3ed4c73: functions.account.AcceptAuthorizationRequest, + 0x3bd2b4a0: functions.phone.AcceptCallRequest, + 0xf831a20f: functions.contacts.AcceptContactRequest, + 0x3dbc0415: functions.messages.AcceptEncryptionRequest, + 0xe894ad4d: functions.auth.AcceptLoginTokenRequest, + 0xee72f79a: functions.help.AcceptTermsOfServiceRequest, + 0x67a3f0de: functions.messages.AcceptUrlAuthRequest, + 0x4679b65f: types.AccessPointRule, + 0xdd1fbf93: types.bots.AccessSettings, + 0xb8d0afdf: types.AccountDaysTTL, + 0x57bbd166: functions.stories.ActivateStealthModeRequest, + 0xcbc6d107: functions.messages.AddChatUserRequest, + 0xd9ba2e54: functions.contacts.AddContactRequest, + 0x19bc4b6d: functions.messages.AddPollAnswerRequest, + 0x17aeb75a: functions.bots.AddPreviewMediaRequest, + 0x8653febe: functions.stickers.AddStickerToSetRequest, + 0xed8af74d: types.channels.AdminLogResults, + 0xef8d3e6c: types.messages.AffectedFoundMessages, + 0xb45c69d1: types.messages.AffectedHistory, + 0x84d19185: types.messages.AffectedMessages, + 0xcff63ea9: types.AiComposeTone, + 0x9bad6414: types.AiComposeToneDefault, + 0xf1d628ec: types.AiComposeToneExample, + 0xc3987a3a: types.stories.Albums, + 0x564edaeb: types.stories.AlbumsNotModified, + 0xcdbbcebb: types.messages.AllStickers, + 0xe86602c3: types.messages.AllStickersNotModified, + 0x6efc5e81: types.stories.AllStories, + 0x1158fe3e: types.stories.AllStoriesNotModified, + 0xf132e3ef: functions.bots.AllowSendMessageRequest, + 0xe6213f4d: functions.bots.AnswerWebhookJSONQueryRequest, + 0xdd18782e: types.help.AppConfig, + 0x7cde641d: types.help.AppConfigNotModified, + 0xccbbce30: types.help.AppUpdate, + 0x21a61057: functions.messages.AppendTodoListRequest, + 0x6b7da746: functions.premium.ApplyBoostRequest, + 0xf6e26854: functions.payments.ApplyGiftCodeRequest, + 0x4fcba9c8: types.messages.ArchivedStickers, + 0x80ed747d: functions.payments.AssignAppStoreTransactionRequest, + 0xdffd50d3: functions.payments.AssignPlayMarketTransactionRequest, + 0xd90d8dfe: types.AttachMenuBot, + 0xb2a7386b: types.AttachMenuBotIcon, + 0x4576f3f0: types.AttachMenuBotIconColor, + 0x3c4301c0: types.AttachMenuBots, + 0x93bf667f: types.AttachMenuBotsBot, + 0xf1d88a5c: types.AttachMenuBotsNotModified, + 0xc32bfa1a: types.AttachMenuPeerTypeBotPM, + 0x7bfbdefc: types.AttachMenuPeerTypeBroadcast, + 0x0509113f: types.AttachMenuPeerTypeChat, + 0xf146d31f: types.AttachMenuPeerTypePM, + 0x7d6be90e: types.AttachMenuPeerTypeSameBotPM, + 0x310240cc: types.AuctionBidLevel, + 0x2ea2c0d4: types.auth.Authorization, + 0xad01d61d: types.Authorization, + 0xad2e1cd8: types.account.AuthorizationForm, + 0x44747e9a: types.auth.AuthorizationSignUpRequired, + 0x4bff8ea0: types.account.Authorizations, + 0xbaa57628: types.AutoDownloadSettings, + 0x63cacf26: types.account.AutoDownloadSettings, + 0x81602d47: types.AutoSaveException, + 0xc84834ce: types.AutoSaveSettings, + 0x4c3e069d: types.account.AutoSaveSettings, + 0x93c3e27e: types.AvailableEffect, + 0xbddb616e: types.messages.AvailableEffects, + 0xd1ed9a5b: types.messages.AvailableEffectsNotModified, + 0xc077ec01: types.AvailableReaction, + 0x768e3aad: types.messages.AvailableReactions, + 0x9f071957: types.messages.AvailableReactionsNotModified, + 0xa7eff811: types.BadMsgNotification, + 0xedab447b: types.BadServerSalt, + 0x3e24e573: types.payments.BankCardData, + 0xf568028a: types.BankCardOpenUrl, + 0x5b11125a: types.BaseThemeArctic, + 0xc3a12462: types.BaseThemeClassic, + 0xfbd81688: types.BaseThemeDay, + 0xb7b31ea8: types.BaseThemeNight, + 0x6d5f77ee: types.BaseThemeTinted, + 0xcdd42a05: functions.auth.BindTempAuthKeyRequest, + 0x75a3f765: types.BindAuthKeyInner, + 0x6c8e1e06: types.Birthday, + 0x2e2e8734: functions.contacts.BlockRequest, + 0x29a8962c: functions.contacts.BlockFromRepliesRequest, + 0x0ade1591: types.contacts.Blocked, + 0xe1664194: types.contacts.BlockedSlice, + 0x4b3e14d6: types.Boost, + 0x86f8613c: types.premium.BoostsList, + 0x4959427a: types.premium.BoostsStatus, + 0x95fcd1d6: types.BotApp, + 0xeb50adf5: types.messages.BotApp, + 0x5da674b7: types.BotAppNotModified, + 0xc99b1950: types.BotAppSettings, + 0x8f34b2f5: types.BotBusinessConnection, + 0x36585ea4: types.messages.BotCallbackAnswer, + 0x6dfa0622: functions.payments.BotCancelStarsSubscriptionRequest, + 0x9852d6d2: types.BotCommand, + 0xb9aa606a: types.BotCommandScopeChatAdmins, + 0x6fe1a881: types.BotCommandScopeChats, + 0x2f6cb2ab: types.BotCommandScopeDefault, + 0xdb9d897d: types.BotCommandScopePeer, + 0x3fd863d1: types.BotCommandScopePeerAdmins, + 0x0a1321f3: types.BotCommandScopePeerUser, + 0x3c4f04d8: types.BotCommandScopeUsers, + 0x4d8a0299: types.BotInfo, + 0xe8a775b0: types.bots.BotInfo, + 0x17db940b: types.BotInlineMediaResult, + 0x764cf810: types.BotInlineMessageMediaAuto, + 0x18d1cdc2: types.BotInlineMessageMediaContact, + 0x051846fd: types.BotInlineMessageMediaGeo, + 0x354a9b09: types.BotInlineMessageMediaInvoice, + 0x8a86659c: types.BotInlineMessageMediaVenue, + 0x809ad9a6: types.BotInlineMessageMediaWebPage, + 0x0a617e7b: types.BotInlineMessageRichMessage, + 0x8c7f65e2: types.BotInlineMessageText, + 0x11965f3a: types.BotInlineResult, + 0xc7b57ce6: types.BotMenuButton, + 0x4258c205: types.BotMenuButtonCommands, + 0x7533a588: types.BotMenuButtonDefault, + 0x8ecf0511: types.messages.BotPreparedInlineMessage, + 0x23e91ba3: types.BotPreviewMedia, + 0xe021f2f6: types.messages.BotResults, + 0xf93cd45c: types.BotVerification, + 0xb0cd6617: types.BotVerifierSettings, + 0x396ca5fc: types.stats.BroadcastStats, + 0xef156a5c: types.BusinessAwayMessage, + 0xc9b9e2b9: types.BusinessAwayMessageScheduleAlways, + 0xcc4d9ecc: types.BusinessAwayMessageScheduleCustom, + 0xc3f2f501: types.BusinessAwayMessageScheduleOutsideWorkHours, + 0xb88cf373: types.BusinessBotRecipients, + 0xa0624cf7: types.BusinessBotRights, + 0xb4ae666f: types.BusinessChatLink, + 0xec43a2d1: types.account.BusinessChatLinks, + 0xe519abab: types.BusinessGreetingMessage, + 0x5a0a066d: types.BusinessIntro, + 0xac5c1af7: types.BusinessLocation, + 0x21108ff7: types.BusinessRecipients, + 0x120b1ab9: types.BusinessWeeklyOpen, + 0x8c92b098: types.BusinessWorkHours, + 0xc9dd90e9: types.ButtonTypeDefault, + 0x9beee140: types.ButtonTypeRequestGeoLocation, + 0x4f58a237: types.ButtonTypeRequestPeer, + 0xdf3d36f9: types.ButtonTypeRequestPhone, + 0xaacfff84: types.ButtonTypeRequestPoll, + 0xc01a597a: types.ButtonTypeSimpleWebView, + 0x4fdc5ea7: functions.payments.CanPurchaseStoreRequest, + 0x1359f4e6: functions.bots.CanSendMessageRequest, + 0x30eb63f0: functions.stories.CanSendStoryRequest, + 0xc387c04e: types.stories.CanSendStoryCount, + 0x1f040578: functions.auth.CancelCodeRequest, + 0xc1cbd5b6: functions.account.CancelPasswordEmailRequest, + 0x5725e40a: types.CdnConfig, + 0xa99fca4f: types.upload.CdnFile, + 0xeea8e46e: types.upload.CdnFileReuploadNeeded, + 0xc982eaba: types.CdnPublicKey, + 0x40f48462: functions.account.ChangeAuthorizationSettingsRequest, + 0x70c32edb: functions.account.ChangePhoneRequest, + 0xc7770878: functions.payments.ChangeStarsSubscriptionRequest, + 0xf5537ebc: functions.stickers.ChangeStickerRequest, + 0xffb6d4ca: functions.stickers.ChangeStickerPositionRequest, + 0xd49f34c6: types.Channel, + 0x1fad68cd: types.ChannelAdminLogEvent, + 0x55188a2e: types.ChannelAdminLogEventActionChangeAbout, + 0xbe4e0ef8: types.ChannelAdminLogEventActionChangeAvailableReactions, + 0x3ea9feb1: types.ChannelAdminLogEventActionChangeEmojiStatus, + 0x46d840ab: types.ChannelAdminLogEventActionChangeEmojiStickerSet, + 0x6e941a38: types.ChannelAdminLogEventActionChangeHistoryTTL, + 0x050c7ac8: types.ChannelAdminLogEventActionChangeLinkedChat, + 0x0e6b76ae: types.ChannelAdminLogEventActionChangeLocation, + 0x5796e780: types.ChannelAdminLogEventActionChangePeerColor, + 0x434bd2af: types.ChannelAdminLogEventActionChangePhoto, + 0x5e477b25: types.ChannelAdminLogEventActionChangeProfilePeerColor, + 0xb1c3caa7: types.ChannelAdminLogEventActionChangeStickerSet, + 0xe6dfb825: types.ChannelAdminLogEventActionChangeTitle, + 0x6a4afc38: types.ChannelAdminLogEventActionChangeUsername, + 0xf04fb3a9: types.ChannelAdminLogEventActionChangeUsernames, + 0x31bb5d52: types.ChannelAdminLogEventActionChangeWallpaper, + 0x58707d28: types.ChannelAdminLogEventActionCreateTopic, + 0x2df5fc0a: types.ChannelAdminLogEventActionDefaultBannedRights, + 0x42e047bb: types.ChannelAdminLogEventActionDeleteMessage, + 0xae168909: types.ChannelAdminLogEventActionDeleteTopic, + 0xdb9f9140: types.ChannelAdminLogEventActionDiscardGroupCall, + 0x709b2405: types.ChannelAdminLogEventActionEditMessage, + 0xf06fe208: types.ChannelAdminLogEventActionEditTopic, + 0x5a50fca4: types.ChannelAdminLogEventActionExportedInviteDelete, + 0xe90ebb59: types.ChannelAdminLogEventActionExportedInviteEdit, + 0x410a134e: types.ChannelAdminLogEventActionExportedInviteRevoke, + 0x5806b4ec: types.ChannelAdminLogEventActionParticipantEditRank, + 0xe31c34d8: types.ChannelAdminLogEventActionParticipantInvite, + 0x183040d3: types.ChannelAdminLogEventActionParticipantJoin, + 0xfe9fc158: types.ChannelAdminLogEventActionParticipantJoinByInvite, + 0xafb6144a: types.ChannelAdminLogEventActionParticipantJoinByRequest, + 0xf89777f2: types.ChannelAdminLogEventActionParticipantLeave, + 0xf92424d2: types.ChannelAdminLogEventActionParticipantMute, + 0x64642db3: types.ChannelAdminLogEventActionParticipantSubExtend, + 0xd5676710: types.ChannelAdminLogEventActionParticipantToggleAdmin, + 0xe6d83d7e: types.ChannelAdminLogEventActionParticipantToggleBan, + 0xe64429c0: types.ChannelAdminLogEventActionParticipantUnmute, + 0x3e7f6847: types.ChannelAdminLogEventActionParticipantVolume, + 0x5d8d353b: types.ChannelAdminLogEventActionPinTopic, + 0x278f2868: types.ChannelAdminLogEventActionSendMessage, + 0x23209745: types.ChannelAdminLogEventActionStartGroupCall, + 0x8f079643: types.ChannelAdminLogEventActionStopPoll, + 0x64f36dfc: types.ChannelAdminLogEventActionToggleAntiSpam, + 0xc517f77e: types.ChannelAdminLogEventActionToggleAutotranslation, + 0x02cc6383: types.ChannelAdminLogEventActionToggleForum, + 0x56d6a247: types.ChannelAdminLogEventActionToggleGroupCallSetting, + 0x1b7907ae: types.ChannelAdminLogEventActionToggleInvites, + 0xcb2ac766: types.ChannelAdminLogEventActionToggleNoForwards, + 0x5f5c95f1: types.ChannelAdminLogEventActionTogglePreHistoryHidden, + 0x60a79c79: types.ChannelAdminLogEventActionToggleSignatureProfiles, + 0x26ae0971: types.ChannelAdminLogEventActionToggleSignatures, + 0x53909779: types.ChannelAdminLogEventActionToggleSlowMode, + 0xe9e82c18: types.ChannelAdminLogEventActionUpdatePinned, + 0xea107ae4: types.ChannelAdminLogEventsFilter, + 0x2064674e: types.updates.ChannelDifference, + 0x3e11affb: types.updates.ChannelDifferenceEmpty, + 0xa4bcc6fe: types.updates.ChannelDifferenceTooLong, + 0x17d493d5: types.ChannelForbidden, + 0xa04e8d3a: types.ChannelFull, + 0x209b82db: types.ChannelLocation, + 0xbfb5ad8b: types.ChannelLocationEmpty, + 0xc776ba4e: types.messages.ChannelMessages, + 0xcd77d957: types.ChannelMessagesFilter, + 0x94d42ee7: types.ChannelMessagesFilterEmpty, + 0x1bd54456: types.ChannelParticipant, + 0xdfb80317: types.channels.ChannelParticipant, + 0x34c3bb53: types.ChannelParticipantAdmin, + 0xd5f0ad91: types.ChannelParticipantBanned, + 0x2fe601d3: types.ChannelParticipantCreator, + 0x1b03f006: types.ChannelParticipantLeft, + 0xa9478a1a: types.ChannelParticipantSelf, + 0x9ab0feaf: types.channels.ChannelParticipants, + 0xb4608969: types.ChannelParticipantsAdmins, + 0x1427a5e1: types.ChannelParticipantsBanned, + 0xb0d1865b: types.ChannelParticipantsBots, + 0xbb6ae88d: types.ChannelParticipantsContacts, + 0xa3b54985: types.ChannelParticipantsKicked, + 0xe04b5ceb: types.ChannelParticipantsMentions, + 0xf0173fe9: types.channels.ChannelParticipantsNotModified, + 0xde3f3c79: types.ChannelParticipantsRecent, + 0x0656ac4b: types.ChannelParticipantsSearch, + 0x41cbf256: types.Chat, + 0x5fb224d5: types.ChatAdminRights, + 0xf2ecef23: types.ChatAdminWithInvites, + 0xb69b72d7: types.messages.ChatAdminsWithInvites, + 0x9f120418: types.ChatBannedRights, + 0x29562865: types.ChatEmpty, + 0x6592a1a7: types.ChatForbidden, + 0x2633421b: types.ChatFull, + 0xe5d7d19c: types.messages.ChatFull, + 0x5c9d3702: types.ChatInvite, + 0x5a686d7c: types.ChatInviteAlready, + 0xa22cbd96: types.ChatInviteExported, + 0x8c5adfd9: types.ChatInviteImporter, + 0x81b6b00a: types.messages.ChatInviteImporters, + 0x445663a7: types.messages.ChatInviteJoinResultOk, + 0x61ca29d3: types.messages.ChatInviteJoinResultWebView, + 0x61695cb0: types.ChatInvitePeek, + 0xed107ab7: types.ChatInvitePublicJoinRequests, + 0xf041e250: types.ChatOnlines, + 0x38e79fde: types.ChatParticipant, + 0x0360d5d2: types.ChatParticipantAdmin, + 0xe1f867b8: types.ChatParticipantCreator, + 0x3cbc93f8: types.ChatParticipants, + 0x8763d3e1: types.ChatParticipantsForbidden, + 0x1c6e1c11: types.ChatPhoto, + 0x37c1011c: types.ChatPhotoEmpty, + 0x52928bca: types.ChatReactionsAll, + 0xeafc32bc: types.ChatReactionsNone, + 0x661d4037: types.ChatReactionsSome, + 0xc3dffc04: types.ChatTheme, + 0x3458f9c8: types.ChatThemeUniqueGift, + 0xbe098173: types.account.ChatThemes, + 0xe011e1c4: types.account.ChatThemesNotModified, + 0xf10ece2f: types.chatlists.ChatlistInvite, + 0xfa87f659: types.chatlists.ChatlistInviteAlready, + 0x93bd878d: types.chatlists.ChatlistUpdates, + 0x64ff9fd5: types.messages.Chats, + 0x9cd81144: types.messages.ChatsSlice, + 0xc0c4edc9: functions.payments.CheckCanSendGiftRequest, + 0xd5e58274: types.payments.CheckCanSendGiftResultFail, + 0x374fa7ad: types.payments.CheckCanSendGiftResultOk, + 0x3eadb1bb: functions.messages.CheckChatInviteRequest, + 0x41c10fff: functions.chatlists.CheckChatlistInviteRequest, + 0x50077589: functions.bots.CheckDownloadFileParamsRequest, + 0x8e51b4c1: functions.payments.CheckGiftCodeRequest, + 0xb59cf977: functions.phone.CheckGroupCallRequest, + 0x43fe19f3: functions.messages.CheckHistoryImportRequest, + 0x5dc60f03: functions.messages.CheckHistoryImportPeerRequest, + 0x56e59f9c: functions.auth.CheckPaidAuthRequest, + 0xd18b4d16: functions.auth.CheckPasswordRequest, + 0xf1d0fbd3: functions.messages.CheckQuickReplyShortcutRequest, + 0x0d36bf79: functions.auth.CheckRecoveryPasswordRequest, + 0x22567115: functions.channels.CheckSearchPostsFloodRequest, + 0x284b3639: functions.stickers.CheckShortNameRequest, + 0xc9a47b0b: functions.messages.CheckUrlAuthMatchCodeRequest, + 0x2714d86c: functions.account.CheckUsernameRequest, + 0x10e6bd2c: functions.channels.CheckUsernameRequest, + 0x87f2219b: functions.bots.CheckUsernameRequest, + 0xeb983f8f: types.payments.CheckedGiftCode, + 0xa24de717: types.messages.CheckedHistoryImportPeer, + 0x7e58ee9c: functions.messages.ClearAllDraftsRequest, + 0x18201aae: functions.account.ClearRecentEmojiStatusesRequest, + 0x9dfeefb4: functions.messages.ClearRecentReactionsRequest, + 0x8999602d: functions.messages.ClearRecentStickersRequest, + 0xd83d70c1: functions.payments.ClearSavedInfoRequest, + 0x8235057e: functions.messages.ClickSponsoredMessageRequest, + 0x6643b654: types.ClientDHInnerData, + 0xad253d78: types.CodeSettings, + 0x741cd3e3: types.auth.CodeTypeCall, + 0x226ccefb: types.auth.CodeTypeFlashCall, + 0x06ed998c: types.auth.CodeTypeFragmentSms, + 0xd61ad6ee: types.auth.CodeTypeMissedCall, + 0x72a3158c: types.auth.CodeTypeSms, + 0x6ebdff91: types.fragment.CollectibleInfo, + 0x65efe954: types.Community, + 0xfd3cdab8: types.CommunityForbidden, + 0xcbb7a507: types.CommunityFull, + 0x76141ebd: types.CommunityPeer, + 0x7beafa85: types.CommunityPeerRequest, + 0xdaecc589: functions.messages.ComposeMessageWithAIRequest, + 0x8d7ae6af: functions.messages.ComposeRichMessageWithAIRequest, + 0x90d7adfa: types.messages.ComposedMessageWithAI, + 0x4c4537c8: types.messages.ComposedRichMessageWithAI, + 0xcc1a241e: types.Config, + 0x5a592a6c: types.help.ConfigSimple, + 0x67ed1f68: functions.account.ConfirmBotConnectionRequest, + 0x2efe1722: functions.phone.ConfirmCallRequest, + 0x8fdf1920: functions.account.ConfirmPasswordEmailRequest, + 0x5f2178c3: functions.account.ConfirmPhoneRequest, + 0x7ed5348a: functions.payments.ConnectStarRefBotRequest, + 0x033ed001: types.ConnectedBot, + 0x19a13f71: types.ConnectedBotStarRef, + 0x17d7f87b: types.account.ConnectedBots, + 0x98d5ea1d: types.payments.ConnectedStarRefBots, + 0x145ade0b: types.Contact, + 0x1d998733: types.ContactBirthday, + 0x114ff30d: types.contacts.ContactBirthdays, + 0x16d9703b: types.ContactStatus, + 0xeae87e42: types.contacts.Contacts, + 0xb74ba9d2: types.contacts.ContactsNotModified, + 0x57e28221: types.account.ContentSettings, + 0x74bf076b: functions.payments.ConvertStarGiftRequest, + 0x0b290c69: functions.channels.ConvertToGigagroupRequest, + 0x87d0759e: types.help.CountriesList, + 0x93cc1f32: types.help.CountriesListNotModified, + 0xc3878e23: types.help.Country, + 0x4203c5ef: types.help.CountryCode, + 0xb0f9684f: functions.payments.CraftStarGiftRequest, + 0xa63859ec: functions.communities.CreateRequest, + 0xa36396e5: functions.stories.CreateAlbumRequest, + 0xe5b17f2b: functions.bots.CreateBotRequest, + 0x8851e68e: functions.account.CreateBusinessChatLinkRequest, + 0x91006707: functions.channels.CreateChannelRequest, + 0x92ceddd4: functions.messages.CreateChatRequest, + 0x7d0444bb: functions.phone.CreateConferenceCallRequest, + 0x2f98c3d5: functions.messages.CreateForumTopicRequest, + 0x48cdc6d8: functions.phone.CreateGroupCallRequest, + 0x1f4a0e87: functions.payments.CreateStarGiftCollectionRequest, + 0x9021ab67: functions.stickers.CreateStickerSetRequest, + 0x652e4400: functions.account.CreateThemeRequest, + 0x4aa83913: functions.aicompose.CreateToneRequest, + 0x7d748d04: types.DataJSON, + 0x18b7a10d: types.DcOption, + 0x0a245dd3: functions.channels.DeactivateAllUsernamesRequest, + 0x3c479971: functions.phone.DeclineConferenceCallInviteRequest, + 0x4c9409f6: functions.account.DeclinePasswordResetRequest, + 0x35436bbc: functions.messages.DeclineUrlAuthRequest, + 0x6a4ee832: types.help.DeepLinkInfo, + 0x66afa166: types.help.DeepLinkInfoEmpty, + 0x43b46b20: types.DefaultHistoryTTL, + 0xa2c0cf74: functions.account.DeleteAccountRequest, + 0x8d3456d0: functions.stories.DeleteAlbumRequest, + 0x734f9721: functions.ephemeral.DeleteAllWelcomeMessagesRequest, + 0x53bc0020: functions.account.DeleteAutoSaveExceptionsRequest, + 0x60073674: functions.account.DeleteBusinessChatLinkRequest, + 0x1013fd9e: functions.contacts.DeleteByPhonesRequest, + 0xc0111fe3: functions.channels.DeleteChannelRequest, + 0x5bd0ee50: functions.messages.DeleteChatRequest, + 0xa2185cab: functions.messages.DeleteChatUserRequest, + 0x8ca60525: functions.phone.DeleteConferenceCallParticipantsRequest, + 0x096a0e00: functions.contacts.DeleteContactsRequest, + 0xd464a42b: functions.messages.DeleteExportedChatInviteRequest, + 0x719c5c5e: functions.chatlists.DeleteExportedInviteRequest, + 0xd1da940c: functions.messages.DeleteFactCheckRequest, + 0xf64f54f7: functions.phone.DeleteGroupCallMessagesRequest, + 0x1dbfeca0: functions.phone.DeleteGroupCallParticipantMessagesRequest, + 0xb08f922a: functions.messages.DeleteHistoryRequest, + 0x9baa9647: functions.channels.DeleteHistoryRequest, + 0x92f6e797: functions.ephemeral.DeleteMessageRequest, + 0xe58e95d2: functions.messages.DeleteMessagesRequest, + 0x84c1fd4e: functions.channels.DeleteMessagesRequest, + 0x367544db: functions.channels.DeleteParticipantHistoryRequest, + 0xe3b7f82c: functions.messages.DeleteParticipantReactionRequest, + 0xa0b80cf8: functions.messages.DeleteParticipantReactionsRequest, + 0xf5b5563f: functions.account.DeletePasskeyRequest, + 0xf9cbe409: functions.messages.DeletePhoneCallHistoryRequest, + 0x87cf7f2f: functions.photos.DeletePhotosRequest, + 0xac8505a5: functions.messages.DeletePollAnswerRequest, + 0x2d0135b3: functions.bots.DeletePreviewMediaRequest, + 0xe105e910: functions.messages.DeleteQuickReplyMessagesRequest, + 0x3cc04740: functions.messages.DeleteQuickReplyShortcutRequest, + 0x56987bd5: functions.messages.DeleteRevokedExportedChatInvitesRequest, + 0x4dc5085f: functions.messages.DeleteSavedHistoryRequest, + 0x59ae2b16: functions.messages.DeleteScheduledMessagesRequest, + 0xb880bc4b: functions.account.DeleteSecureValueRequest, + 0xad5648e8: functions.payments.DeleteStarGiftCollectionRequest, + 0x87704394: functions.stickers.DeleteStickerSetRequest, + 0xae59db5f: functions.stories.DeleteStoriesRequest, + 0xdd39316a: functions.aicompose.DeleteToneRequest, + 0xd2816f10: functions.messages.DeleteTopicHistoryRequest, + 0x86a0765d: functions.account.DeleteWebBrowserSettingsExceptionsRequest, + 0xe882a9e1: functions.ephemeral.DeleteWelcomeMessageRequest, + 0xd1435160: functions.DestroyAuthKeyRequest, + 0xea109b13: types.DestroyAuthKeyFail, + 0x0a9f2259: types.DestroyAuthKeyNone, + 0xf660e1d4: types.DestroyAuthKeyOk, + 0xe7512126: functions.DestroySessionRequest, + 0x62d350c9: types.DestroySessionNone, + 0xe22045fc: types.DestroySessionOk, + 0x2c221edd: types.messages.DhConfig, + 0xc0e24635: types.messages.DhConfigNotModified, + 0xa69dae02: types.DhGenFail, + 0x3bcbf734: types.DhGenOk, + 0x46dc1fb9: types.DhGenRetry, + 0xfc89f7f3: types.Dialog, + 0xf78a0973: types.DialogCommunity, + 0xaa472651: types.DialogFilter, + 0x96537bd7: types.DialogFilterChatlist, + 0x363293ae: types.DialogFilterDefault, + 0x77744d4a: types.DialogFilterSuggested, + 0x2ad93719: types.messages.DialogFilters, + 0x71bd134c: types.DialogFolder, + 0xe56dbf05: types.DialogPeer, + 0x2f65c8e4: types.DialogPeerCommunity, + 0x514519e2: types.DialogPeerFolder, + 0x15ba6c40: types.messages.Dialogs, + 0xf0e3e596: types.messages.DialogsNotModified, + 0x71e094f3: types.messages.DialogsSlice, + 0x00f49ca0: types.updates.Difference, + 0x5d75a138: types.updates.DifferenceEmpty, + 0xa8fb1981: types.updates.DifferenceSlice, + 0x4afe8f6d: types.updates.DifferenceTooLong, + 0x5e437ed9: functions.account.DisablePeerConnectedBotRequest, + 0x71f276c4: types.DisallowedGiftsSettings, + 0xb2cbc1c0: functions.phone.DiscardCallRequest, + 0xf393aea0: functions.messages.DiscardEncryptionRequest, + 0x7a777135: functions.phone.DiscardGroupCallRequest, + 0xa6341782: types.messages.DiscussionMessage, + 0xf50dbaa1: functions.help.DismissSuggestionRequest, + 0x8fd4c4d8: types.Document, + 0x11b58939: types.DocumentAttributeAnimated, + 0x9852f9c6: types.DocumentAttributeAudio, + 0xfd149899: types.DocumentAttributeCustomEmoji, + 0x15590068: types.DocumentAttributeFilename, + 0x9801d2f7: types.DocumentAttributeHasStickers, + 0x6c37c15c: types.DocumentAttributeImageSize, + 0x6319d612: types.DocumentAttributeSticker, + 0x43c57c48: types.DocumentAttributeVideo, + 0x36f8c871: types.DocumentEmpty, + 0x60fe3294: types.DraftMessage, + 0x1b0c841a: types.DraftMessageEmpty, + 0x8e48a188: functions.auth.DropTempAuthKeysRequest, + 0x31813cd8: functions.bots.EditAccessSettingsRequest, + 0x9a98ad68: functions.channels.EditAdminRequest, + 0x96e6cd81: functions.channels.EditBannedRequest, + 0x8c3410af: functions.account.EditBusinessChatLinkRequest, + 0xdef60797: functions.messages.EditChatAboutRequest, + 0xa85bd1c2: functions.messages.EditChatAdminRequest, + 0xf743b857: functions.messages.EditChatCreatorRequest, + 0xa5866b41: functions.messages.EditChatDefaultBannedRightsRequest, + 0xa00f32b0: functions.messages.EditChatParticipantRankRequest, + 0x35ddd674: functions.messages.EditChatPhotoRequest, + 0x73783ffd: functions.messages.EditChatTitleRequest, + 0xba6705f0: functions.contacts.EditCloseFriendsRequest, + 0xe4fca4a3: functions.payments.EditConnectedStarRefBotRequest, + 0xbdca2f75: functions.messages.EditExportedChatInviteRequest, + 0x653db63d: functions.chatlists.EditExportedInviteRequest, + 0x0589ee75: functions.messages.EditFactCheckRequest, + 0xcecc1134: functions.messages.EditForumTopicRequest, + 0xa5273abf: functions.phone.EditGroupCallParticipantRequest, + 0x1ca6ac0a: functions.phone.EditGroupCallTitleRequest, + 0xa423bb51: functions.messages.EditInlineBotMessageRequest, + 0x58e63f6d: functions.channels.EditLocationRequest, + 0xb106e66c: functions.messages.EditMessageRequest, + 0xcf9c725b: functions.ephemeral.EditMessageRequest, + 0x6847d0ab: functions.folders.EditPeerFoldersRequest, + 0xf12e57c9: functions.channels.EditPhotoRequest, + 0x8525606f: functions.bots.EditPreviewMediaRequest, + 0x5c003cef: functions.messages.EditQuickReplyShortcutRequest, + 0x2c63a72b: functions.stories.EditStoryRequest, + 0x566decd0: functions.channels.EditTitleRequest, + 0x66b91b70: functions.help.EditUserInfoRequest, + 0xdc8b44cf: types.smsjobs.EligibleToJoin, + 0x96d074fd: types.EmailVerificationApple, + 0x922e55a9: types.EmailVerificationCode, + 0xdb909ec2: types.EmailVerificationGoogle, + 0x2b96cd1b: types.account.EmailVerified, + 0xe1bb0d61: types.account.EmailVerifiedLogin, + 0x527d22eb: types.EmailVerifyPurposeLoginChange, + 0x4345be73: types.EmailVerifyPurposeLoginSetup, + 0xbbf51685: types.EmailVerifyPurposePassport, + 0x44e56023: types.messages.EmojiGameDiceInfo, + 0xda2ad647: types.messages.EmojiGameOutcome, + 0x59e65335: types.messages.EmojiGameUnavailable, + 0x7a9abda9: types.EmojiGroup, + 0x80d26cc7: types.EmojiGroupGreeting, + 0x093bcf34: types.EmojiGroupPremium, + 0x881fb94b: types.messages.EmojiGroups, + 0x6fb4ad87: types.messages.EmojiGroupsNotModified, + 0xd5b3b9f9: types.EmojiKeyword, + 0x236df622: types.EmojiKeywordDeleted, + 0x5cc761bd: types.EmojiKeywordsDifference, + 0xb3fb5361: types.EmojiLanguage, + 0x7a1e11d1: types.EmojiList, + 0x481eadfa: types.EmojiListNotModified, + 0xe7ff068a: types.EmojiStatus, + 0x7184603b: types.EmojiStatusCollectible, + 0x2de11aae: types.EmojiStatusEmpty, + 0x90c467d1: types.account.EmojiStatuses, + 0xd08ce645: types.account.EmojiStatusesNotModified, + 0xa575739d: types.EmojiURL, + 0x61f0d4c7: types.EncryptedChat, + 0x1e1c7c45: types.EncryptedChatDiscarded, + 0xab7ec0a0: types.EncryptedChatEmpty, + 0x48f1d94c: types.EncryptedChatRequested, + 0x66b25953: types.EncryptedChatWaiting, + 0xa8008cd8: types.EncryptedFile, + 0xc21f497e: types.EncryptedFileEmpty, + 0xed18c118: types.EncryptedMessage, + 0x23734b06: types.EncryptedMessageService, + 0xdd27bee9: types.EphemeralMessage, + 0xe5bfffcd: functions.auth.ExportAuthorizationRequest, + 0xbd0d99eb: functions.bots.ExportBotTokenRequest, + 0xa455de90: functions.messages.ExportChatInviteRequest, + 0x8472478e: functions.chatlists.ExportChatlistInviteRequest, + 0xf8654027: functions.contacts.ExportContactTokenRequest, + 0xe6aa647f: functions.phone.ExportGroupCallInviteRequest, + 0x0f91b065: functions.payments.ExportInvoiceRequest, + 0xb7e085fe: functions.auth.ExportLoginTokenRequest, + 0xe63fadeb: functions.channels.ExportMessageLinkRequest, + 0x7b8def20: functions.stories.ExportStoryLinkRequest, + 0xb434e2b8: types.auth.ExportedAuthorization, + 0x3c60b621: types.bots.ExportedBotToken, + 0x1871be50: types.messages.ExportedChatInvite, + 0x222600ef: types.messages.ExportedChatInviteReplaced, + 0xbdc62dcc: types.messages.ExportedChatInvites, + 0x0c5181ac: types.ExportedChatlistInvite, + 0x10e6e3a6: types.chatlists.ExportedChatlistInvite, + 0x41bf109b: types.ExportedContactToken, + 0x204bd158: types.phone.ExportedGroupCallInvite, + 0x10ab6dc7: types.chatlists.ExportedInvites, + 0xaed0cbd9: types.payments.ExportedInvoice, + 0x5dab1af4: types.ExportedMessageLink, + 0x3fc9053b: types.ExportedStoryLink, + 0xb89bfccf: types.FactCheck, + 0xb9ffc55b: functions.messages.FaveStickerRequest, + 0x2cb51097: types.messages.FavedStickers, + 0x9e8fa6d3: types.messages.FavedStickersNotModified, + 0xbe382906: types.messages.FeaturedStickers, + 0xc6dc0c66: types.messages.FeaturedStickersNotModified, + 0x096a18d5: types.upload.File, + 0xf18cda44: types.upload.FileCdnRedirect, + 0xcae1aadf: types.storage.FileGif, + 0xf39b035c: types.FileHash, + 0x007efe0e: types.storage.FileJpeg, + 0x4b09ebbc: types.storage.FileMov, + 0x528a0677: types.storage.FileMp3, + 0xb3cea0e4: types.storage.FileMp4, + 0x40bc6f52: types.storage.FilePartial, + 0xae1e508d: types.storage.FilePdf, + 0x0a4f63c0: types.storage.FilePng, + 0xaa963b05: types.storage.FileUnknown, + 0x1081464c: types.storage.FileWebp, + 0x2c85094c: functions.auth.FinishFirebasePnvLoginRequest, + 0x4f1ebf24: functions.smsjobs.FinishJobRequest, + 0x9857ad07: functions.auth.FinishPasskeyLoginRequest, + 0x1d2652ee: functions.account.FinishTakeoutSessionRequest, + 0xdf5ac00c: types.auth.FirebasePnvIntent, + 0x783f6b56: functions.auth.FirebasePnvSignUpRequest, + 0xff544e65: types.Folder, + 0xe9baa668: types.FolderPeer, + 0xfcdad815: types.ForumTopic, + 0x023f109b: types.ForumTopicDeleted, + 0x367617d3: types.messages.ForumTopics, + 0x13704a7c: functions.messages.ForwardMessagesRequest, + 0xb3134d9d: types.contacts.Found, + 0x8af09dd2: types.messages.FoundStickerSets, + 0x0d54b65d: types.messages.FoundStickerSetsNotModified, + 0x82c9e290: types.messages.FoundStickers, + 0x6010c534: types.messages.FoundStickersNotModified, + 0xe2de7737: types.stories.FoundStories, + 0xe87acbc0: types.FoundStory, + 0xcc5bebb3: functions.payments.FulfillStarsSubscriptionRequest, + 0x0949d9dc: types.FutureSalt, + 0xae500895: types.FutureSalts, + 0xbdf9653b: types.Game, + 0xb2a2f663: types.GeoPoint, + 0xde4c5d93: types.GeoPointAddress, + 0x1117dd5f: types.GeoPointEmpty, + 0x213853a3: functions.bots.GetAccessSettingsRequest, + 0x08fc711d: functions.account.GetAccountTTLRequest, + 0x33ddf480: functions.channels.GetAdminLogRequest, + 0xb0711d83: functions.bots.GetAdminedBotsRequest, + 0xf8b036af: functions.channels.GetAdminedPublicChannelsRequest, + 0x3920e6ef: functions.messages.GetAdminsWithInvitesRequest, + 0xac806d61: functions.stories.GetAlbumStoriesRequest, + 0x25b3eac7: functions.stories.GetAlbumsRequest, + 0x6a3f8d65: functions.messages.GetAllDraftsRequest, + 0x9b5ae7f9: functions.stories.GetAllReadPeerStoriesRequest, + 0xb288bc7d: functions.account.GetAllSecureValuesRequest, + 0xb8a0a1a8: functions.messages.GetAllStickersRequest, + 0xeeb0d625: functions.stories.GetAllStoriesRequest, + 0x61e3f854: functions.help.GetAppConfigRequest, + 0x522d5a7d: functions.help.GetAppUpdateRequest, + 0x57f17692: functions.messages.GetArchivedStickersRequest, + 0x77216192: functions.messages.GetAttachMenuBotRequest, + 0x16fcc2cb: functions.messages.GetAttachMenuBotsRequest, + 0xcc5b67cc: functions.messages.GetAttachedStickersRequest, + 0xa929597a: functions.account.GetAuthorizationFormRequest, + 0xe320c158: functions.account.GetAuthorizationsRequest, + 0x56da0b3f: functions.account.GetAutoDownloadSettingsRequest, + 0xadcbbcda: functions.account.GetAutoSaveSettingsRequest, + 0xdea20a39: functions.messages.GetAvailableEffectsRequest, + 0x18dea0ac: functions.messages.GetAvailableReactionsRequest, + 0x2e79d779: functions.payments.GetBankCardDataRequest, + 0xdaeda864: functions.contacts.GetBirthdaysRequest, + 0x9a868f80: functions.contacts.GetBlockedRequest, + 0x60f67660: functions.premium.GetBoostsListRequest, + 0x042f1f61: functions.premium.GetBoostsStatusRequest, + 0x34fdc5c3: functions.messages.GetBotAppRequest, + 0x76a86270: functions.account.GetBotBusinessConnectionRequest, + 0x9342ca07: functions.messages.GetBotCallbackAnswerRequest, + 0xe34c0dd6: functions.bots.GetBotCommandsRequest, + 0xdcd914fd: functions.bots.GetBotInfoRequest, + 0x9c60eb28: functions.bots.GetBotMenuButtonRequest, + 0xa1b70815: functions.bots.GetBotRecommendationsRequest, + 0xab42441a: functions.stats.GetBroadcastStatsRequest, + 0x6f70dde1: functions.account.GetBusinessChatLinksRequest, + 0x55451fa9: functions.phone.GetCallConfigRequest, + 0x3fa464c8: functions.ephemeral.GetCallbackAnswerRequest, + 0x52029342: functions.help.GetCdnConfigRequest, + 0x395f69da: functions.upload.GetCdnFileRequest, + 0x91dc3f31: functions.upload.GetCdnFileHashesRequest, + 0x7727a7d5: functions.account.GetChannelDefaultEmojiStatusesRequest, + 0x03173d78: functions.updates.GetChannelDifferenceRequest, + 0x25a71742: functions.channels.GetChannelRecommendationsRequest, + 0x35a9e0d5: functions.account.GetChannelRestrictedStatusEmojisRequest, + 0x0a7f6bbb: functions.channels.GetChannelsRequest, + 0xdf04dd4e: functions.messages.GetChatInviteImportersRequest, + 0xd638de89: functions.account.GetChatThemesRequest, + 0x89419521: functions.chatlists.GetChatlistUpdatesRequest, + 0x49e9528f: functions.messages.GetChatsRequest, + 0xa56a8b60: functions.stories.GetChatsToSendRequest, + 0x2e7b4543: functions.account.GetCollectibleEmojiStatusesRequest, + 0xbe1e85ba: functions.fragment.GetCollectibleInfoRequest, + 0xe40ca104: functions.messages.GetCommonChatsRequest, + 0xc4f9186b: functions.help.GetConfigRequest, + 0x4ea4c80f: functions.account.GetConnectedBotsRequest, + 0xb7d998f0: functions.payments.GetConnectedStarRefBotRequest, + 0x5869a553: functions.payments.GetConnectedStarRefBotsRequest, + 0x7adc669d: functions.contacts.GetContactIDsRequest, + 0x9f07c728: functions.account.GetContactSignUpNotificationRequest, + 0x5dd69e12: functions.contacts.GetContactsRequest, + 0x8b9b4dae: functions.account.GetContentSettingsRequest, + 0x735787a8: functions.help.GetCountriesListRequest, + 0xfd05dd00: functions.payments.GetCraftStarGiftsRequest, + 0xd9ab0f54: functions.messages.GetCustomEmojiDocumentsRequest, + 0x3fedc75f: functions.help.GetDeepLinkInfoRequest, + 0xa60ab9ce: functions.account.GetDefaultBackgroundEmojisRequest, + 0xd6753386: functions.account.GetDefaultEmojiStatusesRequest, + 0x915860ae: functions.account.GetDefaultGroupPhotoEmojisRequest, + 0x658b7188: functions.messages.GetDefaultHistoryTTLRequest, + 0xe2750328: functions.account.GetDefaultProfilePhotoEmojisRequest, + 0xbdf93428: functions.messages.GetDefaultTagReactionsRequest, + 0x26cf8950: functions.messages.GetDhConfigRequest, + 0xefd48c89: functions.messages.GetDialogFiltersRequest, + 0x21202222: functions.messages.GetDialogUnreadMarksRequest, + 0xa0f4cb4f: functions.messages.GetDialogsRequest, + 0x19c2f763: functions.updates.GetDifferenceRequest, + 0xcd984aa5: functions.langpack.GetDifferenceRequest, + 0x446972fd: functions.messages.GetDiscussionMessageRequest, + 0xb1f2061f: functions.messages.GetDocumentByHashRequest, + 0xfb7e8ca7: functions.messages.GetEmojiGameInfoRequest, + 0x7488ce5b: functions.messages.GetEmojiGroupsRequest, + 0x35a0e062: functions.messages.GetEmojiKeywordsRequest, + 0x1508b6af: functions.messages.GetEmojiKeywordsDifferenceRequest, + 0x4e9963b2: functions.messages.GetEmojiKeywordsLanguagesRequest, + 0x21a548f3: functions.messages.GetEmojiProfilePhotoGroupsRequest, + 0x2ecd56cd: functions.messages.GetEmojiStatusGroupsRequest, + 0x1dd840f5: functions.messages.GetEmojiStickerGroupsRequest, + 0xfbfca18f: functions.messages.GetEmojiStickersRequest, + 0xd5b10c26: functions.messages.GetEmojiURLRequest, + 0x73746f5c: functions.messages.GetExportedChatInviteRequest, + 0xa2b5a3f6: functions.messages.GetExportedChatInvitesRequest, + 0xce03da83: functions.chatlists.GetExportedInvitesRequest, + 0x84f80814: functions.messages.GetExtendedMediaRequest, + 0xb9cdc5ee: functions.messages.GetFactCheckRequest, + 0x04f1aaa9: functions.messages.GetFavedStickersRequest, + 0x0ecf6736: functions.messages.GetFeaturedEmojiStickersRequest, + 0x64780b14: functions.messages.GetFeaturedStickersRequest, + 0xbe5335be: functions.upload.GetFileRequest, + 0x9156982a: functions.upload.GetFileHashesRequest, + 0x3ba47bff: functions.messages.GetForumTopicsRequest, + 0xaf0a4a08: functions.messages.GetForumTopicsByIDRequest, + 0x08736a09: functions.channels.GetFullChannelRequest, + 0xaeb00b34: functions.messages.GetFullChatRequest, + 0xb60f5918: functions.users.GetFullUserRequest, + 0x3b7d0ea6: functions.messages.GetFutureChatCreatorAfterLeaveRequest, + 0xe822649d: functions.messages.GetGameHighScoresRequest, + 0xf4239425: functions.payments.GetGiveawayInfoRequest, + 0xeb2b4cf6: functions.account.GetGlobalPrivacySettingsRequest, + 0x041845db: functions.phone.GetGroupCallRequest, + 0xee9f88a6: functions.phone.GetGroupCallChainBlocksRequest, + 0xef7c213a: functions.phone.GetGroupCallJoinAsRequest, + 0x6f636302: functions.phone.GetGroupCallStarsRequest, + 0x1ab21940: functions.phone.GetGroupCallStreamChannelsRequest, + 0x5af4c73a: functions.phone.GetGroupCallStreamRtmpUrlRequest, + 0xc558d8ab: functions.phone.GetGroupParticipantsRequest, + 0xf5dad378: functions.channels.GetGroupsForDiscussionRequest, + 0x4423e6c5: functions.messages.GetHistoryRequest, + 0x11e831ee: functions.channels.GetInactiveChannelsRequest, + 0x514e999d: functions.messages.GetInlineBotResultsRequest, + 0x0f635e1b: functions.messages.GetInlineGameHighScoresRequest, + 0x4d392343: functions.help.GetInviteTextRequest, + 0xa663e830: functions.communities.GetJoinedCommunitiesRequest, + 0xf2f2330a: functions.langpack.GetLangPackRequest, + 0x6a596502: functions.langpack.GetLanguageRequest, + 0x42c6978f: functions.langpack.GetLanguagesRequest, + 0xfdbcd714: functions.chatlists.GetLeaveChatlistSuggestionsRequest, + 0x8341ecc0: functions.channels.GetLeftChannelsRequest, + 0xd348bc44: functions.contacts.GetLocatedRequest, + 0x640f82b8: functions.messages.GetMaskStickersRequest, + 0xdcdf8607: functions.stats.GetMegagroupStatsRequest, + 0xece2a0e6: functions.channels.GetMessageAuthorRequest, + 0xfda68d36: functions.messages.GetMessageEditDataRequest, + 0x5f150144: functions.stats.GetMessagePublicForwardsRequest, + 0x461b3f48: functions.messages.GetMessageReactionsListRequest, + 0x31c1c44f: functions.messages.GetMessageReadParticipantsRequest, + 0xb6e0a3f5: functions.stats.GetMessageStatsRequest, + 0x63c66506: functions.messages.GetMessagesRequest, + 0xad8c9a23: functions.channels.GetMessagesRequest, + 0x8bba90e6: functions.messages.GetMessagesReactionsRequest, + 0x5784d3e1: functions.messages.GetMessagesViewsRequest, + 0x65ad71dc: functions.account.GetMultiWallPapersRequest, + 0x0be77b4a: functions.premium.GetMyBoostsRequest, + 0xd0b5e1fc: functions.messages.GetMyStickersRequest, + 0x1fb33026: functions.help.GetNearestDcRequest, + 0x53577479: functions.account.GetNotifyExceptionsRequest, + 0x12b3ad31: functions.account.GetNotifySettingsRequest, + 0x7ed094a1: functions.messages.GetOldFeaturedStickersRequest, + 0x6e2be050: functions.messages.GetOnlinesRequest, + 0x8c4bfe5d: functions.messages.GetOutboxReadDateRequest, + 0x19ba4a67: functions.account.GetPaidMessagesRevenueRequest, + 0x472455aa: functions.messages.GetPaidReactionPrivacyRequest, + 0xa0ab6cc6: functions.channels.GetParticipantRequest, + 0xf87eabab: functions.communities.GetParticipantJoinedChatsRequest, + 0x77ced9d0: functions.channels.GetParticipantsRequest, + 0xea1f0c52: functions.account.GetPasskeysRequest, + 0xc661ad08: functions.help.GetPassportConfigRequest, + 0x548a30f5: functions.account.GetPasswordRequest, + 0x9cd4eaf9: functions.account.GetPasswordSettingsRequest, + 0x37148dbb: functions.payments.GetPaymentFormRequest, + 0x2478d1cc: functions.payments.GetPaymentReceiptRequest, + 0xda80f42f: functions.help.GetPeerColorsRequest, + 0xe470bcfd: functions.messages.GetPeerDialogsRequest, + 0x93773344: functions.communities.GetPeerLinkRequestsRequest, + 0x78499170: functions.stories.GetPeerMaxIDsRequest, + 0xabcfa9fd: functions.help.GetPeerProfileColorsRequest, + 0xefd9a6a2: functions.messages.GetPeerSettingsRequest, + 0x2c4ada50: functions.stories.GetPeerStoriesRequest, + 0x55fb0996: functions.messages.GetPersonalChannelHistoryRequest, + 0xd6b94df2: functions.messages.GetPinnedDialogsRequest, + 0xd63d94e0: functions.messages.GetPinnedSavedDialogsRequest, + 0x5821a5dc: functions.stories.GetPinnedStoriesRequest, + 0xeda3e33b: functions.messages.GetPollResultsRequest, + 0xc27dfa68: functions.stats.GetPollStatsRequest, + 0xb86e380e: functions.messages.GetPollVotesRequest, + 0xc2510192: functions.bots.GetPopularAppBotsRequest, + 0x2757ba54: functions.payments.GetPremiumGiftCodeOptionsRequest, + 0xb81b93d4: functions.help.GetPremiumPromoRequest, + 0x857ebdb8: functions.messages.GetPreparedInlineMessageRequest, + 0x423ab3ad: functions.bots.GetPreviewInfoRequest, + 0xa2a5594d: functions.bots.GetPreviewMediasRequest, + 0xdadbc950: functions.account.GetPrivacyRequest, + 0xc0977421: functions.help.GetPromoDataRequest, + 0xd483f2a8: functions.messages.GetQuickRepliesRequest, + 0x94a495c3: functions.messages.GetQuickReplyMessagesRequest, + 0x06dd654c: functions.account.GetReactionsNotifySettingsRequest, + 0x0f578105: functions.account.GetRecentEmojiStatusesRequest, + 0x702a40e0: functions.messages.GetRecentLocationsRequest, + 0x3dc0f114: functions.help.GetRecentMeUrlsRequest, + 0x39461db2: functions.messages.GetRecentReactionsRequest, + 0x9da9403b: functions.messages.GetRecentStickersRequest, + 0x22ddd30c: functions.messages.GetRepliesRequest, + 0xbf25b7f3: functions.bots.GetRequestedWebViewButtonRequest, + 0xd89a83a3: functions.users.GetRequirementsToContactRequest, + 0x7a5fa236: functions.payments.GetResaleStarGiftsRequest, + 0x501569cf: functions.messages.GetRichMessageRequest, + 0x82f1e39f: functions.contacts.GetSavedRequest, + 0x1e91fc99: functions.messages.GetSavedDialogsRequest, + 0x6f6f9c96: functions.messages.GetSavedDialogsByIDRequest, + 0x5cf09635: functions.messages.GetSavedGifsRequest, + 0x998ab009: functions.messages.GetSavedHistoryRequest, + 0x227d824b: functions.payments.GetSavedInfoRequest, + 0x788d7fe3: functions.users.GetSavedMusicRequest, + 0x7573a4e9: functions.users.GetSavedMusicByIDRequest, + 0xe09d5faf: functions.account.GetSavedMusicIdsRequest, + 0x3637e05b: functions.messages.GetSavedReactionTagsRequest, + 0xe1902288: functions.account.GetSavedRingtonesRequest, + 0xb455a106: functions.payments.GetSavedStarGiftRequest, + 0xa319e569: functions.payments.GetSavedStarGiftsRequest, + 0xf516760b: functions.messages.GetScheduledHistoryRequest, + 0xbdbb0464: functions.messages.GetScheduledMessagesRequest, + 0x1bbcf300: functions.messages.GetSearchCountersRequest, + 0x6aa3f6bd: functions.messages.GetSearchResultsCalendarRequest, + 0x9c7f2f10: functions.messages.GetSearchResultsPositionsRequest, + 0x73665bc2: functions.account.GetSecureValueRequest, + 0xe785a43f: functions.channels.GetSendAsRequest, + 0x778d902f: functions.smsjobs.GetSmsJobRequest, + 0x1cff7e08: functions.messages.GetSplitRangesRequest, + 0x3d6ce850: functions.messages.GetSponsoredMessagesRequest, + 0xb6c8c393: functions.contacts.GetSponsoredPeersRequest, + 0xa5d0514d: functions.payments.GetStarGiftActiveAuctionsRequest, + 0x6ba2cbec: functions.payments.GetStarGiftAuctionAcquiredGiftsRequest, + 0x5c9ff4d6: functions.payments.GetStarGiftAuctionStateRequest, + 0x981b91dd: functions.payments.GetStarGiftCollectionsRequest, + 0x6d038b58: functions.payments.GetStarGiftUpgradeAttributesRequest, + 0x9c9abcb1: functions.payments.GetStarGiftUpgradePreviewRequest, + 0xd06e93a8: functions.payments.GetStarGiftWithdrawalUrlRequest, + 0xc4563590: functions.payments.GetStarGiftsRequest, + 0xd3c96bc8: functions.payments.GetStarsGiftOptionsRequest, + 0xbd1efd3e: functions.payments.GetStarsGiveawayOptionsRequest, + 0xd1d7efc5: functions.payments.GetStarsRevenueAdsAccountUrlRequest, + 0xd91ffad6: functions.payments.GetStarsRevenueStatsRequest, + 0x2433dc92: functions.payments.GetStarsRevenueWithdrawalUrlRequest, + 0x4ea9b3bf: functions.payments.GetStarsStatusRequest, + 0x032512c5: functions.payments.GetStarsSubscriptionsRequest, + 0xc00ec7d3: functions.payments.GetStarsTopupOptionsRequest, + 0x69da4557: functions.payments.GetStarsTransactionsRequest, + 0x2dca16b8: functions.payments.GetStarsTransactionsByIDRequest, + 0xedd4882a: functions.updates.GetStateRequest, + 0x10a698e8: functions.smsjobs.GetStatusRequest, + 0xc4a353ee: functions.contacts.GetStatusesRequest, + 0xc8a0ec74: functions.messages.GetStickerSetRequest, + 0xd5a5d3a1: functions.messages.GetStickersRequest, + 0xb4352016: functions.stories.GetStoriesArchiveRequest, + 0x5774ca74: functions.stories.GetStoriesByIDRequest, + 0x28e16cc8: functions.stories.GetStoriesViewsRequest, + 0xa6437ef6: functions.stats.GetStoryPublicForwardsRequest, + 0xb9b2881f: functions.stories.GetStoryReactionsListRequest, + 0x374fef40: functions.stats.GetStoryStatsRequest, + 0x7ed23c57: functions.stories.GetStoryViewsListRequest, + 0xefea3803: functions.langpack.GetStringsRequest, + 0xa29cd42c: functions.messages.GetSuggestedDialogFiltersRequest, + 0x0d6b48f7: functions.payments.GetSuggestedStarRefBotsRequest, + 0x9cdf08cd: functions.help.GetSupportRequest, + 0xd360e72c: functions.help.GetSupportNameRequest, + 0x2ca51fd1: functions.help.GetTermsOfServiceUpdateRequest, + 0x3a5869ec: functions.account.GetThemeRequest, + 0x7206e458: functions.account.GetThemesRequest, + 0x49b30240: functions.help.GetTimezonesListRequest, + 0x449e0b51: functions.account.GetTmpPasswordRequest, + 0xb2e8ba03: functions.aicompose.GetToneRequest, + 0xd1b4ab14: functions.aicompose.GetToneExampleRequest, + 0xabd59201: functions.aicompose.GetTonesRequest, + 0x973478b6: functions.contacts.GetTopPeersRequest, + 0xbb8125ba: functions.messages.GetTopReactionsRequest, + 0xe42ce9c9: functions.account.GetUniqueGiftChatThemesRequest, + 0xa1974d72: functions.payments.GetUniqueStarGiftRequest, + 0x4365af6b: functions.payments.GetUniqueStarGiftValueInfoRequest, + 0xf107e790: functions.messages.GetUnreadMentionsRequest, + 0x43286cf2: functions.messages.GetUnreadPollVotesRequest, + 0xbd7f90ac: functions.messages.GetUnreadReactionsRequest, + 0x39854d1f: functions.premium.GetUserBoostsRequest, + 0x038a08d3: functions.help.GetUserInfoRequest, + 0x91cd32a8: functions.photos.GetUserPhotosRequest, + 0x0d91a548: functions.users.GetUsersRequest, + 0xfc8ddbea: functions.account.GetWallPaperRequest, + 0x07967d36: functions.account.GetWallPapersRequest, + 0x182e6d6f: functions.account.GetWebAuthorizationsRequest, + 0x56655768: functions.account.GetWebBrowserSettingsRequest, + 0x24e6818d: functions.upload.GetWebFileRequest, + 0x8d9692a3: functions.messages.GetWebPageRequest, + 0x570d6f6f: functions.messages.GetWebPagePreviewRequest, + 0xdb9ac18d: functions.ephemeral.GetWelcomeMessagesRequest, + 0xb921bd04: functions.GetFutureSaltsRequest, + 0x4367daa0: types.payments.GiveawayInfo, + 0xe175e66f: types.payments.GiveawayInfoResults, + 0xfe41b34f: types.GlobalPrivacySettings, + 0xefb2b617: types.GroupCall, + 0x9e727aad: types.phone.GroupCall, + 0x7780bcb4: types.GroupCallDiscarded, + 0xee430c85: types.GroupCallDonor, + 0x1a8afc7e: types.GroupCallMessage, + 0x2a3dc7ac: types.GroupCallParticipant, + 0x67753ac8: types.GroupCallParticipantVideo, + 0xdcb118b7: types.GroupCallParticipantVideoSourceGroup, + 0x9d1dbd26: types.phone.GroupCallStars, + 0x80eb48af: types.GroupCallStreamChannel, + 0xd0e482b2: types.phone.GroupCallStreamChannels, + 0x2dbf3432: types.phone.GroupCallStreamRtmpUrl, + 0xf47751b6: types.phone.GroupParticipants, + 0xe085f4ea: functions.messages.HideAllChatJoinRequestsRequest, + 0x7fe7e815: functions.messages.HideChatJoinRequestRequest, + 0x66e486fb: functions.chatlists.HideChatlistUpdatesRequest, + 0x4facb138: functions.messages.HidePeerSettingsBarRequest, + 0x1e251c95: functions.help.HidePromoDataRequest, + 0x73a379eb: types.HighScore, + 0x9a3bfd99: types.messages.HighScores, + 0x1662af0b: types.messages.HistoryImport, + 0x5e0fb7b9: types.messages.HistoryImportParsed, + 0x9299359f: types.HttpWait, + 0xa57a7dad: functions.auth.ImportAuthorizationRequest, + 0x67a3ff2c: functions.auth.ImportBotAuthorizationRequest, + 0xde91436e: functions.messages.ImportChatInviteRequest, + 0x13005788: functions.contacts.ImportContactTokenRequest, + 0x2c800be5: functions.contacts.ImportContactsRequest, + 0x95ac5ce4: functions.auth.ImportLoginTokenRequest, + 0x2db873a9: functions.auth.ImportWebTokenAuthorizationRequest, + 0xc13e3c50: types.ImportedContact, + 0x77d01c3b: types.contacts.ImportedContacts, + 0xa927fec5: types.messages.InactiveChats, + 0xb2028afb: functions.stories.IncrementStoryViewsRequest, + 0xc1cd5ea9: functions.InitConnectionRequest, + 0x777df37a: functions.auth.InitFirebasePnvLoginRequest, + 0x34090c3b: functions.messages.InitHistoryImportRequest, + 0x518ad0b7: functions.auth.InitPasskeyLoginRequest, + 0x429547e8: functions.account.InitPasskeyRegistrationRequest, + 0x8ef3eab0: functions.account.InitTakeoutSessionRequest, + 0x3c20629f: types.InlineBotSwitchPM, + 0xb57295d5: types.InlineBotWebView, + 0x48bad7a5: types.InlineButtonTypeBuy, + 0x2955bc38: types.InlineButtonTypeCallback, + 0xb41d3272: types.InlineButtonTypeCopy, + 0xa438619d: types.InlineButtonTypeDisabled, + 0x5cd3709d: types.InlineButtonTypeGame, + 0x93773ff5: types.InlineButtonTypeSwitchInline, + 0xeca4f8d4: types.InlineButtonTypeUrl, + 0xbfd02da2: types.InlineButtonTypeUrlAuth, + 0x3fa33fcf: types.InlineButtonTypeUserProfile, + 0x3bcab5b4: types.InlineButtonTypeWebView, + 0x0e3b2d0c: types.InlineQueryPeerTypeBotPM, + 0x6334ee9a: types.InlineQueryPeerTypeBroadcast, + 0xd766c50a: types.InlineQueryPeerTypeChat, + 0x5ec4be43: types.InlineQueryPeerTypeMegagroup, + 0x833c0fac: types.InlineQueryPeerTypePM, + 0x3081ed9d: types.InlineQueryPeerTypeSameBotPM, + 0x1fe9a9bf: types.InputAiComposeToneDefault, + 0x0773c080: types.InputAiComposeToneID, + 0x0e0c35af: types.InputAiComposeToneSingleUse, + 0x1fa01357: types.InputAiComposeToneSlug, + 0x1d1b1245: types.InputAppEvent, + 0xa920bd7a: types.InputBotAppID, + 0x908c0407: types.InputBotAppShortName, + 0x4b425864: types.InputBotInlineMessageGame, + 0x890c3d89: types.InputBotInlineMessageID, + 0xb6d915d7: types.InputBotInlineMessageID64, + 0x3380c786: types.InputBotInlineMessageMediaAuto, + 0xa6edbffd: types.InputBotInlineMessageMediaContact, + 0x96929a85: types.InputBotInlineMessageMediaGeo, + 0xd7e78225: types.InputBotInlineMessageMediaInvoice, + 0x417bbf11: types.InputBotInlineMessageMediaVenue, + 0xbddcc510: types.InputBotInlineMessageMediaWebPage, + 0xb43df56c: types.InputBotInlineMessageRichMessage, + 0x3dcd7a87: types.InputBotInlineMessageText, + 0x88bf9319: types.InputBotInlineResult, + 0xfff8fdc4: types.InputBotInlineResultDocument, + 0x4fa417f2: types.InputBotInlineResultGame, + 0xa8d864a7: types.InputBotInlineResultPhoto, + 0x832175e0: types.InputBusinessAwayMessage, + 0xc4e5921e: types.InputBusinessBotRecipients, + 0x11679fa7: types.InputBusinessChatLink, + 0x0194cb3b: types.InputBusinessGreetingMessage, + 0x09c469cd: types.InputBusinessIntro, + 0x6f8b32aa: types.InputBusinessRecipients, + 0x3fe268fe: types.InputButtonTypeRequestPeer, + 0xf35aec28: types.InputChannel, + 0xee8c1e86: types.InputChannelEmpty, + 0x5b934f9d: types.InputChannelFromMessage, + 0x8953ad37: types.InputChatPhoto, + 0x1ca48f57: types.InputChatPhotoEmpty, + 0xc93de95c: types.InputChatTheme, + 0x83268483: types.InputChatThemeEmpty, + 0x87e5dfe4: types.InputChatThemeUniqueGift, + 0xbdcdaec0: types.InputChatUploadedPhoto, + 0xf3e0da33: types.InputChatlistDialogFilter, + 0x9880f658: types.InputCheckPasswordEmpty, + 0xd27ff082: types.InputCheckPasswordSRP, + 0x75588b3f: types.InputClientProxy, + 0xa2e214a4: types.InputCollectiblePhone, + 0xe39460a9: types.InputCollectibleUsername, + 0xfcaafeb7: types.InputDialogPeer, + 0x69ef72c4: types.InputDialogPeerCommunity, + 0x64600527: types.InputDialogPeerFolder, + 0x1abfb575: types.InputDocument, + 0x72f0eaae: types.InputDocumentEmpty, + 0xbad07584: types.InputDocumentFileLocation, + 0x07141dbf: types.InputEmojiStatusCollectible, + 0xf141b5e1: types.InputEncryptedChat, + 0x5a17b5e5: types.InputEncryptedFile, + 0x2dc173c8: types.InputEncryptedFileBigUploaded, + 0x1837c364: types.InputEncryptedFileEmpty, + 0xf5235d55: types.InputEncryptedFileLocation, + 0x64bd0306: types.InputEncryptedFileUploaded, + 0xf52ff27f: types.InputFile, + 0xfa4f0bb5: types.InputFileBig, + 0xdfdaabe1: types.InputFileLocation, + 0x62dc8b48: types.InputFileStoryDocument, + 0xfbd2c296: types.InputFolderPeer, + 0x032c3e77: types.InputGameID, + 0xc331e80a: types.InputGameShortName, + 0x48222faf: types.InputGeoPoint, + 0xe4c123d6: types.InputGeoPointEmpty, + 0xd8aa840f: types.InputGroupCall, + 0x8c10603f: types.InputGroupCallInviteMessage, + 0xfe06823f: types.InputGroupCallSlug, + 0x0598a92a: types.InputGroupCallStream, + 0x9961bcb4: types.InputInlineButtonTypeUrlAuth, + 0x53f3ce5a: types.InputInlineButtonTypeUserProfile, + 0xf4997e42: types.InputInvoiceBusinessBotTransferStars, + 0x34e793f1: types.InputInvoiceChatInviteSubscription, + 0xc5b56859: types.InputInvoiceMessage, + 0x3e77f614: types.InputInvoicePremiumAuthCode, + 0x98986c0d: types.InputInvoicePremiumGiftCode, + 0xdabab2ef: types.InputInvoicePremiumGiftStars, + 0xc326caef: types.InputInvoiceSlug, + 0xe8625e92: types.InputInvoiceStarGift, + 0x1ecafa10: types.InputInvoiceStarGiftAuctionBid, + 0x0923d8d1: types.InputInvoiceStarGiftDropOriginalDetails, + 0x9a0b48b8: types.InputInvoiceStarGiftPrepaidUpgrade, + 0xe9b0c658: types.InputInvoiceStarGiftResale, + 0x4a5f5bd9: types.InputInvoiceStarGiftTransfer, + 0x4d818d5d: types.InputInvoiceStarGiftUpgrade, + 0x65f00ce3: types.InputInvoiceStars, + 0x2271f2bf: types.InputMediaAreaChannelPost, + 0xb282217f: types.InputMediaAreaVenue, + 0xf8ab7dfb: types.InputMediaContact, + 0xe66fbf7b: types.InputMediaDice, + 0xa8763ab5: types.InputMediaDocument, + 0x779600f9: types.InputMediaDocumentExternal, + 0x9664f57f: types.InputMediaEmpty, + 0xd33f43f3: types.InputMediaGame, + 0x971fa843: types.InputMediaGeoLive, + 0xf9c44144: types.InputMediaGeoPoint, + 0x405fef0d: types.InputMediaInvoice, + 0xc4103386: types.InputMediaPaidMedia, + 0xe3af4434: types.InputMediaPhoto, + 0xe5bbfe1a: types.InputMediaPhotoExternal, + 0x883a4108: types.InputMediaPoll, + 0xf3a9244a: types.InputMediaStakeDice, + 0x89fdd778: types.InputMediaStory, + 0x9fc55fde: types.InputMediaTodo, + 0x037c9330: types.InputMediaUploadedDocument, + 0x7d8375da: types.InputMediaUploadedPhoto, + 0xc13d1c11: types.InputMediaVenue, + 0xc21b8849: types.InputMediaWebPage, + 0xacfa1a7e: types.InputMessageCallbackQuery, + 0x208e68c9: types.InputMessageEntityMentionName, + 0xa676a322: types.InputMessageID, + 0x86872538: types.InputMessagePinned, + 0x402b4495: types.InputMessageReadMetric, + 0xbad88395: types.InputMessageReplyTo, + 0x3a20ecb8: types.InputMessagesFilterChatPhotos, + 0xe062db83: types.InputMessagesFilterContacts, + 0x9eddf188: types.InputMessagesFilterDocument, + 0x57e2f66c: types.InputMessagesFilterEmpty, + 0xe7026d0d: types.InputMessagesFilterGeo, + 0xffc86587: types.InputMessagesFilterGif, + 0x3751b49e: types.InputMessagesFilterMusic, + 0xc1f8e69a: types.InputMessagesFilterMyMentions, + 0x80c99768: types.InputMessagesFilterPhoneCalls, + 0x56e9f0e4: types.InputMessagesFilterPhotoVideo, + 0x9609a51c: types.InputMessagesFilterPhotos, + 0x1bb00451: types.InputMessagesFilterPinned, + 0xfa2bc90a: types.InputMessagesFilterPoll, + 0xb549da53: types.InputMessagesFilterRoundVideo, + 0x7a7c17a4: types.InputMessagesFilterRoundVoice, + 0x7ef0dd87: types.InputMessagesFilterUrl, + 0x9fc00e65: types.InputMessagesFilterVideo, + 0x50f5c392: types.InputMessagesFilterVoice, + 0xb1db7c7e: types.InputNotifyBroadcasts, + 0x4a95e84e: types.InputNotifyChats, + 0x27bb1adc: types.InputNotifyCommunity, + 0x5c467992: types.InputNotifyForumTopic, + 0xb8bc5b0c: types.InputNotifyPeer, + 0x193b4417: types.InputNotifyUsers, + 0x574b617f: types.InputPageBlockMap, + 0x5b1ccb28: types.InputPasskeyCredentialFirebasePNV, + 0x3c27b78f: types.InputPasskeyCredentialPublicKey, + 0xc31fc14a: types.InputPasskeyResponseLogin, + 0x3e63935c: types.InputPasskeyResponseRegister, + 0x3417d728: types.InputPaymentCredentials, + 0x0aa1c39f: types.InputPaymentCredentialsApplePay, + 0x8ac32801: types.InputPaymentCredentialsGooglePay, + 0xc10eb2cf: types.InputPaymentCredentialsSaved, + 0x27bcbbfc: types.InputPeerChannel, + 0xbd2a0840: types.InputPeerChannelFromMessage, + 0x35a95cb9: types.InputPeerChat, + 0xb8ea86a9: types.InputPeerColorCollectible, + 0x7f3b18ea: types.InputPeerEmpty, + 0xcacb6ae2: types.InputPeerNotifySettings, + 0x37257e99: types.InputPeerPhotoFileLocation, + 0x7da07ec9: types.InputPeerSelf, + 0xdde8a54c: types.InputPeerUser, + 0xa87b0a1c: types.InputPeerUserFromMessage, + 0x1e36fded: types.InputPhoneCall, + 0x6a1dc4be: types.InputPhoneContact, + 0x3bb3b94a: types.InputPhoto, + 0x1cd7bf0d: types.InputPhotoEmpty, + 0x40181ffe: types.InputPhotoFileLocation, + 0xd83466f3: types.InputPhotoLegacyFileLocation, + 0x199fed96: types.InputPollAnswer, + 0x3823cc40: types.InputPrivacyKeyAbout, + 0xd1219bdd: types.InputPrivacyKeyAddedByPhone, + 0xd65a11cc: types.InputPrivacyKeyBirthday, + 0xbdfb0426: types.InputPrivacyKeyChatInvite, + 0xa4dd4c08: types.InputPrivacyKeyForwards, + 0xbdc597b4: types.InputPrivacyKeyNoPaidMessages, + 0xfabadc5f: types.InputPrivacyKeyPhoneCall, + 0x0352dafa: types.InputPrivacyKeyPhoneNumber, + 0xdb9e70d2: types.InputPrivacyKeyPhoneP2P, + 0x5719bacc: types.InputPrivacyKeyProfilePhoto, + 0x4dbe9226: types.InputPrivacyKeySavedMusic, + 0xe1732341: types.InputPrivacyKeyStarGiftsAutoSave, + 0x4f96cb18: types.InputPrivacyKeyStatusTimestamp, + 0xaee69d68: types.InputPrivacyKeyVoiceMessages, + 0x184b35ce: types.InputPrivacyValueAllowAll, + 0x5a4fcce5: types.InputPrivacyValueAllowBots, + 0x840649cf: types.InputPrivacyValueAllowChatParticipants, + 0x2f453e49: types.InputPrivacyValueAllowCloseFriends, + 0x0d09e07b: types.InputPrivacyValueAllowContacts, + 0x77cdc9f1: types.InputPrivacyValueAllowPremium, + 0x131cc67f: types.InputPrivacyValueAllowUsers, + 0xd66b66c9: types.InputPrivacyValueDisallowAll, + 0xc4e57915: types.InputPrivacyValueDisallowBots, + 0xe94f0f86: types.InputPrivacyValueDisallowChatParticipants, + 0x0ba52007: types.InputPrivacyValueDisallowContacts, + 0x90110467: types.InputPrivacyValueDisallowUsers, + 0x24596d41: types.InputQuickReplyShortcut, + 0x01190cf1: types.InputQuickReplyShortcutId, + 0x4119b95e: types.InputReplyToEphemeralMessage, + 0x3bd4b7c2: types.InputReplyToMessage, + 0x69d66c45: types.InputReplyToMonoForum, + 0x5881323a: types.InputReplyToStory, + 0xadf44ee3: types.InputReportReasonChildAbuse, + 0x9b89f93a: types.InputReportReasonCopyright, + 0xf5ddd6e7: types.InputReportReasonFake, + 0xdbd4feed: types.InputReportReasonGeoIrrelevant, + 0x0a8eb2be: types.InputReportReasonIllegalDrugs, + 0xc1e4a2b1: types.InputReportReasonOther, + 0x9ec7863d: types.InputReportReasonPersonalDetails, + 0x2e59d922: types.InputReportReasonPornography, + 0x58dbcab8: types.InputReportReasonSpam, + 0x1e22c78d: types.InputReportReasonViolence, + 0x83281dbd: types.InputRichFileDocument, + 0x9b00622b: types.InputRichFilePhoto, + 0xe4c449fc: types.InputRichMessage, + 0xdacb836a: types.InputRichMessageHTML, + 0x004b572c: types.InputRichMessageMarkdown, + 0xf101aa7f: types.InputSavedStarGiftChat, + 0x2085c238: types.InputSavedStarGiftSlug, + 0x69279795: types.InputSavedStarGiftUser, + 0x5367e5be: types.InputSecureFile, + 0xcbc7ee28: types.InputSecureFileLocation, + 0x3334b0f0: types.InputSecureFileUploaded, + 0xdb21d0a7: types.InputSecureValue, + 0xa937c7be: types.InputSendMessageRichMessageDraftAction, + 0x1cc6e91f: types.InputSingleMedia, + 0x02e16c98: types.InputStarGiftAuction, + 0x7ab58308: types.InputStarGiftAuctionSlug, + 0x206ae6d1: types.InputStarsTransaction, + 0x028703c8: types.InputStickerSetAnimatedEmoji, + 0x0cde3739: types.InputStickerSetAnimatedEmojiAnimations, + 0xe67f520e: types.InputStickerSetDice, + 0x49748553: types.InputStickerSetEmojiChannelDefaultStatuses, + 0x29d0f5ee: types.InputStickerSetEmojiDefaultStatuses, + 0x44c1f8e9: types.InputStickerSetEmojiDefaultTopicIcons, + 0x04c4d4ce: types.InputStickerSetEmojiGenericAnimations, + 0xffb62b95: types.InputStickerSetEmpty, + 0x9de7a269: types.InputStickerSetID, + 0x32da9e9c: types.InputStickerSetItem, + 0xc88b3b02: types.InputStickerSetPremiumGifts, + 0x861cc8a0: types.InputStickerSetShortName, + 0x9d84f3db: types.InputStickerSetThumb, + 0x1cf671a0: types.InputStickerSetTonGifts, + 0x0438865b: types.InputStickeredMediaDocument, + 0x4a992157: types.InputStickeredMediaPhoto, + 0x3fc18057: types.InputStorePaymentAuthCode, + 0x616f7fe8: types.InputStorePaymentGiftPremium, + 0xfb790393: types.InputStorePaymentPremiumGiftCode, + 0x160544ca: types.InputStorePaymentPremiumGiveaway, + 0xa6751e66: types.InputStorePaymentPremiumSubscription, + 0x1d741ef7: types.InputStorePaymentStarsGift, + 0x751f08fa: types.InputStorePaymentStarsGiveaway, + 0xf9a2a6cb: types.InputStorePaymentStarsTopup, + 0x29be5899: types.InputTakeoutFileLocation, + 0x3c5693e9: types.InputTheme, + 0x8fde504f: types.InputThemeSettings, + 0xf5890df1: types.InputThemeSlug, + 0xf21158c6: types.InputUser, + 0xb98886cf: types.InputUserEmpty, + 0x1da448e2: types.InputUserFromMessage, + 0xf7c1b13f: types.InputUserSelf, + 0xe630b979: types.InputWallPaper, + 0x967a462e: types.InputWallPaperNoFile, + 0x72091c80: types.InputWallPaperSlug, + 0x9bed434d: types.InputWebDocument, + 0xf46fe924: types.InputWebFileAudioAlbumThumbLocation, + 0x9f2221c9: types.InputWebFileGeoPointLocation, + 0xc239d686: types.InputWebFileLocation, + 0xc78fe460: functions.messages.InstallStickerSetRequest, + 0xc727bb3b: functions.account.InstallThemeRequest, + 0xfeed5769: functions.account.InstallWallPaperRequest, + 0xca8ae8ba: functions.account.InvalidateSignInCodesRequest, + 0xbcf22685: functions.phone.InviteConferenceCallParticipantRequest, + 0x18cb9f78: types.help.InviteText, + 0xc9e33d54: functions.channels.InviteToChannelRequest, + 0x7b393160: functions.phone.InviteToGroupCallRequest, + 0x7f5defa6: types.messages.InvitedUsers, + 0x049ee584: types.Invoice, + 0xcb9f372d: functions.InvokeAfterMsgRequest, + 0x3dc4b4f0: functions.InvokeAfterMsgsRequest, + 0x087fc5e7: functions.bots.InvokeWebViewCustomMethodRequest, + 0x0dae54f8: functions.InvokeWithApnsSecretRequest, + 0xdd289f8e: functions.InvokeWithBusinessConnectionRequest, + 0x1df92984: functions.InvokeWithGooglePlayIntegrityRequest, + 0xda9b0d0d: functions.InvokeWithLayerRequest, + 0x365275f2: functions.InvokeWithMessagesRangeRequest, + 0xadbb0f94: functions.InvokeWithReCaptchaRequest, + 0xaca9fd2e: functions.InvokeWithTakeoutRequest, + 0xbf9459b7: functions.InvokeWithoutUpdatesRequest, + 0xd433ad73: types.IpPort, + 0x37982646: types.IpPortSecret, + 0x0edc39d0: functions.smsjobs.IsEligibleToJoinRequest, + 0xa74ece2d: functions.smsjobs.JoinRequest, + 0xafe5623f: types.phone.JoinAsPeers, + 0x7f6a1e22: functions.channels.JoinChannelRequest, + 0xae152a69: types.JoinChatBotResultApproved, + 0x0efa0194: types.JoinChatBotResultDeclined, + 0x98a3a840: types.JoinChatBotResultQueued, + 0xd6e3b813: types.JoinChatBotResultWebView, + 0xa6b1e39a: functions.chatlists.JoinChatlistInviteRequest, + 0xe089f8f5: functions.chatlists.JoinChatlistUpdatesRequest, + 0x8fb53057: functions.phone.JoinGroupCallRequest, + 0xcbea6bc4: functions.phone.JoinGroupCallPresentationRequest, + 0xf7444763: types.JsonArray, + 0xc7345e6a: types.JsonBool, + 0x3f6d7b68: types.JsonNull, + 0x2be0dfa4: types.JsonNumber, + 0x99c1d49d: types.JsonObject, + 0xc0de1bd9: types.JsonObjectValue, + 0xb71e767a: types.JsonString, + 0x2f67a72f: types.KeyboardButton, + 0x77608b83: types.KeyboardButtonRow, + 0x4fdd3430: types.KeyboardButtonStyle, + 0x11c1a322: types.KeyboardInlineButton, + 0x19420af6: types.KeyboardInlineButtonRow, + 0xcb296bf8: types.LabeledPrice, + 0xf385c1f6: types.LangPackDifference, + 0xeeca5ce3: types.LangPackLanguage, + 0xcad181f6: types.LangPackString, + 0x2979eeb2: types.LangPackStringDeleted, + 0x6c47ac9f: types.LangPackStringPluralized, + 0x5ff58f20: functions.payments.LaunchPrepaidGiveawayRequest, + 0x9898ad73: functions.smsjobs.LeaveRequest, + 0xf836aa95: functions.channels.LeaveChannelRequest, + 0x74fae13a: functions.chatlists.LeaveChatlistRequest, + 0x500377f9: functions.phone.LeaveGroupCallRequest, + 0x1c50d144: functions.phone.LeaveGroupCallPresentationRequest, + 0x621d5fa0: functions.stats.LoadAsyncGraphRequest, + 0x3e72ba19: functions.auth.LogOutRequest, + 0xc3a2835f: types.auth.LoggedOut, + 0x629f1980: types.auth.LoginToken, + 0x068e9916: types.auth.LoginTokenMigrateTo, + 0x390d5c5e: types.auth.LoginTokenSuccess, + 0x8c5006f8: functions.messages.MarkDialogUnreadRequest, + 0xaed6dbb2: types.MaskCoords, + 0x770416af: types.MediaAreaChannelPost, + 0xcfc9e002: types.MediaAreaCoordinates, + 0xcad5452d: types.MediaAreaGeoPoint, + 0x5787686d: types.MediaAreaStarGift, + 0x14455871: types.MediaAreaSuggestedReaction, + 0x37381085: types.MediaAreaUrl, + 0xbe82db9c: types.MediaAreaVenue, + 0x49a6549c: types.MediaAreaWeather, + 0xef7ff916: types.stats.MegagroupStats, + 0x7600b9d3: types.Message, + 0xcc02aa6d: types.MessageActionBoostApply, + 0xc516d679: types.MessageActionBotAllowed, + 0x5d20bae8: types.MessageActionChangeCommunity, + 0xe188503b: types.MessageActionChangeCreator, + 0x95d2ac92: types.MessageActionChannelCreate, + 0xea3948e9: types.MessageActionChannelMigrateFrom, + 0x15cefd00: types.MessageActionChatAddUser, + 0xbd47cbad: types.MessageActionChatCreate, + 0x95e3fbef: types.MessageActionChatDeletePhoto, + 0xa43f30cc: types.MessageActionChatDeleteUser, + 0x7fcb13a8: types.MessageActionChatEditPhoto, + 0xb5a1ce5a: types.MessageActionChatEditTitle, + 0x031224c3: types.MessageActionChatJoinedByLink, + 0xebbca3cb: types.MessageActionChatJoinedByRequest, + 0x4a8bfe80: types.MessageActionChatJoinedViaCommunity, + 0xe1037f92: types.MessageActionChatMigrateTo, + 0x2ffe2f7a: types.MessageActionConferenceCall, + 0xf3f25f76: types.MessageActionContactSignUp, + 0xfae69f56: types.MessageActionCustomAction, + 0xb6aef7b0: types.MessageActionEmpty, + 0x92a72876: types.MessageActionGameScore, + 0x98e0d697: types.MessageActionGeoProximityReached, + 0x31c48347: types.MessageActionGiftCode, + 0x48e91302: types.MessageActionGiftPremium, + 0x45d5b021: types.MessageActionGiftStars, + 0xa8a3c699: types.MessageActionGiftTon, + 0xa80f51e4: types.MessageActionGiveawayLaunch, + 0x87e2f155: types.MessageActionGiveawayResults, + 0x7a0d7f42: types.MessageActionGroupCall, + 0xb3a07661: types.MessageActionGroupCallScheduled, + 0x9fbab604: types.MessageActionHistoryClear, + 0x502f92f7: types.MessageActionInviteToGroupCall, + 0x16605e3e: types.MessageActionManagedBotCreated, + 0xb07ed085: types.MessageActionNewCreatorPending, + 0x3e2793ba: types.MessageActionNoForwardsRequest, + 0xbf7d6572: types.MessageActionNoForwardsToggle, + 0x84b88578: types.MessageActionPaidMessagesPrice, + 0xac1f1fcd: types.MessageActionPaidMessagesRefunded, + 0x41b3e202: types.MessageActionPaymentRefunded, + 0xc624b16e: types.MessageActionPaymentSent, + 0xffa00ccc: types.MessageActionPaymentSentMe, + 0x80e11a7f: types.MessageActionPhoneCall, + 0x94bd38ed: types.MessageActionPinMessage, + 0x9da1cd6c: types.MessageActionPollAppendAnswer, + 0x399674dc: types.MessageActionPollDeleteAnswer, + 0xb00c47a2: types.MessageActionPrizeStars, + 0x31518e9b: types.MessageActionRequestedPeer, + 0x93b31848: types.MessageActionRequestedPeerSentMe, + 0x4792929b: types.MessageActionScreenshotTaken, + 0xd95c6154: types.MessageActionSecureValuesSent, + 0x1b287353: types.MessageActionSecureValuesSentMe, + 0xb91bbd3a: types.MessageActionSetChatTheme, + 0x5060a3f4: types.MessageActionSetChatWallPaper, + 0x3c134d7b: types.MessageActionSetMessagesTTL, + 0xea2c31d3: types.MessageActionStarGift, + 0x774278d4: types.MessageActionStarGiftPurchaseOffer, + 0x73ada76b: types.MessageActionStarGiftPurchaseOfferDeclined, + 0x7e1c1187: types.MessageActionStarGiftUnique, + 0x2c8f2a25: types.MessageActionSuggestBirthday, + 0x57de635e: types.MessageActionSuggestProfilePhoto, + 0xee7a1596: types.MessageActionSuggestedPostApproval, + 0x69f916f8: types.MessageActionSuggestedPostRefund, + 0x95ddcf69: types.MessageActionSuggestedPostSuccess, + 0xc7edbc83: types.MessageActionTodoAppendTasks, + 0xcc7c5c89: types.MessageActionTodoCompletions, + 0x0d999256: types.MessageActionTopicCreate, + 0xc0944820: types.MessageActionTopicEdit, + 0xb4c38cb5: types.MessageActionWebViewDataSent, + 0x47dd8079: types.MessageActionWebViewDataSentMe, + 0x26b5dde6: types.messages.MessageEditData, + 0x90a6ca84: types.MessageEmpty, + 0x761e6af4: types.MessageEntityBankCard, + 0xf1ccaaac: types.MessageEntityBlockquote, + 0xbd610bc9: types.MessageEntityBold, + 0x6cef8ac7: types.MessageEntityBotCommand, + 0x4c4e743f: types.MessageEntityCashtag, + 0x28a20571: types.MessageEntityCode, + 0xc8cf05f8: types.MessageEntityCustomEmoji, + 0x0652c1c5: types.MessageEntityDiffDelete, + 0x71777116: types.MessageEntityDiffInsert, + 0xc6c1e5a7: types.MessageEntityDiffReplace, + 0x64e475c2: types.MessageEntityEmail, + 0x904ac7c7: types.MessageEntityFormattedDate, + 0x6f635b0d: types.MessageEntityHashtag, + 0x826f8b60: types.MessageEntityItalic, + 0xfa04579d: types.MessageEntityMention, + 0xdc7b1140: types.MessageEntityMentionName, + 0x9b69e34b: types.MessageEntityPhone, + 0x73924be0: types.MessageEntityPre, + 0x32ca960f: types.MessageEntitySpoiler, + 0xbf0693d4: types.MessageEntityStrike, + 0x76a6d327: types.MessageEntityTextUrl, + 0x9c4e7e8b: types.MessageEntityUnderline, + 0xbb92ba95: types.MessageEntityUnknown, + 0x6ed02538: types.MessageEntityUrl, + 0xee479c64: types.MessageExtendedMedia, + 0xad628cc8: types.MessageExtendedMediaPreview, + 0x4e4df4bb: types.MessageFwdHeader, + 0x70322949: types.MessageMediaContact, + 0x08cbec07: types.MessageMediaDice, + 0x52d8ccd9: types.MessageMediaDocument, + 0x3ded6320: types.MessageMediaEmpty, + 0xfdb19008: types.MessageMediaGame, + 0x56e0d474: types.MessageMediaGeo, + 0xb940c666: types.MessageMediaGeoLive, + 0xaa073beb: types.MessageMediaGiveaway, + 0xceaa3ea1: types.MessageMediaGiveawayResults, + 0xf6a548d3: types.MessageMediaInvoice, + 0xa8852491: types.MessageMediaPaidMedia, + 0xe216eb63: types.MessageMediaPhoto, + 0x773f4e66: types.MessageMediaPoll, + 0x68cb6283: types.MessageMediaStory, + 0x8a53b014: types.MessageMediaToDo, + 0x9f84f49e: types.MessageMediaUnsupported, + 0x2ec0533f: types.MessageMediaVenue, + 0xca5cab89: types.MessageMediaVideoStream, + 0xddf10c3b: types.MessageMediaWebPage, + 0x8c79b63c: types.MessagePeerReaction, + 0xb6cc2d5c: types.MessagePeerVote, + 0x74cda504: types.MessagePeerVoteInputOption, + 0x4628f6e6: types.MessagePeerVoteMultiple, + 0x0ae30253: types.MessageRange, + 0x0a339f0b: types.MessageReactions, + 0x31bd492d: types.messages.MessageReactionsList, + 0x4ba3a95a: types.MessageReactor, + 0x83d60fc2: types.MessageReplies, + 0x1b97dd66: types.MessageReplyHeader, + 0x0e5af939: types.MessageReplyStoryHeader, + 0x7903e3d9: types.MessageReportOption, + 0x7a800e0a: types.MessageService, + 0x7fe91c14: types.stats.MessageStats, + 0x455b853d: types.MessageViews, + 0xb6c4f543: types.messages.MessageViews, + 0x1d73e7ea: types.messages.Messages, + 0x74535f21: types.messages.MessagesNotModified, + 0x5f206716: types.messages.MessagesSlice, + 0xa2875319: functions.messages.MigrateChatRequest, + 0x628c9224: types.MissingInvitee, + 0x64407ea7: types.MonoForumDialog, + 0x276d3ec6: types.MsgDetailedInfo, + 0x809db6df: types.MsgNewDetailedInfo, + 0x7d861a08: types.MsgResendReq, + 0x62d6b459: types.MsgsAck, + 0x8cc0d131: types.MsgsAllInfo, + 0x04deb57d: types.MsgsStateInfo, + 0xda69fb52: types.MsgsStateReq, + 0xc448415c: types.MyBoost, + 0x9ae228e2: types.premium.MyBoosts, + 0xfaff629d: types.messages.MyStickers, + 0x8e1a1775: types.NearestDc, + 0x9ec20908: types.NewSessionCreated, + 0xc45a6536: types.help.NoAppUpdate, + 0x97e8bebe: types.NotificationSoundDefault, + 0x830b9ae4: types.NotificationSoundLocal, + 0x6f0c34df: types.NotificationSoundNone, + 0xff6c8049: types.NotificationSoundRingtone, + 0xd612e8ef: types.NotifyBroadcasts, + 0xc007cec3: types.NotifyChats, + 0xbe376999: types.NotifyCommunity, + 0x226e6308: types.NotifyForumTopic, + 0x9fd40bd8: types.NotifyPeer, + 0xb4c83b4c: types.NotifyUsers, + 0x3bb842ac: types.OutboxReadDate, + 0x83c95aec: types.PQInnerData, + 0xa9f55f95: types.PQInnerDataDc, + 0x3c6a84d4: types.PQInnerDataTemp, + 0x56fddf88: types.PQInnerDataTempDc, + 0x98657f0d: types.Page, + 0xce0d37b0: types.PageBlockAnchor, + 0x804361ea: types.PageBlockAudio, + 0xbaafe5e0: types.PageBlockAuthorDate, + 0x66d1670b: types.PageBlockBlockquote, + 0x0e6e47c4: types.PageBlockBlockquoteBlocks, + 0x6d640318: types.PageBlockButtonRow, + 0xef1751b5: types.PageBlockChannel, + 0x65a0fa4d: types.PageBlockCollage, + 0x39f23300: types.PageBlockCover, + 0x76768bed: types.PageBlockDetails, + 0xdb20b188: types.PageBlockDivider, + 0x38fa3ba3: types.PageBlockDocument, + 0xa8718dc5: types.PageBlockEmbed, + 0xf259a80b: types.PageBlockEmbedPost, + 0x48870999: types.PageBlockFooter, + 0xbfd064ec: types.PageBlockHeader, + 0xbaff072f: types.PageBlockHeading1, + 0x096b2aec: types.PageBlockHeading2, + 0x67e731ad: types.PageBlockHeading3, + 0xb532772b: types.PageBlockHeading4, + 0xdbbe6c6a: types.PageBlockHeading5, + 0x682a41a9: types.PageBlockHeading6, + 0x1e148390: types.PageBlockKicker, + 0xe4e88011: types.PageBlockList, + 0xa44f3ef6: types.PageBlockMap, + 0x59080c20: types.PageBlockMath, + 0x1fd6f6c1: types.PageBlockOrderedList, + 0x467a0766: types.PageBlockParagraph, + 0x1759c560: types.PageBlockPhoto, + 0xc070d93e: types.PageBlockPreformatted, + 0x4f4456d3: types.PageBlockPullquote, + 0x16115a96: types.PageBlockRelatedArticles, + 0x031f9590: types.PageBlockSlideshow, + 0xf12bb6e1: types.PageBlockSubheader, + 0x8ffa9a1f: types.PageBlockSubtitle, + 0xbf4dea82: types.PageBlockTable, + 0x3c29a3e2: types.PageBlockThinking, + 0x70abc3fd: types.PageBlockTitle, + 0x13567e8a: types.PageBlockUnsupported, + 0x7c8fe7b6: types.PageBlockVideo, + 0x692a5488: types.PageButton, + 0x6f747657: types.PageCaption, + 0x63ca67aa: types.PageListItemBlocks, + 0x2f58683c: types.PageListItemText, + 0x8ff2d5f0: types.PageListOrderedItemBlocks, + 0x15031189: types.PageListOrderedItemText, + 0xb390dc08: types.PageRelatedArticle, + 0x34566b6a: types.PageTableCell, + 0xe0c0c5e5: types.PageTableRow, + 0x1e109708: types.account.PaidMessagesRevenue, + 0x1f0c1ad9: types.PaidReactionPrivacyAnonymous, + 0x206ad49e: types.PaidReactionPrivacyDefault, + 0xdc6cfcf0: types.PaidReactionPrivacyPeer, + 0x8d78512a: types.communities.ParticipantJoinedChats, + 0x98613ebf: types.Passkey, + 0xe2037789: types.auth.PasskeyLoginOptions, + 0xe16b5ce1: types.account.PasskeyRegistrationOptions, + 0xf8e0aa1c: types.account.Passkeys, + 0xa098d6af: types.help.PassportConfig, + 0xbfb9f457: types.help.PassportConfigNotModified, + 0x957b50fb: types.account.Password, + 0xc23727c9: types.account.PasswordInputSettings, + 0x3a912d4a: types.PasswordKdfAlgoSHA256SHA256PBKDF2HMACSHA512iter100000SHA256ModPow, + 0xd45ab096: types.PasswordKdfAlgoUnknown, + 0x137948a5: types.auth.PasswordRecovery, + 0x9a5c33e5: types.account.PasswordSettings, + 0xea02c27e: types.PaymentCharge, + 0xa0058751: types.payments.PaymentForm, + 0x88f8f21b: types.PaymentFormMethod, + 0xb425cfe1: types.payments.PaymentFormStarGift, + 0x7bf6b15c: types.payments.PaymentFormStars, + 0x70c4fe03: types.payments.PaymentReceipt, + 0xdabbf83a: types.payments.PaymentReceiptStars, + 0x909c3f94: types.PaymentRequestedInfo, + 0x4e5f810d: types.payments.PaymentResult, + 0xcdc27a1f: types.PaymentSavedCredentialsCard, + 0xd8411139: types.payments.PaymentVerificationNeeded, + 0xe8fd8014: types.PeerBlocked, + 0xa2a5371e: types.PeerChannel, + 0x36c6019a: types.PeerChat, + 0xb54b5acf: types.PeerColor, + 0xb9c0639a: types.PeerColorCollectible, + 0xadec6ebe: types.help.PeerColorOption, + 0x767d61eb: types.help.PeerColorProfileSet, + 0x26219a58: types.help.PeerColorSet, + 0x00f8ed08: types.help.PeerColors, + 0x2ba1f5ce: types.help.PeerColorsNotModified, + 0x3371c354: types.messages.PeerDialogs, + 0x2244afad: types.communities.PeerLinkRequests, + 0xca461b5d: types.PeerLocated, + 0x99622c0c: types.PeerNotifySettings, + 0xf8ec284b: types.PeerSelfLocated, + 0xf47741f7: types.PeerSettings, + 0x6880b94d: types.messages.PeerSettings, + 0x9a35e999: types.PeerStories, + 0xcae68768: types.stories.PeerStories, + 0x59511722: types.PeerUser, + 0xe7e82e12: types.PendingSuggestion, + 0x30535af5: types.PhoneCall, + 0xec82e140: types.phone.PhoneCall, + 0x3660c311: types.PhoneCallAccepted, + 0xfaf7e8c9: types.PhoneCallDiscardReasonBusy, + 0xe095c1a0: types.PhoneCallDiscardReasonDisconnect, + 0x57adc690: types.PhoneCallDiscardReasonHangup, + 0x9fbbf1f7: types.PhoneCallDiscardReasonMigrateConferenceCall, + 0x85e42301: types.PhoneCallDiscardReasonMissed, + 0x50ca4de1: types.PhoneCallDiscarded, + 0x5366c915: types.PhoneCallEmpty, + 0xfc878fc8: types.PhoneCallProtocol, + 0x14b0ed0c: types.PhoneCallRequested, + 0xc5226f17: types.PhoneCallWaiting, + 0x9cc123c7: types.PhoneConnection, + 0x635fe375: types.PhoneConnectionWebrtc, + 0xfb197a65: types.Photo, + 0x20212ca8: types.photos.Photo, + 0x021e1ad6: types.PhotoCachedSize, + 0x2331b22d: types.PhotoEmpty, + 0xd8214d41: types.PhotoPathSize, + 0x75c78e60: types.PhotoSize, + 0x0e17e23c: types.PhotoSizeEmpty, + 0xfa3efb95: types.PhotoSizeProgressive, + 0xe0b0bc2e: types.PhotoStrippedSize, + 0x8dca6aa5: types.photos.Photos, + 0x15051f54: types.photos.PhotosSlice, + 0x7abe77ec: functions.PingRequest, + 0xf3427b8c: functions.PingDelayDisconnectRequest, + 0x966e2dbf: types.Poll, + 0x4b7d786a: types.PollAnswer, + 0x3645230a: types.PollAnswerVoters, + 0xba7bb15e: types.PollResults, + 0x2999beed: types.stats.PollStats, + 0x347773c5: types.Pong, + 0x1991b13b: types.bots.PopularAppBots, + 0x5ce14175: types.PopularContact, + 0x1e8caaeb: types.PostAddress, + 0xe7058e7f: types.PostInteractionCountersMessage, + 0x8a480e27: types.PostInteractionCountersStory, + 0x257e962b: types.PremiumGiftCodeOption, + 0x5334759c: types.help.PremiumPromo, + 0x5f2d1df2: types.PremiumSubscriptionOption, + 0xb2539d54: types.PrepaidGiveaway, + 0x9a9d77e0: types.PrepaidStarsGiveaway, + 0xff57708d: types.messages.PreparedInlineMessage, + 0x0ca71d64: types.bots.PreviewInfo, + 0xa486b761: types.PrivacyKeyAbout, + 0x42ffd42b: types.PrivacyKeyAddedByPhone, + 0x2000a518: types.PrivacyKeyBirthday, + 0x500e6dfa: types.PrivacyKeyChatInvite, + 0x69ec56a3: types.PrivacyKeyForwards, + 0x17d348d2: types.PrivacyKeyNoPaidMessages, + 0x3d662b7b: types.PrivacyKeyPhoneCall, + 0xd19ae46d: types.PrivacyKeyPhoneNumber, + 0x39491cc8: types.PrivacyKeyPhoneP2P, + 0x96151fed: types.PrivacyKeyProfilePhoto, + 0xff7a571b: types.PrivacyKeySavedMusic, + 0x2ca4fdf8: types.PrivacyKeyStarGiftsAutoSave, + 0xbc2eab30: types.PrivacyKeyStatusTimestamp, + 0x0697f414: types.PrivacyKeyVoiceMessages, + 0x50a04e45: types.account.PrivacyRules, + 0x65427b82: types.PrivacyValueAllowAll, + 0x21461b5d: types.PrivacyValueAllowBots, + 0x6b134e8e: types.PrivacyValueAllowChatParticipants, + 0xf7e8d89b: types.PrivacyValueAllowCloseFriends, + 0xfffe1bac: types.PrivacyValueAllowContacts, + 0xece9814b: types.PrivacyValueAllowPremium, + 0xb8905fb2: types.PrivacyValueAllowUsers, + 0x8b73e763: types.PrivacyValueDisallowAll, + 0xf6a5f82f: types.PrivacyValueDisallowBots, + 0x41c87565: types.PrivacyValueDisallowChatParticipants, + 0xf888fa1a: types.PrivacyValueDisallowContacts, + 0xe4621141: types.PrivacyValueDisallowUsers, + 0xab339c00: types.ProfileTabFiles, + 0xa2c0f695: types.ProfileTabGifs, + 0x4d4bd46a: types.ProfileTabGifts, + 0xd3656499: types.ProfileTabLinks, + 0x72c64955: types.ProfileTabMedia, + 0x9f27d26e: types.ProfileTabMusic, + 0xb98cd696: types.ProfileTabPosts, + 0xe477092e: types.ProfileTabVoice, + 0xb0d81a83: functions.messages.ProlongWebViewRequest, + 0x08a4d87a: types.help.PromoData, + 0x98f6ac75: types.help.PromoDataEmpty, + 0x01f2bf4a: types.PublicForwardMessage, + 0xedf3add0: types.PublicForwardStory, + 0x93037e20: types.stats.PublicForwards, + 0xc68d6695: types.messages.QuickReplies, + 0x5f91eb5b: types.messages.QuickRepliesNotModified, + 0x0697102b: types.QuickReply, + 0x7f1d072f: functions.messages.RateTranscribedAudioRequest, + 0xa3d1cb80: types.ReactionCount, + 0x8935fc73: types.ReactionCustomEmoji, + 0x1b2286b8: types.ReactionEmoji, + 0x79f5d419: types.ReactionEmpty, + 0x4b9e22a0: types.ReactionNotificationsFromAll, + 0xbac3a61a: types.ReactionNotificationsFromContacts, + 0x523da4eb: types.ReactionPaid, + 0xeafdf716: types.messages.Reactions, + 0xb06fdbdf: types.messages.ReactionsNotModified, + 0x71e4ea58: types.ReactionsNotifySettings, + 0xf731a9f4: functions.messages.ReadDiscussionRequest, + 0x7f4b690a: functions.messages.ReadEncryptedHistoryRequest, + 0x5b118126: functions.messages.ReadFeaturedStickersRequest, + 0x0e306d3a: functions.messages.ReadHistoryRequest, + 0xcc104937: functions.channels.ReadHistoryRequest, + 0x36e5bf4d: functions.messages.ReadMentionsRequest, + 0x36a73f77: functions.messages.ReadMessageContentsRequest, + 0xeab5dc38: functions.channels.ReadMessageContentsRequest, + 0x4a4ff172: types.ReadParticipantDate, + 0x1720b4d8: functions.messages.ReadPollVotesRequest, + 0x9ec44f93: functions.messages.ReadReactionsRequest, + 0xba4a3b5b: functions.messages.ReadSavedHistoryRequest, + 0xa556dac8: functions.stories.ReadStoriesRequest, + 0x17d54f61: functions.phone.ReceivedCallRequest, + 0x05a954c0: functions.messages.ReceivedMessagesRequest, + 0xa384b779: types.ReceivedNotifyMessage, + 0x55a5bb66: functions.messages.ReceivedQueueRequest, + 0xb2da71d2: types.RecentMeUrlChat, + 0xeb49081d: types.RecentMeUrlChatInvite, + 0xbc0a57dc: types.RecentMeUrlStickerSet, + 0x46e1d13d: types.RecentMeUrlUnknown, + 0xb92c09e2: types.RecentMeUrlUser, + 0x0e0310d7: types.help.RecentMeUrls, + 0x88d37c56: types.messages.RecentStickers, + 0x0b17f890: types.messages.RecentStickersNotModified, + 0x711d692d: types.RecentStory, + 0x37096c70: functions.auth.RecoverPasswordRequest, + 0x25ae8f4a: functions.payments.RefundStarsChargeRequest, + 0xec86017a: functions.account.RegisterDeviceRequest, + 0x55b41fd6: functions.account.RegisterPasskeyRequest, + 0xf7760f51: functions.stickers.RemoveStickerFromSetRequest, + 0x124b1c00: functions.stickers.RenameStickerSetRequest, + 0x8535fbd9: functions.stories.ReorderAlbumsRequest, + 0x3b1adf37: functions.messages.ReorderPinnedDialogsRequest, + 0x0e7841f0: functions.messages.ReorderPinnedForumTopicsRequest, + 0x8b716587: functions.messages.ReorderPinnedSavedDialogsRequest, + 0xb627f3aa: functions.bots.ReorderPreviewMediasRequest, + 0x60331907: functions.messages.ReorderQuickRepliesRequest, + 0xc32af4cc: functions.payments.ReorderStarGiftCollectionsRequest, + 0x78337739: functions.messages.ReorderStickerSetsRequest, + 0xef500eab: functions.account.ReorderUsernamesRequest, + 0xb45ced1d: functions.channels.ReorderUsernamesRequest, + 0x9709b1c2: functions.bots.ReorderUsernamesRequest, + 0x4696459a: functions.stickers.ReplaceStickerRequest, + 0xb2b15770: types.ReplyInlineMarkup, + 0x86b40b08: types.ReplyKeyboardForceReply, + 0xa03e5b85: types.ReplyKeyboardHide, + 0x85dd99d1: types.ReplyKeyboardMarkup, + 0xfc78af9b: functions.messages.ReportRequest, + 0x19d8eb45: functions.stories.ReportRequest, + 0xa850a693: functions.channels.ReportAntiSpamFalsePositiveRequest, + 0x4b0c8c0f: functions.messages.ReportEncryptedSpamRequest, + 0x8704f2bf: functions.ephemeral.ReportMessageRequest, + 0x5a6d7395: functions.messages.ReportMessagesDeliveryRequest, + 0xcb9deff6: functions.auth.ReportMissingCodeRequest, + 0xddbcd819: functions.messages.ReportMusicListenRequest, + 0xc5ba3d86: functions.account.ReportPeerRequest, + 0xfa8cc6f5: functions.account.ReportProfilePhotoRequest, + 0x3f64c076: functions.messages.ReportReactionRequest, + 0x4067c5e6: functions.messages.ReportReadMetricsRequest, + 0x6f09ac31: types.ReportResultAddComment, + 0xf0e4e0b6: types.ReportResultChooseOption, + 0x8db33c4b: types.ReportResultReported, + 0xcf1592db: functions.messages.ReportSpamRequest, + 0xf44a8315: functions.channels.ReportSpamRequest, + 0x12cbf0c4: functions.messages.ReportSponsoredMessageRequest, + 0xd712e4be: functions.ReqDHParamsRequest, + 0x60469778: functions.ReqPqRequest, + 0xbe7e8ef1: functions.ReqPqMultiRequest, + 0x53618bce: functions.messages.RequestAppWebViewRequest, + 0x42ff96ed: functions.phone.RequestCallRequest, + 0xba9ee679: functions.messages.RequestChatJoinWebViewRequest, + 0xf64daf43: functions.messages.RequestEncryptionRequest, + 0x8e39261e: functions.auth.RequestFirebaseSmsRequest, + 0xc9e01e7b: functions.messages.RequestMainWebViewRequest, + 0xd897bc66: functions.auth.RequestPasswordRecoveryRequest, + 0x339bef6c: types.RequestPeerTypeBroadcast, + 0xc9f06e1b: types.RequestPeerTypeChat, + 0x3e81e078: types.RequestPeerTypeCreateBot, + 0x5f3b8a00: types.RequestPeerTypeUser, + 0x413a3e73: functions.messages.RequestSimpleWebViewRequest, + 0x894cc99c: functions.messages.RequestUrlAuthRequest, + 0x269dc2c1: functions.messages.RequestWebViewRequest, + 0x31a2a35e: functions.bots.RequestWebViewButtonRequest, + 0xf13bbcd7: types.bots.RequestedButton, + 0x8ba403e4: types.RequestedPeerChannel, + 0x7307544f: types.RequestedPeerChat, + 0xd62ff46a: types.RequestedPeerUser, + 0x050a9839: types.RequirementToContactEmpty, + 0xb4f67e93: types.RequirementToContactPaidMessages, + 0xe581e4e9: types.RequirementToContactPremium, + 0x05162463: types.ResPQ, + 0x947a12df: types.payments.ResaleStarGifts, + 0xcae47523: functions.auth.ResendCodeRequest, + 0x7a7f2a15: functions.account.ResendPasswordEmailRequest, + 0xdf77f3bc: functions.account.ResetAuthorizationRequest, + 0x9fab0d1a: functions.auth.ResetAuthorizationsRequest, + 0x3d8de0f9: functions.bots.ResetBotCommandsRequest, + 0x7e960193: functions.auth.ResetLoginEmailRequest, + 0xdb7e1747: functions.account.ResetNotifySettingsRequest, + 0x9308ce1b: functions.account.ResetPasswordRequest, + 0xe3779861: types.account.ResetPasswordFailedWait, + 0xe926d63e: types.account.ResetPasswordOk, + 0xe9effc7d: types.account.ResetPasswordRequestedWait, + 0x879537f1: functions.contacts.ResetSavedRequest, + 0x1ae373ac: functions.contacts.ResetTopPeerRatingRequest, + 0xbb3b9804: functions.account.ResetWallPapersRequest, + 0x2d01b9ef: functions.account.ResetWebAuthorizationRequest, + 0x682d2594: functions.account.ResetWebAuthorizationsRequest, + 0x5492e5ee: functions.account.ResolveBusinessChatLinkRequest, + 0x8af94344: functions.contacts.ResolvePhoneRequest, + 0xe9ce781c: functions.payments.ResolveStarGiftOfferRequest, + 0x725afbbc: functions.contacts.ResolveUsernameRequest, + 0x9a23af21: types.account.ResolvedBusinessChatLinks, + 0x7f077ad9: types.contacts.ResolvedPeer, + 0x9ae91519: functions.channels.RestrictSponsoredMessagesRequest, + 0xd072acb4: types.RestrictionReason, + 0x9b2754a8: functions.upload.ReuploadCdnFileRequest, + 0x03c610bd: types.RichButtonStyle, + 0xbaf39d8b: types.RichMessage, + 0xa43ad8b7: types.RpcAnswerDropped, + 0xcd78e586: types.RpcAnswerDroppedRunning, + 0x5e2ad36e: types.RpcAnswerUnknown, + 0x58e4a740: functions.RpcDropAnswerRequest, + 0x2144ca19: types.RpcError, + 0x6f02f748: functions.help.SaveAppLogRequest, + 0x76f36233: functions.account.SaveAutoDownloadSettingsRequest, + 0xd69b8361: functions.account.SaveAutoSaveSettingsRequest, + 0xde7b673d: functions.upload.SaveBigFilePartRequest, + 0x277add7e: functions.phone.SaveCallDebugRequest, + 0x41248786: functions.phone.SaveCallLogRequest, + 0x575e1f8c: functions.phone.SaveDefaultGroupCallJoinAsRequest, + 0xccfddf96: functions.messages.SaveDefaultSendAsRequest, + 0x4167add1: functions.phone.SaveDefaultSendAsRequest, + 0xad0fa15c: functions.messages.SaveDraftRequest, + 0xb304a621: functions.upload.SaveFilePartRequest, + 0x327a30cb: functions.messages.SaveGifRequest, + 0xb26732a9: functions.account.SaveMusicRequest, + 0xf21f7f2f: functions.messages.SavePreparedInlineMessageRequest, + 0x392718f8: functions.messages.SaveRecentStickerRequest, + 0x3dea5b03: functions.account.SaveRingtoneRequest, + 0x899fe31d: functions.account.SaveSecureValueRequest, + 0x2a2a697c: functions.payments.SaveStarGiftRequest, + 0xf257106c: functions.account.SaveThemeRequest, + 0x1782cbb1: functions.aicompose.SaveToneRequest, + 0x6c5a5b37: functions.account.SaveWallPaperRequest, + 0xbd87cb6c: types.SavedDialog, + 0xf83ae221: types.messages.SavedDialogs, + 0xc01f6fe8: types.messages.SavedDialogsNotModified, + 0x44ba9dd9: types.messages.SavedDialogsSlice, + 0x84a02a0d: types.messages.SavedGifs, + 0xe8025ca2: types.messages.SavedGifsNotModified, + 0xfb8fe43c: types.payments.SavedInfo, + 0x34a2f297: types.users.SavedMusic, + 0x998d6636: types.account.SavedMusicIds, + 0x4fc81d6e: types.account.SavedMusicIdsNotModified, + 0xe3878aa4: types.users.SavedMusicNotModified, + 0x1142bd56: types.SavedPhoneContact, + 0xcb6ff828: types.SavedReactionTag, + 0x3259950a: types.messages.SavedReactionTags, + 0x889b59ef: types.messages.SavedReactionTagsNotModified, + 0xb7263f6d: types.account.SavedRingtone, + 0x1f307eb7: types.account.SavedRingtoneConverted, + 0xc1e92cc5: types.account.SavedRingtones, + 0xfbf6e8b1: types.account.SavedRingtonesNotModified, + 0x41df43fc: types.SavedStarGift, + 0x95f389b1: types.payments.SavedStarGifts, + 0x05f58d0f: functions.contacts.SearchRequest, + 0x29ee847a: functions.messages.SearchRequest, + 0xe844ebff: types.messages.SearchCounter, + 0x2c11c0d7: functions.messages.SearchCustomEmojiRequest, + 0x92b4494c: functions.messages.SearchEmojiStickerSetsRequest, + 0x6126a43c: functions.messages.SearchGlobalRequest, + 0xf2c4f24d: functions.channels.SearchPostsRequest, + 0xd1810907: functions.stories.SearchPostsRequest, + 0x3e0b5b6a: types.SearchPostsFlood, + 0x7f648b67: types.SearchResultPosition, + 0x147ee23c: types.messages.SearchResultsCalendar, + 0xc9b0539f: types.SearchResultsCalendarPeriod, + 0x53b22baf: types.messages.SearchResultsPositions, + 0x107e31a0: functions.messages.SearchSentMediaRequest, + 0x35705b8a: functions.messages.SearchStickerSetsRequest, + 0x29b1c66a: functions.messages.SearchStickersRequest, + 0x33f0ea47: types.SecureCredentialsEncrypted, + 0x8aeabec3: types.SecureData, + 0x7d09c27e: types.SecureFile, + 0x64199744: types.SecureFileEmpty, + 0xbbf2dda0: types.SecurePasswordKdfAlgoPBKDF2HMACSHA512iter100000, + 0x86471d92: types.SecurePasswordKdfAlgoSHA512, + 0x004a8537: types.SecurePasswordKdfAlgoUnknown, + 0x21ec5a5f: types.SecurePlainEmail, + 0x7d6099dd: types.SecurePlainPhone, + 0x829d99da: types.SecureRequiredType, + 0x027477b4: types.SecureRequiredTypeOneOf, + 0x1527bcac: types.SecureSecretSettings, + 0x187fa0ca: types.SecureValue, + 0x869d758f: types.SecureValueError, + 0xe8a40bd9: types.SecureValueErrorData, + 0x7a700873: types.SecureValueErrorFile, + 0x666220e9: types.SecureValueErrorFiles, + 0x00be3dfa: types.SecureValueErrorFrontSide, + 0x868a2aa5: types.SecureValueErrorReverseSide, + 0xe537ced6: types.SecureValueErrorSelfie, + 0xa1144770: types.SecureValueErrorTranslationFile, + 0x34636dd8: types.SecureValueErrorTranslationFiles, + 0xed1ecdb0: types.SecureValueHash, + 0xcbe31e26: types.SecureValueTypeAddress, + 0x89137c0d: types.SecureValueTypeBankStatement, + 0x06e425c4: types.SecureValueTypeDriverLicense, + 0x8e3ca7ee: types.SecureValueTypeEmail, + 0xa0d0744b: types.SecureValueTypeIdentityCard, + 0x99a48f23: types.SecureValueTypeInternalPassport, + 0x3dac6a00: types.SecureValueTypePassport, + 0x99e3806a: types.SecureValueTypePassportRegistration, + 0x9d2a81e3: types.SecureValueTypePersonalDetails, + 0xb320aadb: types.SecureValueTypePhone, + 0x8b883488: types.SecureValueTypeRentalAgreement, + 0xea02ec33: types.SecureValueTypeTemporaryRegistration, + 0xfc36954e: types.SecureValueTypeUtilityBill, + 0xb81c7034: types.SendAsPeer, + 0xf496b0c6: types.channels.SendAsPeers, + 0x6c5cf2a7: functions.messages.SendBotRequestedPeerRequest, + 0x82574ae5: functions.account.SendChangePhoneCodeRequest, + 0xa677244f: functions.auth.SendCodeRequest, + 0xc6701900: functions.phone.SendConferenceCallBroadcastRequest, + 0x1b3faa88: functions.account.SendConfirmPhoneCodeRequest, + 0xaa2769ed: functions.bots.SendCustomRequestRequest, + 0x44fa7a15: functions.messages.SendEncryptedRequest, + 0x5559481d: functions.messages.SendEncryptedFileRequest, + 0x32d439a4: functions.messages.SendEncryptedServiceRequest, + 0xe5afa56d: functions.phone.SendGroupCallEncryptedMessageRequest, + 0xb1d11410: functions.phone.SendGroupCallMessageRequest, + 0xc0cf7646: functions.messages.SendInlineBotResultRequest, + 0x0330e77f: functions.messages.SendMediaRequest, + 0xfef48f62: functions.messages.SendMessageRequest, + 0xba8d5f35: functions.ephemeral.SendMessageRequest, + 0xfd5ec8f5: types.SendMessageCancelAction, + 0x628cbc6f: types.SendMessageChooseContactAction, + 0xb05ac6b1: types.SendMessageChooseStickerAction, + 0x25972bcb: types.SendMessageEmojiInteraction, + 0xb665902e: types.SendMessageEmojiInteractionSeen, + 0xdd6a8f48: types.SendMessageGamePlayAction, + 0x176f8ba1: types.SendMessageGeoLocationAction, + 0xdbda9246: types.SendMessageHistoryImportAction, + 0xd52f73f7: types.SendMessageRecordAudioAction, + 0x88f27fbc: types.SendMessageRecordRoundAction, + 0xa187d66f: types.SendMessageRecordVideoAction, + 0x52564893: types.SendMessageRichMessageDraftAction, + 0xfbf902b0: types.SendMessageStopDraftAction, + 0x3630b85a: types.SendMessageTextDraftAction, + 0x16bf744e: types.SendMessageTypingAction, + 0xf351d7ab: types.SendMessageUploadAudioAction, + 0xaa0cd9e4: types.SendMessageUploadDocumentAction, + 0xd1d34a26: types.SendMessageUploadPhotoAction, + 0x243e1c66: types.SendMessageUploadRoundAction, + 0xe9763aec: types.SendMessageUploadVideoAction, + 0x1bf89d74: functions.messages.SendMultiMediaRequest, + 0x58bbcb50: functions.messages.SendPaidReactionRequest, + 0x2d03522f: functions.payments.SendPaymentFormRequest, + 0x6c750de1: functions.messages.SendQuickReplyMessagesRequest, + 0xd30d78d4: functions.messages.SendReactionRequest, + 0x7fd736b2: functions.stories.SendReactionRequest, + 0xbd38850a: functions.messages.SendScheduledMessagesRequest, + 0xa1405817: functions.messages.SendScreenshotNotificationRequest, + 0xff7a9383: functions.phone.SendSignalingDataRequest, + 0x8fb86b41: functions.payments.SendStarGiftOfferRequest, + 0x7998c914: functions.payments.SendStarsFormRequest, + 0x8f9e6898: functions.stories.SendStoryRequest, + 0x98e037bb: functions.account.SendVerifyEmailCodeRequest, + 0xa5a356f9: functions.account.SendVerifyPhoneCodeRequest, + 0x10ea6184: functions.messages.SendVoteRequest, + 0xdc0242c8: functions.messages.SendWebViewDataRequest, + 0x0a4314f5: functions.messages.SendWebViewResultMessageRequest, + 0x5e002502: types.auth.SentCode, + 0xf8827ebf: types.auth.SentCodePaymentRequired, + 0x2390fe44: types.auth.SentCodeSuccess, + 0x3dbb5986: types.auth.SentCodeTypeApp, + 0x5353e5a7: types.auth.SentCodeTypeCall, + 0xf450f59b: types.auth.SentCodeTypeEmailCode, + 0x009fd736: types.auth.SentCodeTypeFirebaseSms, + 0xab03c6d9: types.auth.SentCodeTypeFlashCall, + 0xd9565c39: types.auth.SentCodeTypeFragmentSms, + 0x82006484: types.auth.SentCodeTypeMissedCall, + 0xa5491dea: types.auth.SentCodeTypeSetUpEmailRequired, + 0xc000bba2: types.auth.SentCodeTypeSms, + 0xb37794af: types.auth.SentCodeTypeSmsPhrase, + 0xa416ac81: types.auth.SentCodeTypeSmsWord, + 0x811f854f: types.account.SentEmailCode, + 0x9493ff32: types.messages.SentEncryptedFile, + 0x560f8935: types.messages.SentEncryptedMessage, + 0xb5890dba: types.ServerDHInnerData, + 0x79cb045d: types.ServerDHParamsFail, + 0xd0e8075c: types.ServerDHParamsOk, + 0x2442485e: functions.account.SetAccountTTLRequest, + 0xbf899aa0: functions.account.SetAuthorizationTTLRequest, + 0x94c65c76: functions.contacts.SetBlockedRequest, + 0xad399cee: functions.channels.SetBoostsToUnblockRestrictionsRequest, + 0x788464e1: functions.bots.SetBotBroadcastDefaultAdminRightsRequest, + 0xd58f130a: functions.messages.SetBotCallbackAnswerRequest, + 0x0517165a: functions.bots.SetBotCommandsRequest, + 0x925ec9ea: functions.bots.SetBotGroupDefaultAdminRightsRequest, + 0xb8f106e3: functions.messages.SetBotGuestChatResultRequest, + 0x10cf3123: functions.bots.SetBotInfoRequest, + 0x4504d54f: functions.bots.SetBotMenuButtonRequest, + 0x09c2dd95: functions.messages.SetBotPrecheckoutResultsRequest, + 0xe5f672fa: functions.messages.SetBotShippingResultsRequest, + 0xec22cfcd: functions.help.SetBotUpdatesStatusRequest, + 0x59ead627: functions.phone.SetCallRatingRequest, + 0x864b2581: functions.messages.SetChatAvailableReactionsRequest, + 0x081202c9: functions.messages.SetChatThemeRequest, + 0x8ffacae1: functions.messages.SetChatWallPaperRequest, + 0xcff43f61: functions.account.SetContactSignUpNotificationRequest, + 0xb574b16b: functions.account.SetContentSettingsRequest, + 0x8b89dfbd: functions.bots.SetCustomVerificationRequest, + 0x9eb51445: functions.messages.SetDefaultHistoryTTLRequest, + 0x4f47a016: functions.messages.SetDefaultReactionRequest, + 0x40582bb2: functions.channels.SetDiscussionGroupRequest, + 0x3cd930b7: functions.channels.SetEmojiStickersRequest, + 0x791451ed: functions.messages.SetEncryptedTypingRequest, + 0x8ef8ecc0: functions.messages.SetGameScoreRequest, + 0x1edaaac2: functions.account.SetGlobalPrivacySettingsRequest, + 0xb80e5fe4: functions.messages.SetHistoryTTLRequest, + 0xbb12a419: functions.messages.SetInlineBotResultsRequest, + 0x15ad9f64: functions.messages.SetInlineGameScoreRequest, + 0xe71a4810: functions.bots.SetJoinChatResultsRequest, + 0x5dee78b0: functions.account.SetMainProfileTabRequest, + 0x3583fcb1: functions.channels.SetMainProfileTabRequest, + 0xc9f81ce8: functions.account.SetPrivacyRequest, + 0x316ce548: functions.account.SetReactionsNotifySettingsRequest, + 0x90c894b5: functions.users.SetSecureValueErrorsRequest, + 0xa76a5392: functions.stickers.SetStickerSetThumbRequest, + 0xea8ca4f9: functions.channels.SetStickersRequest, + 0x58943ee2: functions.messages.SetTypingRequest, + 0xf5045f1f: functions.SetClientDHParamsRequest, + 0xb6213cdf: types.ShippingOption, + 0x8d52a951: functions.auth.SignInRequest, + 0xaac7b717: functions.auth.SignUpRequest, + 0xe6a1eeb8: types.SmsJob, + 0xd92c2285: types.SpeakingInGroupCallAction, + 0x7dbf8673: types.SponsoredMessage, + 0x430d3150: types.SponsoredMessageReportOption, + 0x3e3bcf2f: types.channels.SponsoredMessageReportResultAdsHidden, + 0x846f9e42: types.channels.SponsoredMessageReportResultChooseOption, + 0xad798849: types.channels.SponsoredMessageReportResultReported, + 0xffda656d: types.messages.SponsoredMessages, + 0x1839490f: types.messages.SponsoredMessagesEmpty, + 0xc69708d3: types.SponsoredPeer, + 0xeb032884: types.contacts.SponsoredPeers, + 0xea32b4b1: types.contacts.SponsoredPeersEmpty, + 0x313a9547: types.StarGift, + 0xd31bc45d: types.StarGiftActiveAuctionState, + 0xaef6abbc: types.payments.StarGiftActiveAuctions, + 0xdb33dad0: types.payments.StarGiftActiveAuctionsNotModified, + 0x9f2504e4: types.StarGiftAttributeBackdrop, + 0x2eb1b658: types.StarGiftAttributeCounter, + 0x1f01c757: types.StarGiftAttributeIdBackdrop, + 0x48aaae3c: types.StarGiftAttributeIdModel, + 0x4a162433: types.StarGiftAttributeIdPattern, + 0x565251e2: types.StarGiftAttributeModel, + 0xe0bff26c: types.StarGiftAttributeOriginalDetails, + 0x4e7085ea: types.StarGiftAttributePattern, + 0x36437737: types.StarGiftAttributeRarity, + 0x78fbf3a8: types.StarGiftAttributeRarityEpic, + 0xcef7e7a8: types.StarGiftAttributeRarityLegendary, + 0xf08d516b: types.StarGiftAttributeRarityRare, + 0xdbce6389: types.StarGiftAttributeRarityUncommon, + 0x42b00348: types.StarGiftAuctionAcquiredGift, + 0x7d5bd1f0: types.payments.StarGiftAuctionAcquiredGifts, + 0x3aae0528: types.StarGiftAuctionRound, + 0x0aa021e5: types.StarGiftAuctionRoundExtendable, + 0x771a4e66: types.StarGiftAuctionState, + 0x6b39f4ec: types.payments.StarGiftAuctionState, + 0x972dabbf: types.StarGiftAuctionStateFinished, + 0xfe333952: types.StarGiftAuctionStateNotModified, + 0x2eeed1c4: types.StarGiftAuctionUserState, + 0xaff56398: types.StarGiftBackground, + 0x9d6b13b0: types.StarGiftCollection, + 0x8a2932f3: types.payments.StarGiftCollections, + 0xa0ba4f17: types.payments.StarGiftCollectionsNotModified, + 0x85f0a9cd: types.StarGiftUnique, + 0x46c6e36f: types.payments.StarGiftUpgradeAttributes, + 0x3de1dfed: types.payments.StarGiftUpgradePreview, + 0x99ea331d: types.StarGiftUpgradePrice, + 0x84aa3a9c: types.payments.StarGiftWithdrawalUrl, + 0x2ed82995: types.payments.StarGifts, + 0xa388a368: types.payments.StarGiftsNotModified, + 0xdd0c66f2: types.StarRefProgram, + 0xbbb6b4a3: types.StarsAmount, + 0x5e0589f1: types.StarsGiftOption, + 0x94ce852a: types.StarsGiveawayOption, + 0x54236209: types.StarsGiveawayWinnersOption, + 0x1b0e4f07: types.StarsRating, + 0x394e7f21: types.payments.StarsRevenueAdsAccountUrl, + 0x6c207376: types.payments.StarsRevenueStats, + 0xfebe5491: types.StarsRevenueStatus, + 0x1dab80b7: types.payments.StarsRevenueWithdrawalUrl, + 0x6c9ce8ed: types.payments.StarsStatus, + 0x2e6eab1a: types.StarsSubscription, + 0x05416d58: types.StarsSubscriptionPricing, + 0x74aee3e0: types.StarsTonAmount, + 0x0bd915c0: types.StarsTopupOption, + 0x13659eb0: types.StarsTransaction, + 0xd80da15d: types.StarsTransactionPeer, + 0xf9677aad: types.StarsTransactionPeerAPI, + 0x60682812: types.StarsTransactionPeerAds, + 0xb457b375: types.StarsTransactionPeerAppStore, + 0xe92fd902: types.StarsTransactionPeerFragment, + 0x7b560a0b: types.StarsTransactionPeerPlayMarket, + 0x250dbaf8: types.StarsTransactionPeerPremiumBot, + 0x95f2bfe4: types.StarsTransactionPeerUnsupported, + 0xe6df7378: functions.messages.StartBotRequest, + 0xb43df344: functions.messages.StartHistoryImportRequest, + 0xd069ccde: functions.stories.StartLiveRequest, + 0x5680e342: functions.phone.StartScheduledGroupCallRequest, + 0xa56c2a3e: types.updates.State, + 0xcb43acde: types.StatsAbsValueAndPrev, + 0xb637edaf: types.StatsDateRangeDays, + 0x8ea464b6: types.StatsGraph, + 0x4a27eb2d: types.StatsGraphAsync, + 0xbedc9822: types.StatsGraphError, + 0xd7584c87: types.StatsGroupTopAdmin, + 0x535f779d: types.StatsGroupTopInviter, + 0x9d04af9b: types.StatsGroupTopPoster, + 0xcbce2fe0: types.StatsPercentValue, + 0x47a971e0: types.StatsURL, + 0x2aee9191: types.smsjobs.Status, + 0xfcfeb29c: types.StickerKeyword, + 0x12b299d4: types.StickerPack, + 0x2dd14edc: types.StickerSet, + 0x6e153f16: types.messages.StickerSet, + 0x6410a5d2: types.StickerSetCovered, + 0x40d13c0e: types.StickerSetFullCovered, + 0x35e410a8: types.messages.StickerSetInstallResultArchive, + 0x38641628: types.messages.StickerSetInstallResultSuccess, + 0x3407e51b: types.StickerSetMultiCovered, + 0x77b15d1c: types.StickerSetNoCovered, + 0xd3f924eb: types.messages.StickerSetNotModified, + 0x30a6ec7e: types.messages.Stickers, + 0xf1749a22: types.messages.StickersNotModified, + 0x63c3dd0a: types.stories.Stories, + 0x712e27fd: types.StoriesStealthMode, + 0x9325705a: types.StoryAlbum, + 0xb826e150: types.StoryFwdHeader, + 0x16a4b93c: types.StoryItem, + 0x51e6ee4f: types.StoryItemDeleted, + 0xffadc913: types.StoryItemSkipped, + 0x6090d6d5: types.StoryReaction, + 0xbbab2643: types.StoryReactionPublicForward, + 0xcfcd0f13: types.StoryReactionPublicRepost, + 0xaa5f789c: types.stories.StoryReactionsList, + 0x50cd067c: types.stats.StoryStats, + 0xb0bdeac5: types.StoryView, + 0x9083670b: types.StoryViewPublicForward, + 0xbd74cf49: types.StoryViewPublicRepost, + 0x8d595cd6: types.StoryViews, + 0xde9eed1d: types.stories.StoryViews, + 0x59d78fc5: types.stories.StoryViewsList, + 0xfc533372: functions.users.SuggestBirthdayRequest, + 0x4dafc503: functions.stickers.SuggestShortNameRequest, + 0x0e8e37e5: types.SuggestedPost, + 0x85fea03f: types.stickers.SuggestedShortName, + 0xb4d5d859: types.payments.SuggestedStarRefBots, + 0xabbbd346: functions.messages.SummarizeTextRequest, + 0x17c6b5f6: types.help.Support, + 0x8c05f1c9: types.help.SupportName, + 0x4dba4501: types.account.Takeout, + 0x780a0310: types.help.TermsOfService, + 0x28ecf961: types.help.TermsOfServiceUpdate, + 0xe3309f7f: types.help.TermsOfServiceUpdateEmpty, + 0x35553762: types.TextAnchor, + 0xc556a45d: types.TextAutoEmail, + 0x24c26789: types.TextAutoPhone, + 0xac6a83aa: types.TextAutoUrl, + 0xb956812d: types.TextBankCard, + 0x6724abc4: types.TextBold, + 0x02ff29d3: types.TextBotCommand, + 0xafc79cd6: types.TextButton, + 0x7b9e1801: types.TextCashtag, + 0x7e6260d7: types.TextConcat, + 0xa26156c0: types.TextCustomEmoji, + 0xa5b45e2b: types.TextDate, + 0x9686cb50: types.TextDiff, + 0xde5a0dd6: types.TextEmail, + 0xdc3d824f: types.TextEmpty, + 0x6c3f19b9: types.TextFixed, + 0x519524ea: types.TextHashtag, + 0x081ccf4f: types.TextImage, + 0xd912a59c: types.TextItalic, + 0x034b8621: types.TextMarked, + 0x9d2eac97: types.TextMath, + 0xcd24cf44: types.TextMention, + 0x01a9fbfc: types.TextMentionName, + 0x1ccb966a: types.TextPhone, + 0x744694e0: types.TextPlain, + 0x4c2a5d62: types.TextSpoiler, + 0x9bf8bb95: types.TextStrike, + 0xed6a8504: types.TextSubscript, + 0xc7fb5e01: types.TextSuperscript, + 0xc12622c4: types.TextUnderline, + 0x3c2884c1: types.TextUrl, + 0x751f3146: types.TextWithEntities, + 0xa00e67d6: types.Theme, + 0xfa58b6d4: types.ThemeSettings, + 0x9a3d8c6d: types.account.Themes, + 0xf41eb622: types.account.ThemesNotModified, + 0xff9289f5: types.Timezone, + 0x7b74ed71: types.help.TimezonesList, + 0x970708cc: types.help.TimezonesListNotModified, + 0x10e8636f: types.TlsBlockDomain, + 0xe675a1c1: types.TlsBlockGrease, + 0x9eb95b5c: types.TlsBlockPublicKey, + 0x4d4dc41e: types.TlsBlockRandom, + 0xe725d44f: types.TlsBlockScope, + 0x4218a164: types.TlsBlockString, + 0x09333afb: types.TlsBlockZero, + 0x6c52c484: types.TlsClientHello, + 0xdb64fd34: types.account.TmpPassword, + 0x221bb5e4: types.TodoCompletion, + 0xcba9a52f: types.TodoItem, + 0x49b92a26: types.TodoList, + 0xbfe3dd3d: functions.communities.ToggleAllPeerLinkRequestApprovalRequest, + 0x7c2557c4: functions.stories.ToggleAllStoriesHiddenRequest, + 0x68f3e4eb: functions.channels.ToggleAntiSpamRequest, + 0x167fc0a1: functions.channels.ToggleAutotranslationRequest, + 0x69f59d69: functions.messages.ToggleBotInAttachMenuRequest, + 0x60eaefa1: functions.payments.ToggleChatStarGiftNotificationsRequest, + 0xd766e3ea: functions.communities.ToggleCommunityCollapsedInDialogsRequest, + 0x646e1097: functions.account.ToggleConnectedBotPausedRequest, + 0xfd2dda49: functions.messages.ToggleDialogFilterTagsRequest, + 0xa731e257: functions.messages.ToggleDialogPinRequest, + 0x3ff75734: functions.channels.ToggleForumRequest, + 0xf128c708: functions.phone.ToggleGroupCallRecordRequest, + 0x974392f2: functions.phone.ToggleGroupCallSettingsRequest, + 0x219c34e6: functions.phone.ToggleGroupCallStartSubscriptionRequest, + 0x0ecc2618: functions.channels.ToggleJoinRequestRequest, + 0xe4cb9580: functions.channels.ToggleJoinToSendRequest, + 0xb2081a35: functions.messages.ToggleNoForwardsRequest, + 0xfe2eda76: functions.account.ToggleNoPaidMessagesExceptionRequest, + 0x435885b5: functions.messages.TogglePaidReactionPrivacyRequest, + 0x9967ad0f: functions.communities.ToggleParticipantBannedRequest, + 0x6a6e7854: functions.channels.ToggleParticipantsHiddenRequest, + 0x736dcfea: functions.communities.TogglePeerLinkRequest, + 0x8c8219a8: functions.communities.TogglePeerLinkRequestApprovalRequest, + 0xbd0415c4: functions.stories.TogglePeerStoriesHiddenRequest, + 0xe47cb579: functions.messages.TogglePeerTranslationsRequest, + 0x9a75a1ef: functions.stories.TogglePinnedRequest, + 0x0b297e9b: functions.stories.TogglePinnedToTopRequest, + 0xeabbb94c: functions.channels.TogglePreHistoryHiddenRequest, + 0xac81bbde: functions.messages.ToggleSavedDialogPinRequest, + 0x418d549c: functions.channels.ToggleSignaturesRequest, + 0xedd49ef0: functions.channels.ToggleSlowModeRequest, + 0xb9d9a38d: functions.account.ToggleSponsoredMessagesRequest, + 0x1513e7b0: functions.payments.ToggleStarGiftsPinnedToTopRequest, + 0xb5052fea: functions.messages.ToggleStickerSetsRequest, + 0x8107455c: functions.messages.ToggleSuggestedPostApprovalRequest, + 0xd3e03124: functions.messages.ToggleTodoCompletedRequest, + 0x8514bdda: functions.contacts.ToggleTopPeersRequest, + 0x06de6392: functions.bots.ToggleUserEmojiStatusPermissionRequest, + 0x58d6b376: functions.account.ToggleUsernameRequest, + 0x50f24105: functions.channels.ToggleUsernameRequest, + 0x053ca973: functions.bots.ToggleUsernameRequest, + 0x9738bb15: functions.channels.ToggleViewForumAsMessagesRequest, + 0x60ed4229: functions.account.ToggleWebBrowserSettingsExceptionRequest, + 0x6c9d0efe: types.aicompose.Tones, + 0xc1f46103: types.aicompose.TonesNotModified, + 0xedcdc05b: types.TopPeer, + 0xfd9e7bec: types.TopPeerCategoryBotsApp, + 0x6c24f3dd: types.TopPeerCategoryBotsGuestChat, + 0x148677e2: types.TopPeerCategoryBotsInline, + 0xab661b5b: types.TopPeerCategoryBotsPM, + 0x161d9628: types.TopPeerCategoryChannels, + 0x0637b7ed: types.TopPeerCategoryCorrespondents, + 0xfbeec0f0: types.TopPeerCategoryForwardChats, + 0xa8406ca9: types.TopPeerCategoryForwardUsers, + 0xbd17a14a: types.TopPeerCategoryGroups, + 0xfb834291: types.TopPeerCategoryPeers, + 0x1e76a78c: types.TopPeerCategoryPhoneCalls, + 0x70b772a8: types.contacts.TopPeers, + 0xb52c939d: types.contacts.TopPeersDisabled, + 0xde266ef5: types.contacts.TopPeersNotModified, + 0x269e9a49: functions.messages.TranscribeAudioRequest, + 0xcfb9d957: types.messages.TranscribedAudio, + 0x7f18176a: functions.payments.TransferStarGiftRequest, + 0x33db32f8: types.messages.TranslateResult, + 0x1a542004: functions.messages.TranslateRichMessageRequest, + 0xa5eec345: functions.messages.TranslateTextRequest, + 0x4203998f: types.messages.TranslatedRichMessage, + 0xb550d328: functions.contacts.UnblockRequest, + 0xf96e55de: functions.messages.UninstallStickerSetRequest, + 0x416c56e8: types.payments.UniqueStarGift, + 0x512fe446: types.payments.UniqueStarGiftValueInfo, + 0x062dd747: functions.messages.UnpinAllMessagesRequest, + 0x6a0d3206: functions.account.UnregisterDeviceRequest, + 0x8c0f91fb: types.UpdateAiComposeTones, + 0x5e5259b6: functions.stories.UpdateAlbumRequest, + 0x17b7a20b: types.UpdateAttachMenuBots, + 0xec05b097: types.UpdateAutoSaveSettings, + 0xcc6e0c11: functions.account.UpdateBirthdayRequest, + 0x8ae5c97a: types.UpdateBotBusinessConnect, + 0xb9cfc48d: types.UpdateBotCallbackQuery, + 0x904dd49c: types.UpdateBotChatBoost, + 0x7cb34d79: types.UpdateBotChatInviteRequester, + 0x4d712f2e: types.UpdateBotCommands, + 0xa02a982e: types.UpdateBotDeleteBusinessMessage, + 0x07df587c: types.UpdateBotEditBusinessMessage, + 0xcdd4093d: types.UpdateBotGuestChatQuery, + 0x496f379c: types.UpdateBotInlineQuery, + 0x12f12a07: types.UpdateBotInlineSend, + 0x14b85813: types.UpdateBotMenuButton, + 0xac21d3ce: types.UpdateBotMessageReaction, + 0x09cb7759: types.UpdateBotMessageReactions, + 0x9ddb347c: types.UpdateBotNewBusinessMessage, + 0x8caa9a96: types.UpdateBotPrecheckoutQuery, + 0x283bd312: types.UpdateBotPurchasedPaidMedia, + 0xb5aefd7d: types.UpdateBotShippingQuery, + 0x6c0d8e23: types.UpdateBotStarsSubscription, + 0xc4870a49: types.UpdateBotStopped, + 0x8317c0c3: types.UpdateBotWebhookJSON, + 0x9b9240a6: types.UpdateBotWebhookJSONQuery, + 0xa26a7fa5: functions.account.UpdateBusinessAwayMessageRequest, + 0x1ea2fda7: types.UpdateBusinessBotCallbackQuery, + 0x66cdafc4: functions.account.UpdateBusinessGreetingMessageRequest, + 0xa614d034: functions.account.UpdateBusinessIntroRequest, + 0x9e6b131a: functions.account.UpdateBusinessLocationRequest, + 0x4b00e066: functions.account.UpdateBusinessWorkHoursRequest, + 0x635b4c09: types.UpdateChannel, + 0xb23fc698: types.UpdateChannelAvailableMessages, + 0xd29a27f4: types.UpdateChannelMessageForwards, + 0xf226ac08: types.UpdateChannelMessageViews, + 0x985d3abb: types.UpdateChannelParticipant, + 0x25f324f7: types.UpdateChannelReadMessagesContents, + 0x108d941f: types.UpdateChannelTooLong, + 0x8c88c923: types.UpdateChannelUserTyping, + 0x07b68920: types.UpdateChannelViewForumAsMessages, + 0x2f2ba99f: types.UpdateChannelWebPage, + 0xf89a6a4e: types.UpdateChat, + 0x54c01850: types.UpdateChatDefaultBannedRights, + 0xd087663a: types.UpdateChatParticipant, + 0x3dda5451: types.UpdateChatParticipantAdd, + 0xd7ca61a2: types.UpdateChatParticipantAdmin, + 0xe32f3d77: types.UpdateChatParticipantDelete, + 0xbd8367b9: types.UpdateChatParticipantRank, + 0x07761198: types.UpdateChatParticipants, + 0x83487af0: types.UpdateChatUserTyping, + 0x684d214e: functions.account.UpdateColorRequest, + 0xd8aa3671: functions.channels.UpdateColorRequest, + 0xa229dd06: types.UpdateConfig, + 0x66a08c7e: functions.account.UpdateConnectedBotRequest, + 0x139f63fb: functions.contacts.UpdateContactNoteRequest, + 0x7084a7be: types.UpdateContactsReset, + 0x8e5e9873: types.UpdateDcOptions, + 0xc32d5b12: types.UpdateDeleteChannelMessages, + 0x56dbfcf8: types.UpdateDeleteEphemeralMessages, + 0x3e85e92c: types.UpdateDeleteGroupCallMessages, + 0xa20db0e5: types.UpdateDeleteMessages, + 0x53e6f1ec: types.UpdateDeleteQuickReply, + 0x566fe7cd: types.UpdateDeleteQuickReplyMessages, + 0xf2a71983: types.UpdateDeleteScheduledMessages, + 0x38df3532: functions.account.UpdateDeviceLockedRequest, + 0x26ffde7d: types.UpdateDialogFilter, + 0x1ad4a04a: functions.messages.UpdateDialogFilterRequest, + 0xa5d72105: types.UpdateDialogFilterOrder, + 0x3504914f: types.UpdateDialogFilters, + 0xc563c1e4: functions.messages.UpdateDialogFiltersOrderRequest, + 0x6e6fe51c: types.UpdateDialogPinned, + 0xb658f23e: types.UpdateDialogUnreadMark, + 0xedfc111e: types.UpdateDraftMessage, + 0x1b3f4df7: types.UpdateEditChannelMessage, + 0x4bbb8f01: types.UpdateEditEphemeralMessage, + 0xe40370a3: types.UpdateEditMessage, + 0xfb9c547a: types.UpdateEmojiGameInfo, + 0xfbd3de6b: functions.account.UpdateEmojiStatusRequest, + 0xf0d3e6a8: functions.channels.UpdateEmojiStatusRequest, + 0x1710f156: types.UpdateEncryptedChatTyping, + 0x38fe25b7: types.UpdateEncryptedMessagesRead, + 0xb4a2e88d: types.UpdateEncryption, + 0x7c1079d6: types.UpdateEphemeralBotCallbackQuery, + 0xe511996d: types.UpdateFavedStickers, + 0x19360dc0: types.UpdateFolderPeers, + 0x871fb939: types.UpdateGeoLiveViewed, + 0x9d2216e0: types.UpdateGroupCall, + 0xa477288f: types.UpdateGroupCallChainBlocks, + 0x0b783982: types.UpdateGroupCallConnection, + 0xc957a766: types.UpdateGroupCallEncryptedMessage, + 0xd8326f0d: types.UpdateGroupCallMessage, + 0xf2ebdb4e: types.UpdateGroupCallParticipants, + 0x691e9052: types.UpdateInlineBotCallbackQuery, + 0xbdac7e70: types.UpdateJoinChatWebViewDecision, + 0x56022f4d: types.UpdateLangPack, + 0x46560264: types.UpdateLangPackTooLong, + 0x564fe691: types.UpdateLoginToken, + 0x4880ed9a: types.UpdateManagedBot, + 0xd5a41724: types.UpdateMessageExtendedMedia, + 0x4e90bfd6: types.UpdateMessageID, + 0xd64c522b: types.UpdateMessagePoll, + 0x7699f014: types.UpdateMessagePollVote, + 0x1e297bfa: types.UpdateMessageReactions, + 0x9f812b08: types.UpdateMonoForumNoPaidException, + 0x86fccf85: types.UpdateMoveStickerSetToTop, + 0x8951abef: types.UpdateNewAuthorization, + 0xb22083a6: types.UpdateNewBotConnection, + 0x62ba04d9: types.UpdateNewChannelMessage, + 0x12bcbd9a: types.UpdateNewEncryptedMessage, + 0x20bcbba1: types.UpdateNewEphemeralMessage, + 0x1f2b0afd: types.UpdateNewMessage, + 0xf53da717: types.UpdateNewQuickReply, + 0x39a51dfb: types.UpdateNewScheduledMessage, + 0x688a30aa: types.UpdateNewStickerSet, + 0x1824e40b: types.UpdateNewStoryReaction, + 0xbec268ef: types.UpdateNotifySettings, + 0x84be5b93: functions.account.UpdateNotifySettingsRequest, + 0x4b12327b: functions.channels.UpdatePaidMessagesPriceRequest, + 0x8b725fce: types.UpdatePaidReactionPrivacy, + 0xa59b102f: functions.account.UpdatePasswordSettingsRequest, + 0xebe07752: types.UpdatePeerBlocked, + 0xbb9bb9a5: types.UpdatePeerHistoryTTL, + 0xb4afcfb0: types.UpdatePeerLocated, + 0x6a7e7366: types.UpdatePeerSettings, + 0xae3f101d: types.UpdatePeerWallpaper, + 0x7063c3db: types.UpdatePendingJoinRequests, + 0xd94305e0: functions.account.UpdatePersonalChannelRequest, + 0xab0f6b1e: types.UpdatePhoneCall, + 0x2661bf09: types.UpdatePhoneCallSignalingData, + 0x5bb98608: types.UpdatePinnedChannelMessages, + 0xfa0f3ca2: types.UpdatePinnedDialogs, + 0x683b2c52: types.UpdatePinnedForumTopic, + 0x175df251: functions.messages.UpdatePinnedForumTopicRequest, + 0xdef143d0: types.UpdatePinnedForumTopics, + 0xd2aaf7ec: functions.messages.UpdatePinnedMessageRequest, + 0xed85eab5: types.UpdatePinnedMessages, + 0x686c85a6: types.UpdatePinnedSavedDialogs, + 0xee3b272a: types.UpdatePrivacy, + 0x78515775: functions.account.UpdateProfileRequest, + 0x09e82039: functions.photos.UpdateProfilePhotoRequest, + 0x3354678f: types.UpdatePtsChanged, + 0xf9470ab2: types.UpdateQuickReplies, + 0x3e050d0f: types.UpdateQuickReplyMessage, + 0xd6b19546: types.UpdateReadChannelDiscussionInbox, + 0x695c9e7c: types.UpdateReadChannelDiscussionOutbox, + 0x922e6e10: types.UpdateReadChannelInbox, + 0xb75f99a9: types.UpdateReadChannelOutbox, + 0xfb4c496c: types.UpdateReadFeaturedEmojiStickers, + 0x571d2742: types.UpdateReadFeaturedStickers, + 0x9e84bc99: types.UpdateReadHistoryInbox, + 0x2f2f21bf: types.UpdateReadHistoryOutbox, + 0xf8227181: types.UpdateReadMessagesContents, + 0x77b0e372: types.UpdateReadMonoForumInbox, + 0xa4a79376: types.UpdateReadMonoForumOutbox, + 0xf74e932b: types.UpdateReadStories, + 0x30f443db: types.UpdateRecentEmojiStatuses, + 0x6f7863f4: types.UpdateRecentReactions, + 0x9a422c20: types.UpdateRecentStickers, + 0xaeaf9e74: types.UpdateSavedDialogPinned, + 0x9375341e: types.UpdateSavedGifs, + 0x60297dec: functions.messages.UpdateSavedReactionTagRequest, + 0x39c67432: types.UpdateSavedReactionTags, + 0x74d8be99: types.UpdateSavedRingtones, + 0x504aa18f: types.UpdateSentPhoneCode, + 0x7d627683: types.UpdateSentStoryReaction, + 0xebe46819: types.UpdateServiceNotification, + 0x093fa0bf: functions.smsjobs.UpdateSettingsRequest, + 0x78d4dec1: types.UpdateShort, + 0x4d6deea5: types.UpdateShortChatMessage, + 0x313bc7f8: types.UpdateShortMessage, + 0x9015e101: types.UpdateShortSentMessage, + 0xf16269d4: types.UpdateSmsJob, + 0x48e246c2: types.UpdateStarGiftAuctionState, + 0xdc58f31e: types.UpdateStarGiftAuctionUserState, + 0x4fddbee7: functions.payments.UpdateStarGiftCollectionRequest, + 0xac072444: types.UpdateStarGiftCraftFail, + 0xedbe6ccb: functions.payments.UpdateStarGiftPriceRequest, + 0x778b5ab3: functions.bots.UpdateStarRefProgramRequest, + 0x4e80a379: types.UpdateStarsBalance, + 0xa584b019: types.UpdateStarsRevenueStatus, + 0x6628562c: functions.account.UpdateStatusRequest, + 0x31c24808: types.UpdateStickerSets, + 0x0bb2d201: types.UpdateStickerSetsOrder, + 0x2c084dc1: types.UpdateStoriesStealthMode, + 0x75b3b798: types.UpdateStory, + 0x1bf335b9: types.UpdateStoryID, + 0x8216fba3: types.UpdateTheme, + 0x2bf40ccc: functions.account.UpdateThemeRequest, + 0x903bcf59: functions.aicompose.UpdateToneRequest, + 0x0084cd5a: types.UpdateTranscribedAudio, + 0x20529438: types.UpdateUser, + 0x28373599: types.UpdateUserEmojiStatus, + 0xed9f30c5: functions.bots.UpdateUserEmojiStatusRequest, + 0xa7848924: types.UpdateUserName, + 0x05492a13: types.UpdateUserPhone, + 0xe5bdf8de: types.UpdateUserStatus, + 0x2a17bf5c: types.UpdateUserTyping, + 0x3e0bdd7c: functions.account.UpdateUsernameRequest, + 0x3514b3de: functions.channels.UpdateUsernameRequest, + 0x140502d1: types.UpdateWebBrowserException, + 0xc39a2ade: types.UpdateWebBrowserSettings, + 0x9adf82fe: functions.account.UpdateWebBrowserSettingsRequest, + 0x7f891213: types.UpdateWebPage, + 0x1592b79d: types.UpdateWebViewResultSent, + 0x74ae4240: types.Updates, + 0x725b04c3: types.UpdatesCombined, + 0xe317af7e: types.UpdatesTooLong, + 0xaed6e4f5: functions.payments.UpgradeStarGiftRequest, + 0xe14c4a71: functions.photos.UploadContactProfilePhotoRequest, + 0x5057c497: functions.messages.UploadEncryptedFileRequest, + 0x2a862092: functions.messages.UploadImportedMediaRequest, + 0x14967978: functions.messages.UploadMediaRequest, + 0x0388a3b5: functions.photos.UploadProfilePhotoRequest, + 0x831a83a2: functions.account.UploadRingtoneRequest, + 0x1c3db333: functions.account.UploadThemeRequest, + 0xe39a8f03: functions.account.UploadWallPaperRequest, + 0x623a8fa0: types.UrlAuthResultAccepted, + 0xa9d6db1f: types.UrlAuthResultDefault, + 0x3cd623ec: types.UrlAuthResultRequest, + 0xb1b8cc83: types.User, + 0xd3bc4b7a: types.UserEmpty, + 0x06cbe645: types.UserFull, + 0x3b6d152e: types.users.UserFull, + 0x01eb3758: types.help.UserInfo, + 0xf3ae2eed: types.help.UserInfoEmpty, + 0x82d1f706: types.UserProfilePhoto, + 0x4f11bae1: types.UserProfilePhotoEmpty, + 0x09d05049: types.UserStatusEmpty, + 0x65899777: types.UserStatusLastMonth, + 0x541a1d1a: types.UserStatusLastWeek, + 0x008c703f: types.UserStatusOffline, + 0xedb93949: types.UserStatusOnline, + 0x7b197dc8: types.UserStatusRecently, + 0xb4073647: types.Username, + 0x62d706b8: types.users.Users, + 0x315a4974: types.users.UsersSlice, + 0xb6c8f12b: functions.payments.ValidateRequestedInfoRequest, + 0xd1451883: types.payments.ValidatedRequestedInfo, + 0x032da4cf: functions.account.VerifyEmailRequest, + 0x4dd3a7f6: functions.account.VerifyPhoneRequest, + 0xde33b094: types.VideoSize, + 0xf85c413c: types.VideoSizeEmojiMarkup, + 0x0da082fe: types.VideoSizeStickerMarkup, + 0x269e3643: functions.messages.ViewSponsoredMessageRequest, + 0x4899484e: types.messages.VotesList, + 0xa437c3ed: types.WallPaper, + 0xe0804116: types.WallPaperNoFile, + 0x372efcd0: types.WallPaperSettings, + 0xcdc3858c: types.account.WallPapers, + 0x1c199183: types.account.WallPapersNotModified, + 0xa6f8f452: types.WebAuthorization, + 0xed56c9fc: types.account.WebAuthorizations, + 0x79eb8cb3: types.account.WebBrowserSettings, + 0xc31c8f4e: types.account.WebBrowserSettingsNotModified, + 0x1c570ed1: types.WebDocument, + 0xf9c8bcc6: types.WebDocumentNoProxy, + 0x933ca597: types.WebDomainException, + 0x21e753bc: types.upload.WebFile, + 0xe89c45b2: types.WebPage, + 0xfd5e12bd: types.messages.WebPage, + 0x7781fe18: types.WebPageAttributeAiComposeTone, + 0x01c641c2: types.WebPageAttributeStarGiftAuction, + 0x31cad303: types.WebPageAttributeStarGiftCollection, + 0x50cc03d3: types.WebPageAttributeStickerSet, + 0x2e94c3e7: types.WebPageAttributeStory, + 0x54b56617: types.WebPageAttributeTheme, + 0xcf6f6db8: types.WebPageAttributeUniqueStarGift, + 0x211a1788: types.WebPageEmpty, + 0x7311ca11: types.WebPageNotModified, + 0xb0d13e47: types.WebPagePending, + 0x8c9a88ac: types.messages.WebPagePreview, + 0x0c94511c: types.WebViewMessageSent, + 0x4d22ff98: types.WebViewResultUrl, + 0x104fc872: types.ephemeral.WelcomeMessages, + 0x59ffdb31: types.ephemeral.WelcomeMessagesNotModified, +} diff --git a/module/scripts/lib/telethon/tl/core/__init__.py b/module/scripts/lib/telethon/tl/core/__init__.py new file mode 100644 index 0000000..3113196 --- /dev/null +++ b/module/scripts/lib/telethon/tl/core/__init__.py @@ -0,0 +1,26 @@ +""" +This module holds core "special" types, which are more convenient ways +to do stuff in a `telethon.network.mtprotosender.MTProtoSender` instance. + +Only special cases are gzip-packed data, the response message (not a +client message), the message container which references these messages +and would otherwise conflict with the rest, and finally the RpcResult: + + rpc_result#f35c6d01 req_msg_id:long result:bytes = RpcResult; + +Three things to note with this definition: +1. The constructor ID is actually ``42d36c2c``. +2. Those bytes are not read like the rest of bytes (length + payload). + They are actually the raw bytes of another object, which can't be + read directly because it depends on per-request information (since + some can return ``Vector`` and ``Vector``). +3. Those bytes may be gzipped data, which needs to be treated early. +""" +from .tlmessage import TLMessage +from .gzippacked import GzipPacked +from .messagecontainer import MessageContainer +from .rpcresult import RpcResult + +core_objects = {x.CONSTRUCTOR_ID: x for x in ( + GzipPacked, MessageContainer, RpcResult +)} diff --git a/module/scripts/lib/telethon/tl/core/gzippacked.py b/module/scripts/lib/telethon/tl/core/gzippacked.py new file mode 100644 index 0000000..b586a39 --- /dev/null +++ b/module/scripts/lib/telethon/tl/core/gzippacked.py @@ -0,0 +1,48 @@ +try: + from isal import igzip as gzip +except ImportError: + import gzip +import struct + +from .. import TLObject + + +class GzipPacked(TLObject): + CONSTRUCTOR_ID = 0x3072cfa1 + + def __init__(self, data): + self.data = data + + @staticmethod + def gzip_if_smaller(content_related, data): + """Calls bytes(request), and based on a certain threshold, + optionally gzips the resulting data. If the gzipped data is + smaller than the original byte array, this is returned instead. + + Note that this only applies to content related requests. + """ + if content_related and len(data) > 512: + gzipped = bytes(GzipPacked(data)) + return gzipped if len(gzipped) < len(data) else data + else: + return data + + def __bytes__(self): + return struct.pack('`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionEditMessage) + + @property + def deleted_message(self): + """ + Whether a message in this channel was deleted or not. + + If `True`, `old` will be present as + `Message `. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionDeleteMessage) + + @property + def changed_admin(self): + """ + Whether the permissions for an admin in this channel + changed or not. + + If `True`, `old` and `new` will be present as + :tl:`ChannelParticipant`. + """ + return isinstance( + self.original.action, + types.ChannelAdminLogEventActionParticipantToggleAdmin) + + @property + def changed_restrictions(self): + """ + Whether a message in this channel was edited or not. + + If `True`, `old` and `new` will be present as + :tl:`ChannelParticipant`. + """ + return isinstance( + self.original.action, + types.ChannelAdminLogEventActionParticipantToggleBan) + + @property + def changed_invites(self): + """ + Whether the invites in the channel were toggled or not. + + If `True`, `old` and `new` will be present as `bool`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionToggleInvites) + + @property + def changed_location(self): + """ + Whether the location setting of the channel has changed or not. + + If `True`, `old` and `new` will be present as :tl:`ChannelLocation`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionChangeLocation) + + @property + def joined(self): + """ + Whether `user` joined through the channel's + public username or not. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionParticipantJoin) + + @property + def joined_invite(self): + """ + Whether a new user joined through an invite + link to the channel or not. + + If `True`, `new` will be present as + :tl:`ChannelParticipant`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionParticipantInvite) + + @property + def left(self): + """ + Whether `user` left the channel or not. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionParticipantLeave) + + @property + def changed_hide_history(self): + """ + Whether hiding the previous message history for new members + in the channel was toggled or not. + + If `True`, `old` and `new` will be present as `bool`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionTogglePreHistoryHidden) + + @property + def changed_signatures(self): + """ + Whether the message signatures in the channel were toggled + or not. + + If `True`, `old` and `new` will be present as `bool`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionToggleSignatures) + + @property + def changed_pin(self): + """ + Whether a new message in this channel was pinned or not. + + If `True`, `new` will be present as + `Message `. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionUpdatePinned) + + @property + def changed_default_banned_rights(self): + """ + Whether the default banned rights were changed or not. + + If `True`, `old` and `new` will + be present as :tl:`ChatBannedRights`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionDefaultBannedRights) + + @property + def stopped_poll(self): + """ + Whether a poll was stopped or not. + + If `True`, `new` will be present as + `Message `. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionStopPoll) + + @property + def started_group_call(self): + """ + Whether a group call was started or not. + + If `True`, `new` will be present as :tl:`InputGroupCall`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionStartGroupCall) + + @property + def discarded_group_call(self): + """ + Whether a group call was started or not. + + If `True`, `old` will be present as :tl:`InputGroupCall`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionDiscardGroupCall) + + @property + def user_muted(self): + """ + Whether a participant was muted in the ongoing group call or not. + + If `True`, `new` will be present as :tl:`GroupCallParticipant`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionParticipantMute) + + @property + def user_unmutted(self): + """ + Whether a participant was unmuted from the ongoing group call or not. + + If `True`, `new` will be present as :tl:`GroupCallParticipant`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionParticipantUnmute) + + @property + def changed_call_settings(self): + """ + Whether the group call settings were changed or not. + + If `True`, `new` will be `True` if new users are muted on join. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionToggleGroupCallSetting) + + @property + def changed_history_ttl(self): + """ + Whether the Time To Live of the message history has changed. + + Messages sent after this change will have a ``ttl_period`` in seconds + indicating how long they should live for before being auto-deleted. + + If `True`, `old` will be the old TTL, and `new` the new TTL, in seconds. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionChangeHistoryTTL) + + @property + def deleted_exported_invite(self): + """ + Whether the exported chat invite has been deleted. + + If `True`, `old` will be the deleted :tl:`ExportedChatInvite`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionExportedInviteDelete) + + @property + def edited_exported_invite(self): + """ + Whether the exported chat invite has been edited. + + If `True`, `old` and `new` will be the old and new + :tl:`ExportedChatInvite`, respectively. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionExportedInviteEdit) + + @property + def revoked_exported_invite(self): + """ + Whether the exported chat invite has been revoked. + + If `True`, `old` will be the revoked :tl:`ExportedChatInvite`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionExportedInviteRevoke) + + @property + def joined_by_invite(self): + """ + Whether a new participant has joined with the use of an invite link. + + If `True`, `old` will be pre-existing (old) :tl:`ExportedChatInvite` + used to join. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionParticipantJoinByInvite) + + @property + def changed_user_volume(self): + """ + Whether a participant's volume in a call has been changed. + + If `True`, `new` will be the updated :tl:`GroupCallParticipant`. + """ + return isinstance(self.original.action, + types.ChannelAdminLogEventActionParticipantVolume) + + def __str__(self): + return str(self.original) + + def stringify(self): + return self.original.stringify() diff --git a/module/scripts/lib/telethon/tl/custom/button.py b/module/scripts/lib/telethon/tl/custom/button.py new file mode 100644 index 0000000..7f8759d --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/button.py @@ -0,0 +1,347 @@ +from .. import types +from ... import utils + + +class Button: + """ + .. note:: + + This class is used to **define** reply markups, e.g. when + sending a message or replying to events. When you access + `Message.buttons ` + they are actually `MessageButton + `, + so you might want to refer to that class instead. + + Helper class to allow defining ``reply_markup`` when + sending a message with inline or keyboard buttons. + + You should make use of the defined class methods to create button + instances instead making them yourself (i.e. don't do ``Button(...)`` + but instead use methods line `Button.inline(...) ` etc. + + You can use `inline`, `switch_inline`, `url`, `auth`, `buy` and `game` + together to create inline buttons (under the message). + + You can use `text`, `request_location`, `request_phone` and `request_poll` + together to create a reply markup (replaces the user keyboard). + You can also configure the aspect of the reply with these. + The latest message with a reply markup will be the one shown to the user + (messages contain the buttons, not the chat itself). + + You **cannot** mix the two type of buttons together, + and it will error if you try to do so. + + The text for all buttons may be at most 142 characters. + If more characters are given, Telegram will cut the text + to 128 characters and add the ellipsis (…) character as + the 129. + """ + def __init__(self, button, *, resize, single_use, selective, + persistent, placeholder): + self.button = button + self.resize = resize + self.single_use = single_use + self.selective = selective + self.persistent = persistent + self.placeholder = placeholder + @staticmethod + def _get_style(style=None, icon=None): + """ + Returns `KeyboardButtonStyle` by an appropriate mapping. + """ + if style is not None and (not isinstance(style,str) or (style := style.lower()) not in {'primary','danger','success'}): + raise ValueError("Invalid value. Must be either one of 'primary' (blue) , 'danger' (red) and 'success' (green) , or None") + return types.KeyboardButtonStyle(**({f'bg_{style}': True,'icon':icon} if style else {'icon': icon})) + @staticmethod + def _is_inline(button): + """ + Returns `True` if the button belongs to an inline keyboard. + """ + return isinstance(button, types.KeyboardInlineButton) + + @staticmethod + def inline(text, data=None, style=None, icon=None): + """ + Creates a new inline button with some payload data in it. + + If `data` is omitted, the given `text` will be used as `data`. + In any case `data` should be either `bytes` or `str`. + + Note that the given `data` must be less or equal to 64 bytes. + If more than 64 bytes are passed as data, ``ValueError`` is raised. + If you need to store more than 64 bytes, consider saving the real + data in a database and a reference to that data inside the button. + + When the user clicks this button, `events.CallbackQuery + ` will trigger with the + same data that the button contained, so that you can determine which + button was pressed. + """ + if not data: + data = text.encode('utf-8') + elif not isinstance(data, (bytes, bytearray, memoryview)): + data = str(data).encode('utf-8') + + if len(data) > 64: + raise ValueError('Too many bytes for the data') + + return types.KeyboardInlineButton(text, types.InlineButtonTypeCallback(data), style=Button._get_style(style, icon)) + + @staticmethod + def switch_inline(text, query='', same_peer=False, style=None, icon=None): + """ + Creates a new inline button to switch to inline query. + + If `query` is given, it will be the default text to be used + when making the inline query. + + If ``same_peer is True`` the inline query will directly be + set under the currently opened chat. Otherwise, the user will + have to select a different dialog to make the query. + + When the user clicks this button, after a chat is selected, their + input field will be filled with the username of your bot followed + by the query text, ready to make inline queries. + """ + return types.KeyboardInlineButton(text, types.InlineButtonTypeSwitchInline(query, same_peer), style=Button._get_style(style, icon)) + + @staticmethod + def url(text, url=None, style=None, icon=None): + """ + Creates a new inline button to open the desired URL on click. + + If no `url` is given, the `text` will be used as said URL instead. + + You cannot detect that the user clicked this button directly. + + When the user clicks this button, a confirmation box will be shown + to the user asking whether they want to open the displayed URL unless + the domain is trusted, and once confirmed the URL will open in their + device. + """ + return types.KeyboardInlineButton(text, types.InlineButtonTypeUrl(url or text), style=Button._get_style(style, icon)) + + @staticmethod + def auth(text, url=None, style=None, icon=None, *, bot=None, write_access=False, fwd_text=None): + """ + Creates a new inline button to authorize the user at the given URL. + + You should set the `url` to be on the same domain as the one configured + for the desired `bot` via `@BotFather `_ using + the ``/setdomain`` command. + + For more information about letting the user login via Telegram to + a certain domain, see https://core.telegram.org/widgets/login. + + If no `url` is specified, it will default to `text`. + + Args: + bot (`hints.EntityLike`): + The bot that requires this authorization. By default, this + is the bot that is currently logged in (itself), although + you may pass a different input peer. + + .. note:: + + For now, you cannot use ID or username for this argument. + If you want to use a different bot than the one currently + logged in, you must manually use `client.get_input_entity() + `. + + write_access (`bool`): + Whether write access is required or not. + This is `False` by default (read-only access). + + fwd_text (`str`): + The new text to show in the button if the message is + forwarded. By default, the button text will be the same. + + When the user clicks this button, a confirmation box will be shown + to the user asking whether they want to login to the specified domain. + """ + return types.KeyboardInlineButton( + text, + types.InputInlineButtonTypeUrlAuth( + url=url or text, + bot=utils.get_input_user(bot or types.InputUserSelf()), + request_write_access=write_access, + fwd_text=fwd_text + ), + style=Button._get_style(style, icon) + ) + + @classmethod + def text(cls, text, *, resize=None, single_use=None, selective=None, + persistent=None, placeholder=None, style=None, icon=None): + """ + Creates a new keyboard button with the given text. + + Args: + text (`str`): + The title of the button. + + resize (`bool`): + If present, the entire keyboard will be reconfigured to + be resized and be smaller if there are not many buttons. + + single_use (`bool`): + If present, the entire keyboard will be reconfigured to + be usable only once before it hides itself. + + selective (`bool`): + If present, the entire keyboard will be reconfigured to + be "selective". The keyboard will be shown only to specific + users. It will target users that are @mentioned in the text + of the message or to the sender of the message you reply to. + + persistent (`bool`): + If present, always show the keyboard when the regular keyboard + is hidden. Defaults to false, in which case the custom keyboard + can be hidden and revealed via the keyboard icon. + + placeholder (`str`): + The placeholder to be shown in the input field when the keyboard is active; + 1-64 characters + + When the user clicks this button, a text message with the same text + as the button will be sent, and can be handled with `events.NewMessage + `. You cannot distinguish + between a button press and the user typing and sending exactly the + same text on their own. + """ + return cls( + types.KeyboardButton(text, types.ButtonTypeDefault(), style=cls._get_style(style, icon)), + resize=resize, + single_use=single_use, + selective=selective, + persistent=persistent, + placeholder=placeholder + ) + + @classmethod + def request_location(cls, text, style=None, icon=None, *, resize=None, single_use=None, selective=None, + persistent=None, placeholder=None): + """ + Creates a new keyboard button to request the user's location on click. + + ``resize``, ``single_use``, ``selective``, ``persistent`` and ``placeholder`` + are documented in `text`. + + When the user clicks this button, a confirmation box will be shown + to the user asking whether they want to share their location with the + bot, and if confirmed a message with geo media will be sent. + """ + return cls( + types.KeyboardButton(text, types.ButtonTypeRequestGeoLocation(), style=cls._get_style(style, icon)), + resize=resize, + single_use=single_use, + selective=selective, + persistent=persistent, + placeholder=placeholder + ) + + @classmethod + def request_phone(cls, text, style=None, icon=None, *, resize=None, single_use=None, + selective=None, persistent=None, placeholder=None): + """ + Creates a new keyboard button to request the user's phone on click. + + ``resize``, ``single_use``, ``selective``, ``persistent`` and ``placeholder`` + are documented in `text`. + + When the user clicks this button, a confirmation box will be shown + to the user asking whether they want to share their phone with the + bot, and if confirmed a message with contact media will be sent. + """ + return cls( + types.KeyboardButton(text, types.ButtonTypeRequestPhone(), style=cls._get_style(style, icon)), + resize=resize, + single_use=single_use, + selective=selective, + placeholder=placeholder, + persistent=persistent + ) + + @classmethod + def request_poll(cls, text, style=None, icon=None, *, force_quiz=False, resize=None, single_use=None, + selective=None, persistent=None, placeholder=None): + """ + Creates a new keyboard button to request the user to create a poll. + + If `force_quiz` is `False`, the user will be allowed to choose whether + they want their poll to be a quiz or not. Otherwise, the user will be + forced to create a quiz when creating the poll. + + If a poll is a quiz, there will be only one answer that is valid, and + the votes cannot be retracted. Otherwise, users can vote and retract + the vote, and the pol might be multiple choice. + + ``resize``, ``single_use``, ``selective``, ``persistent`` and ``placeholder`` + are documented in `text`. + + When the user clicks this button, a screen letting the user create a + poll will be shown, and if they do create one, the poll will be sent. + """ + return cls( + types.KeyboardButton(text, types.ButtonTypeRequestPoll(force_quiz), style=cls._get_style(style, icon)), + resize=resize, + single_use=single_use, + selective=selective, + persistent=persistent, + placeholder=placeholder + ) + + @staticmethod + def clear(selective=None): + """ + Clears all keyboard buttons after sending a message with this markup. + When used, no other button should be present or it will be ignored. + + ``selective`` is as documented in `text`. + + """ + return types.ReplyKeyboardHide(selective=selective) + + @staticmethod + def force_reply(single_use=None, selective=None, placeholder=None): + """ + Forces a reply to the message with this markup. If used, + no other button should be present or it will be ignored. + + ``single_use``, ``selective`` and ``placeholder`` are as documented in `text`. + + """ + return types.ReplyKeyboardForceReply( + single_use=single_use, + selective=selective, + placeholder=placeholder) + + @staticmethod + def buy(text, style=None, icon=None): + """ + Creates a new inline button to buy a product. + + This can only be used when sending files of type + :tl:`InputMediaInvoice`, and must be the first button. + + If the button is not specified, Telegram will automatically + add the button to the message. See the + `Payments API `__ + documentation for more information. + """ + return types.KeyboardInlineButton(text, types.InlineButtonTypeBuy(), style=Button._get_style(style, icon)) + + @staticmethod + def game(text, style=None, icon=None): + """ + Creates a new inline button to start playing a game. + + This should be used when sending files of type + :tl:`InputMediaGame`, and must be the first button. + + See the + `Games `__ + documentation for more information on using games. + """ + return types.KeyboardInlineButton(text, types.InlineButtonTypeGame(), style=Button._get_style(style, icon)) diff --git a/module/scripts/lib/telethon/tl/custom/chatgetter.py b/module/scripts/lib/telethon/tl/custom/chatgetter.py new file mode 100644 index 0000000..240d9b8 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/chatgetter.py @@ -0,0 +1,150 @@ +import abc + +from ... import errors, utils +from ...tl import types + + +class ChatGetter(abc.ABC): + """ + Helper base class that introduces the `chat`, `input_chat` + and `chat_id` properties and `get_chat` and `get_input_chat` + methods. + """ + def __init__(self, chat_peer=None, *, input_chat=None, chat=None, broadcast=None): + self._chat_peer = chat_peer + self._input_chat = input_chat + self._chat = chat + self._broadcast = broadcast + self._client = None + + @property + def chat(self): + """ + Returns the :tl:`User`, :tl:`Chat` or :tl:`Channel` where this object + belongs to. It may be `None` if Telegram didn't send the chat. + + If you only need the ID, use `chat_id` instead. + + If you need to call a method which needs + this chat, use `input_chat` instead. + + If you're using `telethon.events`, use `get_chat()` instead. + """ + return self._chat + + async def get_chat(self): + """ + Returns `chat`, but will make an API call to find the + chat unless it's already cached. + + If you only need the ID, use `chat_id` instead. + + If you need to call a method which needs + this chat, use `get_input_chat()` instead. + """ + # See `get_sender` for information about 'min'. + if (self._chat is None or getattr(self._chat, 'min', None))\ + and await self.get_input_chat(): + try: + self._chat =\ + await self._client.get_entity(self._input_chat) + except ValueError: + await self._refetch_chat() + return self._chat + + @property + def input_chat(self): + """ + This :tl:`InputPeer` is the input version of the chat where the + message was sent. Similarly to `input_sender + `, this + doesn't have things like username or similar, but still useful in + some cases. + + Note that this might not be available if the library doesn't + have enough information available. + """ + if self._input_chat is None and self._chat_peer and self._client: + try: + self._input_chat = self._client._mb_entity_cache.get( + utils.get_peer_id(self._chat_peer, add_mark=False))._as_input_peer() + except AttributeError: + pass + + return self._input_chat + + async def get_input_chat(self): + """ + Returns `input_chat`, but will make an API call to find the + input chat unless it's already cached. + """ + if self.input_chat is None and self.chat_id and self._client: + try: + # The chat may be recent, look in dialogs + target = self.chat_id + async for d in self._client.iter_dialogs(100): + if d.id == target: + self._chat = d.entity + self._input_chat = d.input_entity + break + except errors.RPCError: + pass + + return self._input_chat + + @property + def chat_id(self): + """ + Returns the marked chat integer ID. Note that this value **will + be different** from ``peer_id`` for incoming private messages, since + the chat *to* which the messages go is to your own person, but + the *chat* itself is with the one who sent the message. + + TL;DR; this gets the ID that you expect. + + If there is a chat in the object, `chat_id` will *always* be set, + which is why you should use it instead of `chat.id `. + """ + return utils.get_peer_id(self._chat_peer) if self._chat_peer else None + + @property + def is_private(self): + """ + `True` if the message was sent as a private message. + + Returns `None` if there isn't enough information + (e.g. on `events.MessageDeleted `). + """ + return isinstance(self._chat_peer, types.PeerUser) if self._chat_peer else None + + @property + def is_group(self): + """ + True if the message was sent on a group or megagroup. + + Returns `None` if there isn't enough information + (e.g. on `events.MessageDeleted `). + """ + # TODO Cache could tell us more in the future + if self._broadcast is None and hasattr(self.chat, 'broadcast'): + self._broadcast = bool(self.chat.broadcast) + + if isinstance(self._chat_peer, types.PeerChannel): + if self._broadcast is None: + return None + else: + return not self._broadcast + + return isinstance(self._chat_peer, types.PeerChat) + + @property + def is_channel(self): + """`True` if the message was sent on a megagroup or channel.""" + # The only case where chat peer could be none is in MessageDeleted, + # however those always have the peer in channels. + return isinstance(self._chat_peer, types.PeerChannel) + + async def _refetch_chat(self): + """ + Re-fetches chat information through other means. + """ diff --git a/module/scripts/lib/telethon/tl/custom/conversation.py b/module/scripts/lib/telethon/tl/custom/conversation.py new file mode 100644 index 0000000..6cb973d --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/conversation.py @@ -0,0 +1,529 @@ +import asyncio +import functools +import inspect +import itertools +import time + +from .chatgetter import ChatGetter +from ... import helpers, utils, errors + +# Sometimes the edits arrive very fast (within the same second). +# In that case we add a small delta so that the age is older, for +# comparision purposes. This value is enough for up to 1000 messages. +_EDIT_COLLISION_DELTA = 0.001 + + +def _checks_cancelled(f): + @functools.wraps(f) + def wrapper(self, *args, **kwargs): + if self._cancelled: + raise asyncio.CancelledError('The conversation was cancelled before') + + return f(self, *args, **kwargs) + return wrapper + + +class Conversation(ChatGetter): + """ + Represents a conversation inside an specific chat. + + A conversation keeps track of new messages since it was + created until its exit and easily lets you query the + current state. + + If you need a conversation across two or more chats, + you should use two conversations and synchronize them + as you better see fit. + """ + _id_counter = 0 + _custom_counter = 0 + + def __init__(self, client, input_chat, + *, timeout, total_timeout, max_messages, + exclusive, replies_are_responses): + # This call resets the client + ChatGetter.__init__(self, input_chat=input_chat) + + self._id = Conversation._id_counter + Conversation._id_counter += 1 + + self._client = client + self._timeout = timeout + self._total_timeout = total_timeout + self._total_due = None + + self._outgoing = set() + self._last_outgoing = 0 + self._incoming = [] + self._last_incoming = 0 + self._max_incoming = max_messages + self._last_read = None + self._custom = {} + + self._pending_responses = {} + self._pending_replies = {} + self._pending_edits = {} + self._pending_reads = {} + + self._exclusive = exclusive + self._cancelled = False + + # The user is able to expect two responses for the same message. + # {desired message ID: next incoming index} + self._response_indices = {} + if replies_are_responses: + self._reply_indices = self._response_indices + else: + self._reply_indices = {} + + self._edit_dates = {} + + @_checks_cancelled + async def send_message(self, *args, **kwargs): + """ + Sends a message in the context of this conversation. Shorthand + for `telethon.client.messages.MessageMethods.send_message` with + ``entity`` already set. + """ + sent = await self._client.send_message( + self._input_chat, *args, **kwargs) + + # Albums will be lists, so handle that + ms = sent if isinstance(sent, list) else (sent,) + self._outgoing.update(m.id for m in ms) + self._last_outgoing = ms[-1].id + return sent + + @_checks_cancelled + async def send_file(self, *args, **kwargs): + """ + Sends a file in the context of this conversation. Shorthand + for `telethon.client.uploads.UploadMethods.send_file` with + ``entity`` already set. + """ + sent = await self._client.send_file( + self._input_chat, *args, **kwargs) + + # Albums will be lists, so handle that + ms = sent if isinstance(sent, list) else (sent,) + self._outgoing.update(m.id for m in ms) + self._last_outgoing = ms[-1].id + return sent + + @_checks_cancelled + def mark_read(self, message=None): + """ + Marks as read the latest received message if ``message is None``. + Otherwise, marks as read until the given message (or message ID). + + This is equivalent to calling `client.send_read_acknowledge + `. + """ + if message is None: + if self._incoming: + message = self._incoming[-1].id + else: + message = 0 + elif not isinstance(message, int): + message = message.id + + return self._client.send_read_acknowledge( + self._input_chat, max_id=message) + + def get_response(self, message=None, *, timeout=None): + """ + Gets the next message that responds to a previous one. This is + the method you need most of the time, along with `get_edit`. + + Args: + message (`Message ` | `int`, optional): + The message (or the message ID) for which a response + is expected. By default this is the last sent message. + + timeout (`int` | `float`, optional): + If present, this `timeout` (in seconds) will override the + per-action timeout defined for the conversation. + + .. code-block:: python + + async with client.conversation(...) as conv: + await conv.send_message('Hey, what is your name?') + + response = await conv.get_response() + name = response.text + + await conv.send_message('Nice to meet you, {}!'.format(name)) + """ + return self._get_message( + message, self._response_indices, self._pending_responses, timeout, + lambda x, y: True + ) + + def get_reply(self, message=None, *, timeout=None): + """ + Gets the next message that explicitly replies to a previous one. + """ + return self._get_message( + message, self._reply_indices, self._pending_replies, timeout, + lambda x, y: x.reply_to and x.reply_to.reply_to_msg_id == y + ) + + def _get_message( + self, target_message, indices, pending, timeout, condition): + """ + Gets the next desired message under the desired condition. + + Args: + target_message (`object`): + The target message for which we want to find another + response that applies based on `condition`. + + indices (`dict`): + This dictionary remembers the last ID chosen for the + input `target_message`. + + pending (`dict`): + This dictionary remembers {msg_id: Future} to be set + once `condition` is met. + + timeout (`int`): + The timeout (in seconds) override to use for this operation. + + condition (`callable`): + The condition callable that checks if an incoming + message is a valid response. + """ + start_time = time.time() + target_id = self._get_message_id(target_message) + + # If there is no last-chosen ID, make sure to pick one *after* + # the input message, since we don't want responses back in time + if target_id not in indices: + for i, incoming in enumerate(self._incoming): + if incoming.id > target_id: + indices[target_id] = i + break + else: + indices[target_id] = len(self._incoming) + + # We will always return a future from here, even if the result + # can be set immediately. Otherwise, needing to await only + # sometimes is an annoying edge case (i.e. we would return + # a `Message` but `get_response()` always `await`'s). + future = self._client.loop.create_future() + + # If there are enough responses saved return the next one + last_idx = indices[target_id] + if last_idx < len(self._incoming): + incoming = self._incoming[last_idx] + if condition(incoming, target_id): + indices[target_id] += 1 + future.set_result(incoming) + return future + + # Otherwise the next incoming response will be the one to use + # + # Note how we fill "pending" before giving control back to the + # event loop through "await". We want to register it as soon as + # possible, since any other task switch may arrive with the result. + pending[target_id] = future + return self._get_result(future, start_time, timeout, pending, target_id) + + def get_edit(self, message=None, *, timeout=None): + """ + Awaits for an edit after the last message to arrive. + The arguments are the same as those for `get_response`. + """ + start_time = time.time() + target_id = self._get_message_id(message) + + target_date = self._edit_dates.get(target_id, 0) + earliest_edit = min( + (x for x in self._incoming + if x.edit_date + and x.id > target_id + and x.edit_date.timestamp() > target_date + ), + key=lambda x: x.edit_date.timestamp(), + default=None + ) + + future = self._client.loop.create_future() + if earliest_edit and earliest_edit.edit_date.timestamp() > target_date: + self._edit_dates[target_id] = earliest_edit.edit_date.timestamp() + future.set_result(earliest_edit) + return future # we should always return something we can await + + # Otherwise the next incoming response will be the one to use + self._pending_edits[target_id] = future + return self._get_result(future, start_time, timeout, self._pending_edits, target_id) + + def wait_read(self, message=None, *, timeout=None): + """ + Awaits for the sent message to be marked as read. Note that + receiving a response doesn't imply the message was read, and + this action will also trigger even without a response. + """ + start_time = time.time() + future = self._client.loop.create_future() + target_id = self._get_message_id(message) + + if self._last_read is None: + self._last_read = target_id - 1 + + if self._last_read >= target_id: + return + + self._pending_reads[target_id] = future + return self._get_result(future, start_time, timeout, self._pending_reads, target_id) + + async def wait_event(self, event, *, timeout=None): + """ + Waits for a custom event to occur. Timeouts still apply. + + .. note:: + + **Only use this if there isn't another method available!** + For example, don't use `wait_event` for new messages, + since `get_response` already exists, etc. + + Unless you're certain that your code will run fast enough, + generally you should get a "handle" of this special coroutine + before acting. In this example you will see how to wait for a user + to join a group with proper use of `wait_event`: + + .. code-block:: python + + from telethon import TelegramClient, events + + client = TelegramClient(...) + group_id = ... + + async def main(): + # Could also get the user id from an event; this is just an example + user_id = ... + + async with client.conversation(user_id) as conv: + # Get a handle to the future event we'll wait for + handle = conv.wait_event(events.ChatAction( + group_id, + func=lambda e: e.user_joined and e.user_id == user_id + )) + + # Perform whatever action in between + await conv.send_message('Please join this group before speaking to me!') + + # Wait for the event we registered above to fire + event = await handle + + # Continue with the conversation + await conv.send_message('Thanks!') + + This way your event can be registered before acting, + since the response may arrive before your event was + registered. It depends on your use case since this + also means the event can arrive before you send + a previous action. + """ + start_time = time.time() + if isinstance(event, type): + event = event() + + await event.resolve(self._client) + + counter = Conversation._custom_counter + Conversation._custom_counter += 1 + + future = self._client.loop.create_future() + self._custom[counter] = (event, future) + try: + return await self._get_result(future, start_time, timeout, self._custom, counter) + finally: + # Need to remove it from the dict if it times out, else we may + # try and fail to set the result later (#1618). + self._custom.pop(counter, None) + + async def _check_custom(self, built): + for key, (ev, fut) in list(self._custom.items()): + ev_type = type(ev) + inst = built[ev_type] + + if inst: + filter = ev.filter(inst) + if inspect.isawaitable(filter): + filter = await filter + + if filter: + fut.set_result(inst) + del self._custom[key] + + def _on_new_message(self, response): + response = response.message + if response.chat_id != self.chat_id or response.out: + return + + if len(self._incoming) == self._max_incoming: + self._cancel_all(ValueError('Too many incoming messages')) + return + + self._incoming.append(response) + + # Most of the time, these dictionaries will contain just one item + # TODO In fact, why not make it be that way? Force one item only. + # How often will people want to wait for two responses at + # the same time? It's impossible, first one will arrive + # and then another, so they can do that. + for msg_id, future in list(self._pending_responses.items()): + self._response_indices[msg_id] = len(self._incoming) + future.set_result(response) + del self._pending_responses[msg_id] + + for msg_id, future in list(self._pending_replies.items()): + if response.reply_to and msg_id == response.reply_to.reply_to_msg_id: + self._reply_indices[msg_id] = len(self._incoming) + future.set_result(response) + del self._pending_replies[msg_id] + + def _on_edit(self, message): + message = message.message + if message.chat_id != self.chat_id or message.out: + return + + # We have to update our incoming messages with the new edit date + for i, m in enumerate(self._incoming): + if m.id == message.id: + self._incoming[i] = message + break + + for msg_id, future in list(self._pending_edits.items()): + if msg_id < message.id: + edit_ts = message.edit_date.timestamp() + + # We compare <= because edit_ts resolution is always to + # seconds, but we may have increased _edit_dates before. + # Since the dates are ever growing this is not a problem. + if edit_ts <= self._edit_dates.get(msg_id, 0): + self._edit_dates[msg_id] += _EDIT_COLLISION_DELTA + else: + self._edit_dates[msg_id] = message.edit_date.timestamp() + + future.set_result(message) + del self._pending_edits[msg_id] + + def _on_read(self, event): + if event.chat_id != self.chat_id or event.inbox: + return + + self._last_read = event.max_id + + for msg_id, pending in list(self._pending_reads.items()): + if msg_id >= self._last_read: + pending.set_result(True) + del self._pending_reads[msg_id] + + def _get_message_id(self, message): + if message is not None: # 0 is valid but false-y, check for None + return message if isinstance(message, int) else message.id + elif self._last_outgoing: + return self._last_outgoing + else: + raise ValueError('No message was sent previously') + + @_checks_cancelled + def _get_result(self, future, start_time, timeout, pending, target_id): + due = self._total_due + if timeout is None: + timeout = self._timeout + + if timeout is not None: + due = min(due, start_time + timeout) + + # NOTE: We can't try/finally to pop from pending here because + # the event loop needs to get back to us, but it might + # dispatch another update before, and in that case a + # response could be set twice. So responses must be + # cleared when their futures are set to a result. + return asyncio.wait_for( + future, + timeout=None if due == float('inf') else due - time.time() + ) + + def _cancel_all(self, exception=None): + self._cancelled = True + for pending in itertools.chain( + self._pending_responses.values(), + self._pending_replies.values(), + self._pending_edits.values()): + if exception: + pending.set_exception(exception) + else: + pending.cancel() + + for _, fut in self._custom.values(): + if exception: + fut.set_exception(exception) + else: + fut.cancel() + + async def __aenter__(self): + self._input_chat = \ + await self._client.get_input_entity(self._input_chat) + + self._chat_peer = utils.get_peer(self._input_chat) + + # Make sure we're the only conversation in this chat if it's exclusive + chat_id = utils.get_peer_id(self._chat_peer) + conv_set = self._client._conversations[chat_id] + if self._exclusive and conv_set: + raise errors.AlreadyInConversationError() + + conv_set.add(self) + self._cancelled = False + + self._last_outgoing = 0 + self._last_incoming = 0 + for d in ( + self._outgoing, self._incoming, + self._pending_responses, self._pending_replies, + self._pending_edits, self._response_indices, + self._reply_indices, self._edit_dates, self._custom): + d.clear() + + if self._total_timeout: + self._total_due = time.time() + self._total_timeout + else: + self._total_due = float('inf') + + return self + + def cancel(self): + """ + Cancels the current conversation. Pending responses and subsequent + calls to get a response will raise ``asyncio.CancelledError``. + + This method is synchronous and should not be awaited. + """ + self._cancel_all() + + async def cancel_all(self): + """ + Calls `cancel` on *all* conversations in this chat. + + Note that you should ``await`` this method, since it's meant to be + used outside of a context manager, and it needs to resolve the chat. + """ + chat_id = await self._client.get_peer_id(self._input_chat) + for conv in self._client._conversations[chat_id]: + conv.cancel() + + async def __aexit__(self, exc_type, exc_val, exc_tb): + chat_id = utils.get_peer_id(self._chat_peer) + conv_set = self._client._conversations[chat_id] + conv_set.discard(self) + if not conv_set: + del self._client._conversations[chat_id] + + self._cancel_all() + + __enter__ = helpers._sync_enter + __exit__ = helpers._sync_exit diff --git a/module/scripts/lib/telethon/tl/custom/dialog.py b/module/scripts/lib/telethon/tl/custom/dialog.py new file mode 100644 index 0000000..5f5ba33 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/dialog.py @@ -0,0 +1,172 @@ +from . import Draft +from .. import TLObject, types, functions +from ... import utils + + +class Dialog: + """ + Custom class that encapsulates a dialog (an open "conversation" with + someone, a group or a channel) providing an abstraction to easily + access the input version/normal entity/message etc. The library will + return instances of this class when calling :meth:`.get_dialogs()`. + + Args: + dialog (:tl:`Dialog`): + The original ``Dialog`` instance. + + pinned (`bool`): + Whether this dialog is pinned to the top or not. + + folder_id (`folder_id`): + The folder ID that this dialog belongs to. + + archived (`bool`): + Whether this dialog is archived or not (``folder_id is None``). + + message (`Message `): + The last message sent on this dialog. Note that this member + will not be updated when new messages arrive, it's only set + on creation of the instance. + + date (`datetime`): + The date of the last message sent on this dialog. + + entity (`entity`): + The entity that belongs to this dialog (user, chat or channel). + + input_entity (:tl:`InputPeer`): + Input version of the entity. + + id (`int`): + The marked ID of the entity, which is guaranteed to be unique. + + name (`str`): + Display name for this dialog. For chats and channels this is + their title, and for users it's "First-Name Last-Name". + + title (`str`): + Alias for `name`. + + unread_count (`int`): + How many messages are currently unread in this dialog. Note that + this value won't update when new messages arrive. + + unread_mentions_count (`int`): + How many mentions are currently unread in this dialog. Note that + this value won't update when new messages arrive. + + unread_reactions_count (`int`): + How many reactions are currently unread in this dialog. Note that + this value won't update when new messages arrive. + + unread_poll_votes_count (`int`): + How many poll votes are currently unread in this dialog. Note that + this value won't update when new messages arrive. + + draft (`Draft `): + The draft object in this dialog. It will not be `None`, + so you can call ``draft.set_message(...)``. + + is_user (`bool`): + `True` if the `entity` is a :tl:`User`. + + is_group (`bool`): + `True` if the `entity` is a :tl:`Chat` + or a :tl:`Channel` megagroup. + + is_channel (`bool`): + `True` if the `entity` is a :tl:`Channel`. + """ + def __init__(self, client, dialog, entities, message): + # Both entities and messages being dicts {ID: item} + self._client = client + self.dialog = dialog + self.pinned = bool(dialog.pinned) + self.folder_id = dialog.folder_id + self.archived = dialog.folder_id is not None + self.message = message + self.date = getattr(self.message, 'date', None) + + self.entity = entities[utils.get_peer_id(dialog.peer)] + self.input_entity = utils.get_input_peer(self.entity) + self.id = utils.get_peer_id(self.entity) # ^ May be InputPeerSelf() + self.name = self.title = utils.get_display_name(self.entity) + + self.unread_count = dialog.unread_count + self.unread_mentions_count = dialog.unread_mentions_count + self.unread_reactions_count = dialog.unread_reactions_count + self.unread_poll_votes_count = dialog.unread_poll_votes_count + + self.draft = Draft(client, self.entity, self.dialog.draft) + + self.is_user = isinstance(self.entity, types.User) + self.is_group = ( + isinstance(self.entity, (types.Chat, types.ChatForbidden)) or + (isinstance(self.entity, types.Channel) and self.entity.megagroup) + ) + self.is_channel = isinstance(self.entity, types.Channel) + self.is_community = isinstance(self.entity, types.Community) + + async def send_message(self, *args, **kwargs): + """ + Sends a message to this dialog. This is just a wrapper around + ``client.send_message(dialog.input_entity, *args, **kwargs)``. + """ + return await self._client.send_message( + self.input_entity, *args, **kwargs) + + async def delete(self, revoke=False): + """ + Deletes the dialog from your dialog list. If you own the + channel this won't destroy it, only delete it from the list. + + Shorthand for `telethon.client.dialogs.DialogMethods.delete_dialog` + with ``entity`` already set. + """ + # Pass the entire entity so the method can determine whether + # the `Chat` is deactivated (in which case we don't kick ourselves, + # or it would raise `PEER_ID_INVALID`). + await self._client.delete_dialog(self.entity, revoke=revoke) + + async def archive(self, folder=1): + """ + Archives (or un-archives) this dialog. + + Args: + folder (`int`, optional): + The folder to which the dialog should be archived to. + + If you want to "un-archive" it, use ``folder=0``. + + Returns: + The :tl:`Updates` object that the request produces. + + Example: + + .. code-block:: python + + # Archiving + dialog.archive() + + # Un-archiving + dialog.archive(0) + """ + return await self._client(functions.folders.EditPeerFoldersRequest([ + types.InputFolderPeer(self.input_entity, folder_id=folder) + ])) + + def to_dict(self): + return { + '_': 'Dialog', + 'name': self.name, + 'date': self.date, + 'draft': self.draft, + 'message': self.message, + 'entity': self.entity, + } + + def __str__(self): + return TLObject.pretty_format(self.to_dict()) + + def stringify(self): + return TLObject.pretty_format(self.to_dict(), indent=0) diff --git a/module/scripts/lib/telethon/tl/custom/draft.py b/module/scripts/lib/telethon/tl/custom/draft.py new file mode 100644 index 0000000..bde965e --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/draft.py @@ -0,0 +1,191 @@ +import datetime + +from .. import TLObject, types +from ..functions.messages import SaveDraftRequest +from ..types import DraftMessage +from ...errors import RPCError +from ...extensions import markdown +from ...utils import get_input_peer, get_peer, get_peer_id + + +class Draft: + """ + Custom class that encapsulates a draft on the Telegram servers, providing + an abstraction to change the message conveniently. The library will return + instances of this class when calling :meth:`get_drafts()`. + + Args: + date (`datetime`): + The date of the draft. + + link_preview (`bool`): + Whether the link preview is enabled or not. + + reply_to_msg_id (`int`): + The message ID that the draft will reply to. + """ + def __init__(self, client, entity, draft): + self._client = client + self._peer = get_peer(entity) + self._entity = entity + self._input_entity = get_input_peer(entity) if entity else None + + if not draft or not isinstance(draft, DraftMessage): + draft = DraftMessage('', None, None, None, None) + + self._text = markdown.unparse(draft.message, draft.entities) + self._raw_text = draft.message + self.date = draft.date + self.link_preview = not draft.no_webpage + self.reply_to_msg_id = draft.reply_to.reply_to_msg_id if isinstance(draft.reply_to, types.InputReplyToMessage) else None + + @property + def entity(self): + """ + The entity that belongs to this dialog (user, chat or channel). + """ + return self._entity + + @property + def input_entity(self): + """ + Input version of the entity. + """ + if not self._input_entity: + try: + self._input_entity = self._client._mb_entity_cache.get( + get_peer_id(self._peer, add_mark=False))._as_input_peer() + except AttributeError: + pass + + return self._input_entity + + async def get_entity(self): + """ + Returns `entity` but will make an API call if necessary. + """ + if not self.entity and await self.get_input_entity(): + try: + self._entity =\ + await self._client.get_entity(self._input_entity) + except ValueError: + pass + + return self._entity + + async def get_input_entity(self): + """ + Returns `input_entity` but will make an API call if necessary. + """ + # We don't actually have an API call we can make yet + # to get more info, but keep this method for consistency. + return self.input_entity + + @property + def text(self): + """ + The markdown text contained in the draft. It will be + empty if there is no text (and hence no draft is set). + """ + return self._text + + @property + def raw_text(self): + """ + The raw (text without formatting) contained in the draft. + It will be empty if there is no text (thus draft not set). + """ + return self._raw_text + + @property + def is_empty(self): + """ + Convenience bool to determine if the draft is empty or not. + """ + return not self._text + + async def set_message( + self, text=None, reply_to=0, parse_mode=(), + link_preview=None): + """ + Changes the draft message on the Telegram servers. The changes are + reflected in this object. + + :param str text: New text of the draft. + Preserved if left as None. + + :param int reply_to: Message ID to reply to. + Preserved if left as 0, erased if set to None. + + :param bool link_preview: Whether to attach a web page preview. + Preserved if left as None. + + :param str parse_mode: The parse mode to be used for the text. + :return bool: `True` on success. + """ + if text is None: + text = self._text + + if reply_to == 0: + reply_to = self.reply_to_msg_id + + if link_preview is None: + link_preview = self.link_preview + + raw_text, entities =\ + await self._client._parse_message_text(text, parse_mode) + + result = await self._client(SaveDraftRequest( + peer=self._peer, + message=raw_text, + no_webpage=not link_preview, + reply_to=None if reply_to is None else types.InputReplyToMessage(reply_to), + entities=entities + )) + + if result: + self._text = text + self._raw_text = raw_text + self.link_preview = link_preview + self.reply_to_msg_id = reply_to + self.date = datetime.datetime.now(tz=datetime.timezone.utc) + + return result + + async def send(self, clear=True, parse_mode=()): + """ + Sends the contents of this draft to the dialog. This is just a + wrapper around ``send_message(dialog.input_entity, *args, **kwargs)``. + """ + await self._client.send_message( + self._peer, self.text, reply_to=self.reply_to_msg_id, + link_preview=self.link_preview, parse_mode=parse_mode, + clear_draft=clear + ) + + async def delete(self): + """ + Deletes this draft, and returns `True` on success. + """ + return await self.set_message(text='') + + def to_dict(self): + try: + entity = self.entity + except RPCError as e: + entity = e + + return { + '_': 'Draft', + 'text': self.text, + 'entity': entity, + 'date': self.date, + 'link_preview': self.link_preview, + 'reply_to_msg_id': self.reply_to_msg_id + } + + def __str__(self): + return TLObject.pretty_format(self.to_dict()) + + def stringify(self): + return TLObject.pretty_format(self.to_dict(), indent=0) diff --git a/module/scripts/lib/telethon/tl/custom/file.py b/module/scripts/lib/telethon/tl/custom/file.py new file mode 100644 index 0000000..aa71482 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/file.py @@ -0,0 +1,146 @@ +import mimetypes +import os + +from ... import utils +from ...tl import types + + +class File: + """ + Convenience class over media like photos or documents, which + supports accessing the attributes in a more convenient way. + + If any of the attributes are not present in the current media, + the properties will be `None`. + + The original media is available through the ``media`` attribute. + """ + def __init__(self, media): + self.media = media + + @property + def id(self): + """ + The old bot-API style ``file_id`` representing this file. + + .. warning:: + + This feature has not been maintained for a long time and + may not work. It will be removed in future versions. + + .. note:: + + This file ID may not work under user accounts, + but should still be usable by bot accounts. + + You can, however, still use it to identify + a file in for example a database. + """ + return utils.pack_bot_file_id(self.media) + + @property + def name(self): + """ + The file name of this document. + """ + return self._from_attr(types.DocumentAttributeFilename, 'file_name') + + @property + def ext(self): + """ + The extension from the mime type of this file. + + If the mime type is unknown, the extension + from the file name (if any) will be used. + """ + return ( + mimetypes.guess_extension(self.mime_type) + or os.path.splitext(self.name or '')[-1] + or None + ) + + @property + def mime_type(self): + """ + The mime-type of this file. + """ + if isinstance(self.media, types.Photo): + return 'image/jpeg' + elif isinstance(self.media, types.Document): + return self.media.mime_type + + @property + def width(self): + """ + The width in pixels of this media if it's a photo or a video. + """ + if isinstance(self.media, types.Photo): + return max(getattr(s, 'w', 0) for s in self.media.sizes) + + return self._from_attr(( + types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'w') + + @property + def height(self): + """ + The height in pixels of this media if it's a photo or a video. + """ + if isinstance(self.media, types.Photo): + return max(getattr(s, 'h', 0) for s in self.media.sizes) + + return self._from_attr(( + types.DocumentAttributeImageSize, types.DocumentAttributeVideo), 'h') + + @property + def duration(self): + """ + The duration in seconds of the audio or video. + """ + return self._from_attr(( + types.DocumentAttributeAudio, types.DocumentAttributeVideo), 'duration') + + @property + def title(self): + """ + The title of the song. + """ + return self._from_attr(types.DocumentAttributeAudio, 'title') + + @property + def performer(self): + """ + The performer of the song. + """ + return self._from_attr(types.DocumentAttributeAudio, 'performer') + + @property + def emoji(self): + """ + A string with all emoji that represent the current sticker. + """ + return self._from_attr(types.DocumentAttributeSticker, 'alt') + + @property + def sticker_set(self): + """ + The :tl:`InputStickerSet` to which the sticker file belongs. + """ + return self._from_attr(types.DocumentAttributeSticker, 'stickerset') + + @property + def size(self): + """ + The size in bytes of this file. + + For photos, this is the heaviest thumbnail, as it often repressents the largest dimensions. + """ + if isinstance(self.media, types.Photo): + return max(filter(None, map(utils._photo_size_byte_count, self.media.sizes)), default=None) + elif isinstance(self.media, types.Document): + return self.media.size + + def _from_attr(self, cls, field): + if isinstance(self.media, types.Document): + for attr in self.media.attributes: + if isinstance(attr, cls): + return getattr(attr, field, None) diff --git a/module/scripts/lib/telethon/tl/custom/forward.py b/module/scripts/lib/telethon/tl/custom/forward.py new file mode 100644 index 0000000..afe6b3a --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/forward.py @@ -0,0 +1,51 @@ +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter +from ... import utils, helpers +from ...tl import types + + +class Forward(ChatGetter, SenderGetter): + """ + Custom class that encapsulates a :tl:`MessageFwdHeader` providing an + abstraction to easily access information like the original sender. + + Remember that this class implements `ChatGetter + ` and `SenderGetter + ` which means you + have access to all their sender and chat properties and methods. + + Attributes: + + original_fwd (:tl:`MessageFwdHeader`): + The original :tl:`MessageFwdHeader` instance. + + Any other attribute: + Attributes not described here are the same as those available + in the original :tl:`MessageFwdHeader`. + """ + def __init__(self, client, original, entities): + # Copy all the fields, not reference! It would cause memory cycles: + # self.original_fwd.original_fwd.original_fwd.original_fwd + # ...would be valid if we referenced. + self.__dict__.update(original.__dict__) + self.original_fwd = original + + sender_id = sender = input_sender = peer = chat = input_chat = None + if original.from_id: + ty = helpers._entity_type(original.from_id) + if ty == helpers._EntityType.USER: + sender_id = utils.get_peer_id(original.from_id) + sender, input_sender = utils._get_entity_pair( + sender_id, entities, client._mb_entity_cache) + + elif ty in (helpers._EntityType.CHAT, helpers._EntityType.CHANNEL): + peer = original.from_id + chat, input_chat = utils._get_entity_pair( + utils.get_peer_id(peer), entities, client._mb_entity_cache) + + # This call resets the client + ChatGetter.__init__(self, peer, chat=chat, input_chat=input_chat) + SenderGetter.__init__(self, sender_id, sender=sender, input_sender=input_sender) + self._client = client + + # TODO We could reload the message diff --git a/module/scripts/lib/telethon/tl/custom/inlinebuilder.py b/module/scripts/lib/telethon/tl/custom/inlinebuilder.py new file mode 100644 index 0000000..1bea134 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/inlinebuilder.py @@ -0,0 +1,450 @@ +import hashlib + +from .. import functions, types +from ... import utils + +_TYPE_TO_MIMES = { + 'gif': ['image/gif'], # 'video/mp4' too, but that's used for video + 'article': ['text/html'], + 'audio': ['audio/mpeg'], + 'contact': [], + 'file': ['application/pdf', 'application/zip'], # actually any + 'geo': [], + 'photo': ['image/jpeg'], + 'sticker': ['image/webp', 'application/x-tgsticker'], + 'venue': [], + 'video': ['video/mp4'], # tdlib includes text/html for some reason + 'voice': ['audio/ogg'], +} + + +class InlineBuilder: + """ + Helper class to allow defining `InlineQuery + ` ``results``. + + Common arguments to all methods are + explained here to avoid repetition: + + text (`str`, optional): + If present, the user will send a text + message with this text upon being clicked. + + link_preview (`bool`, optional): + Whether to show a link preview in the sent + text message or not. + + geo (:tl:`InputGeoPoint`, :tl:`GeoPoint`, :tl:`InputMediaVenue`, :tl:`MessageMediaVenue`, optional): + If present, it may either be a geo point or a venue. + + period (int, optional): + The period in seconds to be used for geo points. + + contact (:tl:`InputMediaContact`, :tl:`MessageMediaContact`, optional): + If present, it must be the contact information to send. + + game (`bool`, optional): + May be `True` to indicate that the game will be sent. + + buttons (`list`, `custom.Button `, :tl:`KeyboardInlineButton`, optional): + Same as ``buttons`` for `client.send_message() + `. + + parse_mode (`str`, optional): + Same as ``parse_mode`` for `client.send_message() + `. + + id (`str`, optional): + The string ID to use for this result. If not present, it + will be the SHA256 hexadecimal digest of converting the + created :tl:`InputBotInlineResult` with empty ID to ``bytes()``, + so that the ID will be deterministic for the same input. + + .. note:: + + If two inputs are exactly the same, their IDs will be the same + too. If you send two articles with the same ID, it will raise + ``ResultIdDuplicateError``. Consider giving them an explicit + ID if you need to send two results that are the same. + """ + def __init__(self, client): + self._client = client + + # noinspection PyIncorrectDocstring + async def article( + self, title, description=None, + *, url=None, thumb=None, content=None, + id=None, text=None, parse_mode=(), link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None + ): + """ + Creates new inline result of article type. + + Args: + title (`str`): + The title to be shown for this result. + + description (`str`, optional): + Further explanation of what this result means. + + url (`str`, optional): + The URL to be shown for this result. + + thumb (:tl:`InputWebDocument`, optional): + The thumbnail to be shown for this result. + For now it has to be a :tl:`InputWebDocument` if present. + + content (:tl:`InputWebDocument`, optional): + The content to be shown for this result. + For now it has to be a :tl:`InputWebDocument` if present. + + Example: + .. code-block:: python + + results = [ + # Option with title and description sending a message. + builder.article( + title='First option', + description='This is the first option', + text='Text sent after clicking this option', + ), + # Option with title URL to be opened when clicked. + builder.article( + title='Second option', + url='https://example.com', + text='Text sent if the user clicks the option and not the URL', + ), + # Sending a message with buttons. + # You can use a list or a list of lists to include more buttons. + builder.article( + title='Third option', + text='Text sent with buttons below', + buttons=Button.url('https://example.com'), + ), + ] + """ + # TODO Does 'article' work always? + # article, photo, gif, mpeg4_gif, video, audio, + # voice, document, location, venue, contact, game + result = types.InputBotInlineResult( + id=id or '', + type='article', + send_message=await self._message( + text=text, parse_mode=parse_mode, link_preview=link_preview, + geo=geo, period=period, + contact=contact, + game=game, + buttons=buttons + ), + title=title, + description=description, + url=url, + thumb=thumb, + content=content + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + # noinspection PyIncorrectDocstring + async def photo( + self, file, *, id=None, include_media=True, + text=None, parse_mode=(), link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None + ): + """ + Creates a new inline result of photo type. + + Args: + include_media (`bool`, optional): + Whether the photo file used to display the result should be + included in the message itself or not. By default, the photo + is included, and the text parameter alters the caption. + + file (`obj`, optional): + Same as ``file`` for `client.send_file() + `. + + Example: + .. code-block:: python + + results = [ + # Sending just the photo when the user selects it. + builder.photo('/path/to/photo.jpg'), + + # Including a caption with some in-memory photo. + photo_bytesio = ... + builder.photo( + photo_bytesio, + text='This will be the caption of the sent photo', + ), + + # Sending just the message without including the photo. + builder.photo( + photo, + text='This will be a normal text message', + include_media=False, + ), + ] + """ + try: + fh = utils.get_input_photo(file) + except TypeError: + _, media, _ = await self._client._file_to_media( + file, allow_cache=True, as_image=True + ) + if isinstance(media, types.InputPhoto): + fh = media + else: + r = await self._client(functions.messages.UploadMediaRequest( + types.InputPeerSelf(), media=media + )) + fh = utils.get_input_photo(r.photo) + + result = types.InputBotInlineResultPhoto( + id=id or '', + type='photo', + photo=fh, + send_message=await self._message( + text=text or '', + parse_mode=parse_mode, + link_preview=link_preview, + media=include_media, + geo=geo, + period=period, + contact=contact, + game=game, + buttons=buttons + ) + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + # noinspection PyIncorrectDocstring + async def document( + self, file, title=None, *, description=None, type=None, + mime_type=None, attributes=None, force_document=False, + voice_note=False, video_note=False, use_cache=True, id=None, + text=None, parse_mode=(), link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None, + include_media=True + ): + """ + Creates a new inline result of document type. + + `use_cache`, `mime_type`, `attributes`, `force_document`, + `voice_note` and `video_note` are described in `client.send_file + `. + + Args: + file (`obj`): + Same as ``file`` for `client.send_file() + `. + + title (`str`, optional): + The title to be shown for this result. + + description (`str`, optional): + Further explanation of what this result means. + + type (`str`, optional): + The type of the document. May be one of: article, audio, + contact, file, geo, gif, photo, sticker, venue, video, voice. + It will be automatically set if ``mime_type`` is specified, + and default to ``'file'`` if no matching mime type is found. + you may need to pass ``attributes`` in order to use ``type`` + effectively. + + attributes (`list`, optional): + Optional attributes that override the inferred ones, like + :tl:`DocumentAttributeFilename` and so on. + + include_media (`bool`, optional): + Whether the document file used to display the result should be + included in the message itself or not. By default, the document + is included, and the text parameter alters the caption. + + Example: + .. code-block:: python + + results = [ + # Sending just the file when the user selects it. + builder.document('/path/to/file.pdf'), + + # Including a caption with some in-memory file. + file_bytesio = ... + builder.document( + file_bytesio, + text='This will be the caption of the sent file', + ), + + # Sending just the message without including the file. + builder.document( + photo, + text='This will be a normal text message', + include_media=False, + ), + ] + """ + if type is None: + if voice_note: + type = 'voice' + elif mime_type: + for ty, mimes in _TYPE_TO_MIMES.items(): + for mime in mimes: + if mime_type == mime: + type = ty + break + + if type is None: + type = 'file' + + try: + fh = utils.get_input_document(file) + except TypeError: + _, media, _ = await self._client._file_to_media( + file, + mime_type=mime_type, + attributes=attributes, + force_document=force_document, + voice_note=voice_note, + video_note=video_note, + allow_cache=use_cache + ) + if isinstance(media, types.InputDocument): + fh = media + else: + r = await self._client(functions.messages.UploadMediaRequest( + types.InputPeerSelf(), media=media + )) + fh = utils.get_input_document(r.document) + + result = types.InputBotInlineResultDocument( + id=id or '', + type=type, + document=fh, + send_message=await self._message( + # Empty string for text if there's media but text is None. + # We may want to display a document but send text; however + # default to sending the media (without text, i.e. stickers). + text=text or '', + parse_mode=parse_mode, + link_preview=link_preview, + media=include_media, + geo=geo, + period=period, + contact=contact, + game=game, + buttons=buttons + ), + title=title, + description=description + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + # noinspection PyIncorrectDocstring + async def game( + self, short_name, *, id=None, + text=None, parse_mode=(), link_preview=True, + geo=None, period=60, contact=None, game=False, buttons=None + ): + """ + Creates a new inline result of game type. + + Args: + short_name (`str`): + The short name of the game to use. + """ + result = types.InputBotInlineResultGame( + id=id or '', + short_name=short_name, + send_message=await self._message( + text=text, parse_mode=parse_mode, link_preview=link_preview, + geo=geo, period=period, + contact=contact, + game=game, + buttons=buttons + ) + ) + if id is None: + result.id = hashlib.sha256(bytes(result)).hexdigest() + + return result + + async def _message( + self, *, + text=None, parse_mode=(), link_preview=True, media=False, + geo=None, period=60, contact=None, game=False, buttons=None + ): + # Empty strings are valid but false-y; if they're empty use dummy '\0' + args = ('\0' if text == '' else text, geo, contact, game) + if sum(1 for x in args if x is not None and x is not False) != 1: + raise ValueError( + 'Must set exactly one of text, geo, contact or game (set {})' + .format(', '.join(x[0] for x in zip( + 'text geo contact game'.split(), args) if x[1]) or 'none') + ) + + markup = self._client.build_reply_markup(buttons) + if text is not None: + text, msg_entities = await self._client._parse_message_text( + text, parse_mode + ) + if media: + # "MediaAuto" means it will use whatever media the inline + # result itself has (stickers, photos, or documents), while + # respecting the user's text (caption) and formatting. + return types.InputBotInlineMessageMediaAuto( + message=text, + entities=msg_entities, + reply_markup=markup + ) + else: + return types.InputBotInlineMessageText( + message=text, + no_webpage=not link_preview, + entities=msg_entities, + reply_markup=markup + ) + elif isinstance(geo, (types.InputGeoPoint, types.GeoPoint)): + return types.InputBotInlineMessageMediaGeo( + geo_point=utils.get_input_geo(geo), + period=period, + reply_markup=markup + ) + elif isinstance(geo, (types.InputMediaVenue, types.MessageMediaVenue)): + if isinstance(geo, types.InputMediaVenue): + geo_point = geo.geo_point + else: + geo_point = geo.geo + + return types.InputBotInlineMessageMediaVenue( + geo_point=geo_point, + title=geo.title, + address=geo.address, + provider=geo.provider, + venue_id=geo.venue_id, + venue_type=geo.venue_type, + reply_markup=markup + ) + elif isinstance(contact, ( + types.InputMediaContact, types.MessageMediaContact)): + return types.InputBotInlineMessageMediaContact( + phone_number=contact.phone_number, + first_name=contact.first_name, + last_name=contact.last_name, + vcard=contact.vcard, + reply_markup=markup + ) + elif game: + return types.InputBotInlineMessageGame( + reply_markup=markup + ) + else: + raise ValueError('No text, game or valid geo or contact given') diff --git a/module/scripts/lib/telethon/tl/custom/inlineresult.py b/module/scripts/lib/telethon/tl/custom/inlineresult.py new file mode 100644 index 0000000..d56b2fe --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/inlineresult.py @@ -0,0 +1,176 @@ +from .. import types, functions +from ... import utils + + +class InlineResult: + """ + Custom class that encapsulates a bot inline result providing + an abstraction to easily access some commonly needed features + (such as clicking a result to select it). + + Attributes: + + result (:tl:`BotInlineResult`): + The original :tl:`BotInlineResult` object. + """ + # tdlib types are the following (InlineQueriesManager::answer_inline_query @ 1a4a834): + # gif, article, audio, contact, file, geo, photo, sticker, venue, video, voice + # + # However, those documented in https://core.telegram.org/bots/api#inline-mode are different. + ARTICLE = 'article' + PHOTO = 'photo' + GIF = 'gif' + VIDEO = 'video' + VIDEO_GIF = 'mpeg4_gif' + AUDIO = 'audio' + DOCUMENT = 'document' + LOCATION = 'location' + VENUE = 'venue' + CONTACT = 'contact' + GAME = 'game' + + def __init__(self, client, original, query_id=None, *, entity=None): + self._client = client + self.result = original + self._query_id = query_id + self._entity = entity + + @property + def type(self): + """ + The always-present type of this result. It will be one of: + ``'article'``, ``'photo'``, ``'gif'``, ``'mpeg4_gif'``, ``'video'``, + ``'audio'``, ``'voice'``, ``'document'``, ``'location'``, ``'venue'``, + ``'contact'``, ``'game'``. + + You can access all of these constants through `InlineResult`, + such as `InlineResult.ARTICLE`, `InlineResult.VIDEO_GIF`, etc. + """ + return self.result.type + + @property + def message(self): + """ + The always-present :tl:`BotInlineMessage` that + will be sent if `click` is called on this result. + """ + return self.result.send_message + + @property + def title(self): + """ + The title for this inline result. It may be `None`. + """ + return self.result.title + + @property + def description(self): + """ + The description for this inline result. It may be `None`. + """ + return self.result.description + + @property + def url(self): + """ + The URL present in this inline results. If you want to "click" + this URL to open it in your browser, you should use Python's + `webbrowser.open(url)` for such task. + """ + if isinstance(self.result, types.BotInlineResult): + return self.result.url + + @property + def photo(self): + """ + Returns either the :tl:`WebDocument` thumbnail for + normal results or the :tl:`Photo` for media results. + """ + if isinstance(self.result, types.BotInlineResult): + return self.result.thumb + elif isinstance(self.result, types.BotInlineMediaResult): + return self.result.photo + + @property + def document(self): + """ + Returns either the :tl:`WebDocument` content for + normal results or the :tl:`Document` for media results. + """ + if isinstance(self.result, types.BotInlineResult): + return self.result.content + elif isinstance(self.result, types.BotInlineMediaResult): + return self.result.document + + async def click(self, entity=None, reply_to=None, comment_to=None, + silent=False, clear_draft=False, hide_via=False, + background=None): + """ + Clicks this result and sends the associated `message`. + + Args: + entity (`entity`): + The entity to which the message of this result should be sent. + + reply_to (`int` | `Message `, optional): + If present, the sent message will reply to this ID or message. + + comment_to (`int` | `Message `, optional): + Similar to ``reply_to``, but replies in the linked group of a + broadcast channel instead (effectively leaving a "comment to" + the specified message). + + silent (`bool`, optional): + Whether the message should notify people with sound or not. + Defaults to `False` (send with a notification sound unless + the person has the chat muted). Set it to `True` to alter + this behaviour. + + clear_draft (`bool`, optional): + Whether the draft should be removed after sending the + message from this result or not. Defaults to `False`. + + hide_via (`bool`, optional): + Whether the "via @bot" should be hidden or not. + Only works with certain bots (like @bing or @gif). + + background (`bool`, optional): + Whether the message should be send in background. + + """ + if entity: + entity = await self._client.get_input_entity(entity) + elif self._entity: + entity = self._entity + else: + raise ValueError('You must provide the entity where the result should be sent to') + + if comment_to: + entity, reply_id = await self._client._get_comment_data(entity, comment_to) + else: + reply_id = None if reply_to is None else utils.get_message_id(reply_to) + + req = functions.messages.SendInlineBotResultRequest( + peer=entity, + query_id=self._query_id, + id=self.result.id, + silent=silent, + background=background, + clear_draft=clear_draft, + hide_via=hide_via, + reply_to=None if reply_id is None else types.InputReplyToMessage(reply_id) + ) + return self._client._get_response_message( + req, await self._client(req), entity) + + async def download_media(self, *args, **kwargs): + """ + Downloads the media in this result (if there is a document, the + document will be downloaded; otherwise, the photo will if present). + + This is a wrapper around `client.download_media + `. + """ + if self.document or self.photo: + return await self._client.download_media( + self.document or self.photo, *args, **kwargs) diff --git a/module/scripts/lib/telethon/tl/custom/inlineresults.py b/module/scripts/lib/telethon/tl/custom/inlineresults.py new file mode 100644 index 0000000..9ab0c0a --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/inlineresults.py @@ -0,0 +1,83 @@ +import time + +from .inlineresult import InlineResult + + +class InlineResults(list): + """ + Custom class that encapsulates :tl:`BotResults` providing + an abstraction to easily access some commonly needed features + (such as clicking one of the results to select it) + + Note that this is a list of `InlineResult + ` + so you can iterate over it or use indices to + access its elements. In addition, it has some + attributes. + + Attributes: + result (:tl:`BotResults`): + The original :tl:`BotResults` object. + + query_id (`int`): + The random ID that identifies this query. + + cache_time (`int`): + For how long the results should be considered + valid. You can call `results_valid` at any + moment to determine if the results are still + valid or not. + + users (:tl:`User`): + The users present in this inline query. + + gallery (`bool`): + Whether these results should be presented + in a grid (as a gallery of images) or not. + + next_offset (`str`, optional): + The string to be used as an offset to get + the next chunk of results, if any. + + switch_pm (:tl:`InlineBotSwitchPM`, optional): + If presents, the results should show a button to + switch to a private conversation with the bot using + the text in this object. + """ + def __init__(self, client, original, *, entity=None): + super().__init__(InlineResult(client, x, original.query_id, entity=entity) + for x in original.results) + + self.result = original + self.query_id = original.query_id + self.cache_time = original.cache_time + self._valid_until = time.time() + self.cache_time + self.users = original.users + self.gallery = bool(original.gallery) + self.next_offset = original.next_offset + self.switch_pm = original.switch_pm + + def results_valid(self): + """ + Returns `True` if the cache time has not expired + yet and the results can still be considered valid. + """ + return time.time() < self._valid_until + + def _to_str(self, item_function): + return ('[{}, query_id={}, cache_time={}, users={}, gallery={}, ' + 'next_offset={}, switch_pm={}]'.format( + ', '.join(item_function(x) for x in self), + self.query_id, + self.cache_time, + self.users, + self.gallery, + self.next_offset, + self.switch_pm + )) + + def __str__(self): + return self._to_str(str) + + def __repr__(self): + return self._to_str(repr) diff --git a/module/scripts/lib/telethon/tl/custom/inputsizedfile.py b/module/scripts/lib/telethon/tl/custom/inputsizedfile.py new file mode 100644 index 0000000..fcb743f --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/inputsizedfile.py @@ -0,0 +1,9 @@ +from ..types import InputFile + + +class InputSizedFile(InputFile): + """InputFile class with two extra parameters: md5 (digest) and size""" + def __init__(self, id_, parts, name, md5, size): + super().__init__(id_, parts, name, md5.hexdigest()) + self.md5 = md5.digest() + self.size = size diff --git a/module/scripts/lib/telethon/tl/custom/message.py b/module/scripts/lib/telethon/tl/custom/message.py new file mode 100644 index 0000000..b490df9 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/message.py @@ -0,0 +1,1248 @@ +from typing import Optional, List, TYPE_CHECKING +from datetime import datetime +from .chatgetter import ChatGetter +from .sendergetter import SenderGetter +from .messagebutton import MessageButton +from .forward import Forward +from .file import File +from .. import TLObject, types, functions, alltlobjects +from ... import utils, errors + + +# TODO Figure out a way to have the code generator error on missing fields +# Maybe parsing the init function alone if that's possible. +class Message(ChatGetter, SenderGetter, TLObject): + """ + This custom class aggregates both :tl:`Message` and + :tl:`MessageService` to ease accessing their members. + + Remember that this class implements `ChatGetter + ` and `SenderGetter + ` which means you + have access to all their sender and chat properties and methods. + + Members: + out (`bool`): + Whether the message is outgoing (i.e. you sent it from + another session) or incoming (i.e. someone else sent it). + + Note that messages in your own chat are always incoming, + but this member will be `True` if you send a message + to your own chat. Messages you forward to your chat are + *not* considered outgoing, just like official clients + display them. + + mentioned (`bool`): + Whether you were mentioned in this message or not. + Note that replies to your own messages also count + as mentions. + + media_unread (`bool`): + Whether you have read the media in this message + or not, e.g. listened to the voice note media. + + silent (`bool`): + Whether the message should notify people with sound or not. + Previously used in channels, but since 9 August 2019, it can + also be `used in private chats + `_. + + post (`bool`): + Whether this message is a post in a broadcast + channel or not. + + from_scheduled (`bool`): + Whether this message was originated from a previously-scheduled + message or not. + + legacy (`bool`): + Whether this is a legacy message or not. + + edit_hide (`bool`): + Whether the edited mark of this message is edited + should be hidden (e.g. in GUI clients) or shown. + + pinned (`bool`): + Whether this message is currently pinned or not. + + noforwards (`bool`): + Whether this message can be forwarded or not. + + invert_media (`bool`): + Whether the media in this message should be inverted. + + offline (`bool`): + Whether the message was sent by an implicit action, for example, as an away or a greeting business message, or as a scheduled message. + + id (`int`): + The ID of this message. This field is *always* present. + Any other member is optional and may be `None`. + + from_id (:tl:`Peer`): + The peer who sent this message, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. + This value will be `None` for anonymous messages. + + peer_id (:tl:`Peer`): + The peer to which this message was sent, which is either + :tl:`PeerUser`, :tl:`PeerChat` or :tl:`PeerChannel`. This + will always be present except for empty messages. + + fwd_from (:tl:`MessageFwdHeader`): + The original forward header if this message is a forward. + You should probably use the `forward` property instead. + + via_bot_id (`int`): + The ID of the bot used to send this message + through its inline mode (e.g. "via @like"). + + reply_to (:tl:`MessageReplyHeader` | :tl:`MessageReplyStoryHeader`): + The original reply header if this message is replying to another. + + date (`datetime`): + The UTC+0 `datetime` object indicating when this message + was sent. This will always be present except for empty + messages. + + message (`str`): + The string text of the message for `Message + ` instances, + which will be `None` for other types of messages. + + media (:tl:`MessageMedia`): + The media sent with this message if any (such as + photos, videos, documents, gifs, stickers, etc.). + + You may want to access the `photo`, `document` + etc. properties instead. + + If the media was not present or it was :tl:`MessageMediaEmpty`, + this member will instead be `None` for convenience. + + reply_markup (:tl:`ReplyMarkup`): + The reply markup for this message (which was sent + either via a bot or by a bot). You probably want + to access `buttons` instead. + + entities (List[:tl:`MessageEntity`]): + The list of markup entities in this message, + such as bold, italics, code, hyperlinks, etc. + + views (`int`): + The number of views this message from a broadcast + channel has. This is also present in forwards. + + forwards (`int`): + The number of times this message has been forwarded. + + replies (`int`): + The number of times another message has replied to this message. + + edit_date (`datetime`): + The date when this message was last edited. + + post_author (`str`): + The display name of the message sender to + show in messages sent to broadcast channels. + + grouped_id (`int`): + If this message belongs to a group of messages + (photo albums or video albums), all of them will + have the same value here. + + reactions (:tl:`MessageReactions`) + Reactions to this message. + + restriction_reason (List[:tl:`RestrictionReason`]) + An optional list of reasons why this message was restricted. + If the list is `None`, this message has not been restricted. + + ttl_period (`int`): + The Time To Live period configured for this message. + The message should be erased from wherever it's stored (memory, a + local database, etc.) when + ``datetime.now() > message.date + timedelta(seconds=message.ttl_period)``. + + action (:tl:`MessageAction`): + The message action object of the message for :tl:`MessageService` + instances, which will be `None` for other types of messages. + + saved_peer_id (:tl:`Peer`) + """ + + # region Initialization + + def __init__( + self, + id: int, + peer_id: types.TypePeer, + date: Optional[datetime] = None, + message: Optional[str] = None, + # Copied from Message.__init__ signature + out: Optional[bool] = None, + mentioned: Optional[bool] = None, + media_unread: Optional[bool] = None, + silent: Optional[bool] = None, + post: Optional[bool] = None, + from_scheduled: Optional[bool] = None, + legacy: Optional[bool] = None, + edit_hide: Optional[bool] = None, + pinned: Optional[bool] = None, + noforwards: Optional[bool] = None, + invert_media: Optional[bool] = None, + offline: Optional[bool] = None, + video_processing_pending: Optional[bool] = None, + paid_suggested_post_stars: Optional[bool] = None, + paid_suggested_post_ton: Optional[bool] = None, + from_id: Optional[types.TypePeer] = None, + from_boosts_applied: Optional[int] = None, + from_rank: Optional[str] = None, + saved_peer_id: Optional[types.TypePeer] = None, + fwd_from: Optional[types.TypeMessageFwdHeader] = None, + via_bot_id: Optional[int] = None, + via_business_bot_id: Optional[int] = None, + guestchat_via_from: Optional[types.TypePeer] = None, + reply_to: Optional[types.TypeMessageReplyHeader] = None, + media: Optional[types.TypeMessageMedia] = None, + reply_markup: Optional[types.TypeReplyMarkup] = None, + entities: Optional[List[types.TypeMessageEntity]] = None, + views: Optional[int] = None, + forwards: Optional[int] = None, + replies: Optional[types.TypeMessageReplies] = None, + edit_date: Optional[datetime] = None, + post_author: Optional[str] = None, + grouped_id: Optional[int] = None, + reactions: Optional[types.TypeMessageReactions] = None, + restriction_reason: Optional[List[types.TypeRestrictionReason]] = None, + ttl_period: Optional[int] = None, + quick_reply_shortcut_id: Optional[int] = None, + effect: Optional[int] = None, + factcheck: Optional[types.TypeFactCheck] = None, + report_delivery_until_date: Optional[datetime] = None, + paid_message_stars: Optional[int] = None, + suggested_post: Optional[types.TypeSuggestedPost] = None, + schedule_repeat_period: Optional[int] = None, + summary_from_language: Optional[str] = None, + rich_message: Optional[types.RichMessage] = None, + # Copied from MessageService.__init__ signature + action: Optional[types.TypeMessageAction] = None, + reactions_are_possible: Optional[bool] = None, + ): + # Copied from Message.__init__ body + self.id = id + self.peer_id = peer_id + self.date = date + self.message = message + self.out = bool(out) + self.mentioned = mentioned + self.media_unread = media_unread + self.silent = silent + self.post = post + self.from_scheduled = from_scheduled + self.legacy = legacy + self.edit_hide = edit_hide + self.pinned = pinned + self.noforwards = noforwards + self.invert_media = invert_media + self.offline = offline + self.video_processing_pending = video_processing_pending + self.paid_suggested_post_stars = paid_suggested_post_stars + self.paid_suggested_post_ton = paid_suggested_post_ton + self.from_id = from_id + self.from_boosts_applied = from_boosts_applied + self.from_rank = from_rank + self.saved_peer_id = saved_peer_id + self.fwd_from = fwd_from + self.via_bot_id = via_bot_id + self.via_business_bot_id = via_business_bot_id + self.guestchat_via_from = guestchat_via_from + self.reply_to = reply_to + self.media = None if isinstance(media, types.MessageMediaEmpty) else media + self.reply_markup = reply_markup + self.entities = entities + self.views = views + self.forwards = forwards + self.replies = replies + self.edit_date = edit_date + self.post_author = post_author + self.grouped_id = grouped_id + self.reactions = reactions + self.restriction_reason = restriction_reason + self.ttl_period = ttl_period + self.quick_reply_shortcut_id = quick_reply_shortcut_id + self.effect = effect + self.factcheck = factcheck + self.report_delivery_until_date = report_delivery_until_date + self.paid_message_stars = paid_message_stars + self.suggested_post = suggested_post + self.schedule_repeat_period = schedule_repeat_period + self.summary_from_language = summary_from_language + self.rich_message = rich_message + # Copied from MessageService.__init__ body + self.action = action + self.reactions_are_possible = reactions_are_possible + + # Convenient storage for custom functions + # TODO This is becoming a bit of bloat + self._client = None + self._text = None + self._file = None + self._reply_message = None + self._buttons = None + self._buttons_flat = None + self._buttons_count = None + self._via_bot = None + self._via_input_bot = None + self._action_entities = None + self._linked_chat = None + + sender_id = None + if from_id is not None: + sender_id = utils.get_peer_id(from_id) + elif peer_id: + # If the message comes from a Channel, let the sender be it + # ...or... + # incoming messages in private conversations no longer have from_id + # (layer 119+), but the sender can only be the chat we're in. + if post or (not out and isinstance(peer_id, types.PeerUser)): + sender_id = utils.get_peer_id(peer_id) + + # Note that these calls would reset the client + ChatGetter.__init__(self, peer_id, broadcast=post) + SenderGetter.__init__(self, sender_id) + + self._forward = None + self._reply_to_chat = None + self._reply_to_sender = None + + def _finish_init(self, client, entities, input_chat): + """ + Finishes the initialization of this message by setting + the client that sent the message and making use of the + known entities. + """ + self._client = client + + # Make messages sent to ourselves outgoing unless they're forwarded. + # This makes it consistent with official client's appearance. + if self.peer_id == types.PeerUser(client._self_id) and not self.fwd_from: + self.out = True + + cache = client._mb_entity_cache + + self._sender, self._input_sender = utils._get_entity_pair( + self.sender_id, entities, cache) + + self._chat, self._input_chat = utils._get_entity_pair( + self.chat_id, entities, cache) + + if input_chat: # This has priority + self._input_chat = input_chat + + if self.via_bot_id: + self._via_bot, self._via_input_bot = utils._get_entity_pair( + self.via_bot_id, entities, cache) + + if self.fwd_from: + self._forward = Forward(self._client, self.fwd_from, entities) + + if self.action: + if isinstance(self.action, (types.MessageActionChatAddUser, + types.MessageActionChatCreate)): + self._action_entities = [entities.get(i) + for i in self.action.users] + elif isinstance(self.action, types.MessageActionChatDeleteUser): + self._action_entities = [entities.get(self.action.user_id)] + elif isinstance(self.action, types.MessageActionChatJoinedByLink): + self._action_entities = [entities.get(self.action.inviter_id)] + elif isinstance(self.action, types.MessageActionChatMigrateTo): + self._action_entities = [entities.get(utils.get_peer_id( + types.PeerChannel(self.action.channel_id)))] + elif isinstance( + self.action, types.MessageActionChannelMigrateFrom): + self._action_entities = [entities.get(utils.get_peer_id( + types.PeerChat(self.action.chat_id)))] + + if self.replies and self.replies.channel_id: + self._linked_chat = entities.get(utils.get_peer_id( + types.PeerChannel(self.replies.channel_id))) + + if isinstance(self.reply_to, types.MessageReplyHeader): + if self.reply_to.reply_to_peer_id: + self._reply_to_chat = entities.get(utils.get_peer_id(self.reply_to.reply_to_peer_id)) + if self.reply_to.reply_from: + if self.reply_to.reply_from.from_id: + self._reply_to_sender = entities.get(utils.get_peer_id(self.reply_to.reply_from.from_id)) + + + + # endregion Initialization + + # region Public Properties + + @property + def client(self): + """ + Returns the `TelegramClient ` + that *patched* this message. This will only be present if you + **use the friendly methods**, it won't be there if you invoke + raw API methods manually, in which case you should only access + members, not properties. + """ + return self._client + + @property + def text(self): + """ + The message text, formatted using the client's default + parse mode. Will be `None` for :tl:`MessageService`. + """ + if self._text is None and self._client: + if not self._client.parse_mode: + self._text = self.message + else: + self._text = self._client.parse_mode.unparse( + self.message, self.entities) + + return self._text + + @text.setter + def text(self, value): + self._text = value + if self._client and self._client.parse_mode: + self.message, self.entities = self._client.parse_mode.parse(value) + else: + self.message, self.entities = value, [] + + @property + def raw_text(self): + """ + The raw message text, ignoring any formatting. + Will be `None` for :tl:`MessageService`. + + Setting a value to this field will erase the + `entities`, unlike changing the `message` member. + """ + return self.message + + @raw_text.setter + def raw_text(self, value): + self.message = value + self.entities = [] + self._text = None + + @property + def is_reply(self): + """ + `True` if the message is a reply to some other message or story. + + Remember that if the replied-to is a message, + you can access the ID of the message this one is + replying to through `reply_to.reply_to_msg_id`, + and the `Message` object with `get_reply_message()`. + """ + return self.reply_to is not None + + @property + def forward(self): + """ + The `Forward ` + information if this message is a forwarded message. + """ + return self._forward + + @property + def reply_to_chat(self): + """ + The :tl:`Channel` in which the replied-to message was sent, + if this message is a reply in another chat + """ + return self._reply_to_chat + + @property + def reply_to_sender(self): + """ + The :tl:`User`, :tl:`Channel`, or whatever other entity that + sent the replied-to message, if this message is a reply in another chat. + """ + return self._reply_to_sender + + @property + def buttons(self): + """ + Returns a list of lists of `MessageButton + `, + if any. + + Otherwise, it returns `None`. + """ + if self._buttons is None and self.reply_markup: + if not self.input_chat: + return + try: + bot = self._needed_markup_bot() + except ValueError: + return + else: + self._set_buttons(self._input_chat, bot) + + return self._buttons + + async def get_buttons(self): + """ + Returns `buttons` when that property fails (this is rarely needed). + """ + if not self.buttons and self.reply_markup: + chat = await self.get_input_chat() + if not chat: + return + try: + bot = self._needed_markup_bot() + except ValueError: + await self._reload_message() + bot = self._needed_markup_bot() # TODO use via_input_bot + + self._set_buttons(chat, bot) + + return self._buttons + + @property + def button_count(self): + """ + Returns the total button count (sum of all `buttons` rows). + """ + if self._buttons_count is None: + if isinstance(self.reply_markup, ( + types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + self._buttons_count = sum( + len(row.buttons) for row in self.reply_markup.rows) + else: + self._buttons_count = 0 + + return self._buttons_count + + @property + def file(self): + """ + Returns a `File ` wrapping the + `photo` or `document` in this message. If the media type is different + (polls, games, none, etc.), this property will be `None`. + + This instance lets you easily access other properties, such as + `file.id `, + `file.name `, + etc., without having to manually inspect the ``document.attributes``. + """ + if not self._file: + media = self.photo or self.document + if media: + self._file = File(media) + + return self._file + + @property + def photo(self): + """ + The :tl:`Photo` media in this message, if any. + + This will also return the photo for :tl:`MessageService` if its + action is :tl:`MessageActionChatEditPhoto`, or if the message has + a web preview with a photo. + """ + if isinstance(self.media, types.MessageMediaPhoto): + if isinstance(self.media.photo, types.Photo): + return self.media.photo + elif isinstance(self.action, types.MessageActionChatEditPhoto): + return self.action.photo + else: + web = self.web_preview + if web and isinstance(web.photo, types.Photo): + return web.photo + + @property + def document(self): + """ + The :tl:`Document` media in this message, if any. + """ + if isinstance(self.media, types.MessageMediaDocument): + if isinstance(self.media.document, types.Document): + return self.media.document + else: + web = self.web_preview + if web and isinstance(web.document, types.Document): + return web.document + + @property + def web_preview(self): + """ + The :tl:`WebPage` media in this message, if any. + """ + if isinstance(self.media, types.MessageMediaWebPage): + if isinstance(self.media.webpage, types.WebPage): + return self.media.webpage + + @property + def audio(self): + """ + The :tl:`Document` media in this message, if it's an audio file. + """ + return self._document_by_attribute(types.DocumentAttributeAudio, + lambda attr: not attr.voice) + + @property + def voice(self): + """ + The :tl:`Document` media in this message, if it's a voice note. + """ + return self._document_by_attribute(types.DocumentAttributeAudio, + lambda attr: attr.voice) + + @property + def video(self): + """ + The :tl:`Document` media in this message, if it's a video. + """ + return self._document_by_attribute(types.DocumentAttributeVideo) + + @property + def video_note(self): + """ + The :tl:`Document` media in this message, if it's a video note. + """ + return self._document_by_attribute(types.DocumentAttributeVideo, + lambda attr: attr.round_message) + + @property + def gif(self): + """ + The :tl:`Document` media in this message, if it's a "gif". + + "Gif" files by Telegram are normally ``.mp4`` video files without + sound, the so called "animated" media. However, it may be the actual + gif format if the file is too large. + """ + return self._document_by_attribute(types.DocumentAttributeAnimated) + + @property + def sticker(self): + """ + The :tl:`Document` media in this message, if it's a sticker. + """ + return self._document_by_attribute(types.DocumentAttributeSticker) + + @property + def contact(self): + """ + The :tl:`MessageMediaContact` in this message, if it's a contact. + """ + if isinstance(self.media, types.MessageMediaContact): + return self.media + + @property + def game(self): + """ + The :tl:`Game` media in this message, if it's a game. + """ + if isinstance(self.media, types.MessageMediaGame): + return self.media.game + + @property + def geo(self): + """ + The :tl:`GeoPoint` media in this message, if it has a location. + """ + if isinstance(self.media, (types.MessageMediaGeo, + types.MessageMediaGeoLive, + types.MessageMediaVenue)): + return self.media.geo + + @property + def invoice(self): + """ + The :tl:`MessageMediaInvoice` in this message, if it's an invoice. + """ + if isinstance(self.media, types.MessageMediaInvoice): + return self.media + + @property + def poll(self): + """ + The :tl:`MessageMediaPoll` in this message, if it's a poll. + """ + if isinstance(self.media, types.MessageMediaPoll): + return self.media + + @property + def venue(self): + """ + The :tl:`MessageMediaVenue` in this message, if it's a venue. + """ + if isinstance(self.media, types.MessageMediaVenue): + return self.media + + @property + def dice(self): + """ + The :tl:`MessageMediaDice` in this message, if it's a dice roll. + """ + if isinstance(self.media, types.MessageMediaDice): + return self.media + + @property + def action_entities(self): + """ + Returns a list of entities that took part in this action. + + Possible cases for this are :tl:`MessageActionChatAddUser`, + :tl:`types.MessageActionChatCreate`, :tl:`MessageActionChatDeleteUser`, + :tl:`MessageActionChatJoinedByLink` :tl:`MessageActionChatMigrateTo` + and :tl:`MessageActionChannelMigrateFrom`. + + If the action is neither of those, the result will be `None`. + If some entities could not be retrieved, the list may contain + some `None` items in it. + """ + return self._action_entities + + @property + def via_bot(self): + """ + The bot :tl:`User` if the message was sent via said bot. + + This will only be present if `via_bot_id` is not `None` and + the entity is known. + """ + return self._via_bot + + @property + def via_input_bot(self): + """ + Returns the input variant of `via_bot`. + """ + return self._via_input_bot + + @property + def reply_to_msg_id(self): + """ + Returns the message ID this message is replying to, if any. + This is equivalent to accessing ``.reply_to.reply_to_msg_id``. + """ + return ( + self.reply_to.reply_to_msg_id + if isinstance(self.reply_to, types.MessageReplyHeader) + else None + ) + + @property + def to_id(self): + """ + Returns the peer to which this message was sent to. This used to exist + to infer the ``.peer_id``. + """ + # If the client wasn't set we can't emulate the behaviour correctly, + # so as a best-effort simply return the chat peer. + if self._client and not self.out and self.is_private: + return types.PeerUser(self._client._self_id) + + return self.peer_id + + # endregion Public Properties + + # region Public Methods + + def get_entities_text(self, cls=None): + """ + Returns a list of ``(markup entity, inner text)`` + (like bold or italics). + + The markup entity is a :tl:`MessageEntity` that represents bold, + italics, etc., and the inner text is the `str` inside that markup + entity. + + For example: + + .. code-block:: python + + print(repr(message.text)) # shows: 'Hello **world**!' + + for ent, txt in message.get_entities_text(): + print(ent) # shows: MessageEntityBold(offset=6, length=5) + print(txt) # shows: world + + Args: + cls (`type`): + Returns entities matching this type only. For example, + the following will print the text for all ``code`` entities: + + >>> from telethon.tl.types import MessageEntityCode + >>> + >>> m = ... # get the message + >>> for _, inner_text in m.get_entities_text(MessageEntityCode): + >>> print(inner_text) + """ + ent = self.entities + if not ent: + return [] + + if cls: + ent = [c for c in ent if isinstance(c, cls)] + + texts = utils.get_inner_text(self.message, ent) + return list(zip(ent, texts)) + + async def get_reply_message(self): + """ + The `Message` that this message is replying to, or `None`. + + The result will be cached after its first use. + """ + if self._reply_message is None and self._client: + if not isinstance(self.reply_to, types.MessageReplyHeader): + return None + + # Bots cannot access other bots' messages by their ID. + # However they can access them through replies... + self._reply_message = await self._client.get_messages( + await self.get_input_chat() if self.is_channel else None, + ids=types.InputMessageReplyTo(self.id) + ) + if not self._reply_message: + # ...unless the current message got deleted. + # + # If that's the case, give it a second chance accessing + # directly by its ID. + self._reply_message = await self._client.get_messages( + self._input_chat if self.is_channel else None, + ids=self.reply_to.reply_to_msg_id + ) + + return self._reply_message + + async def respond(self, *args, **kwargs): + """ + Responds to the message (not as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` + with ``entity`` already set. + """ + if self._client: + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def reply(self, *args, **kwargs): + """ + Replies to the message (as a reply). Shorthand for + `telethon.client.messages.MessageMethods.send_message` + with both ``entity`` and ``reply_to`` already set. + """ + if self._client: + kwargs['reply_to'] = self.id + return await self._client.send_message( + await self.get_input_chat(), *args, **kwargs) + + async def forward_to(self, *args, **kwargs): + """ + Forwards the message. Shorthand for + `telethon.client.messages.MessageMethods.forward_messages` + with both ``messages`` and ``from_peer`` already set. + + If you need to forward more than one message at once, don't use + this `forward_to` method. Use a + `telethon.client.telegramclient.TelegramClient` instance directly. + """ + if self._client: + kwargs['messages'] = self.id + kwargs['from_peer'] = await self.get_input_chat() + return await self._client.forward_messages(*args, **kwargs) + + async def edit(self, *args, **kwargs): + """ + Edits the message if it's outgoing. Shorthand for + `telethon.client.messages.MessageMethods.edit_message` + with both ``entity`` and ``message`` already set. + + Returns + The edited `Message `, + unless `entity` was a :tl:`InputBotInlineMessageID` or :tl:`InputBotInlineMessageID64` in which + case this method returns a boolean. + + Raises + ``MessageAuthorRequiredError`` if you're not the author of the + message but tried editing it anyway. + + ``MessageNotModifiedError`` if the contents of the message were + not modified at all. + + ``MessageIdInvalidError`` if the ID of the message is invalid + (the ID itself may be correct, but the message with that ID + cannot be edited). For example, when trying to edit messages + with a reply markup (or clear markup) this error will be raised. + + .. note:: + + This is different from `client.edit_message + ` + and **will respect** the previous state of the message. + For example, if the message didn't have a link preview, + the edit won't add one by default, and you should force + it by setting it to `True` if you want it. + + This is generally the most desired and convenient behaviour, + and will work for link previews and message buttons. + """ + if 'link_preview' not in kwargs: + kwargs['link_preview'] = bool(self.web_preview) + + if 'buttons' not in kwargs: + kwargs['buttons'] = self.reply_markup + + return await self._client.edit_message( + await self.get_input_chat(), self.id, + *args, **kwargs + ) + + async def delete(self, *args, **kwargs): + """ + Deletes the message. You're responsible for checking whether you + have the permission to do so, or to except the error otherwise. + Shorthand for + `telethon.client.messages.MessageMethods.delete_messages` with + ``entity`` and ``message_ids`` already set. + + If you need to delete more than one message at once, don't use + this `delete` method. Use a + `telethon.client.telegramclient.TelegramClient` instance directly. + """ + if self._client: + return await self._client.delete_messages( + await self.get_input_chat(), [self.id], + *args, **kwargs + ) + + async def download_media(self, *args, **kwargs): + """ + Downloads the media contained in the message, if any. Shorthand + for `telethon.client.downloads.DownloadMethods.download_media` + with the ``message`` already set. + """ + if self._client: + # Passing the entire message is important, in case it has to be + # refetched for a fresh file reference. + return await self._client.download_media(self, *args, **kwargs) + + async def click(self, i=None, j=None, + *, text=None, filter=None, data=None, share_phone=None, + share_geo=None, password=None, open_url=None): + """ + Calls :tl:`SendVote` with the specified poll option + or `button.click ` + on the specified button. + + Does nothing if the message is not a poll or has no buttons. + + Args: + i (`int` | `list`): + Clicks the i'th button or poll option (starting from the index 0). + For multiple-choice polls, a list with the indices should be used. + Will ``raise IndexError`` if out of bounds. Example: + + >>> message = ... # get the message somehow + >>> # Clicking the 3rd button + >>> # [button1] [button2] + >>> # [ button3 ] + >>> # [button4] [button5] + >>> await message.click(2) # index + + j (`int`): + Clicks the button at position (i, j), these being the + indices for the (row, column) respectively. Example: + + >>> # Clicking the 2nd button on the 1st row. + >>> # [button1] [button2] + >>> # [ button3 ] + >>> # [button4] [button5] + >>> await message.click(0, 1) # (row, column) + + This is equivalent to ``message.buttons[0][1].click()``. + + text (`str` | `callable`): + Clicks the first button or poll option with the text "text". This may + also be a callable, like a ``re.compile(...).match``, + and the text will be passed to it. + + If you need to select multiple options in a poll, + pass a list of indices to the ``i`` parameter. + + filter (`callable`): + Clicks the first button or poll option for which the callable + returns `True`. The callable should accept a single + `MessageButton ` + or `PollAnswer ` argument. + + If you need to select multiple options in a poll, + pass a list of indices to the ``i`` parameter. + + data (`bytes`): + This argument overrides the rest and will not search any + buttons. Instead, it will directly send the request to + behave as if it clicked a button with said data. Note + that if the message does not have this data, it will + ``raise DataInvalidError``. + + share_phone (`bool` | `str` | tl:`InputMediaContact`): + When clicking on a keyboard button requesting a phone number + (:tl:`ButtonTypeRequestPhone`), this argument must be + explicitly set to avoid accidentally sharing the number. + + It can be `True` to automatically share the current user's + phone, a string to share a specific phone number, or a contact + media to specify all details. + + If the button is pressed without this, `ValueError` is raised. + + share_geo (`tuple` | `list` | tl:`InputMediaGeoPoint`): + When clicking on a keyboard button requesting a geo location + (:tl:`ButtonTypeRequestGeoLocation`), this argument must + be explicitly set to avoid accidentally sharing the location. + + It must be a `tuple` of `float` as ``(longitude, latitude)``, + or a :tl:`InputGeoPoint` instance to avoid accidentally using + the wrong roder. + + If the button is pressed without this, `ValueError` is raised. + + password (`str`): + When clicking certain buttons (such as BotFather's confirmation + button to transfer ownership), if your account has 2FA enabled, + you need to provide your account's password. Otherwise, + `teltehon.errors.PasswordHashInvalidError` is raised. + + open_url (`bool`): + When clicking on an inline keyboard URL button :tl:`InlineButtonTypeUrl` + By default it will return URL of the button, passing ``click(open_url=True)`` + will lunch the default browser with given URL of the button and + return `True` on success. + + Example: + + .. code-block:: python + + # Click the first button + await message.click(0) + + # Click some row/column + await message.click(row, column) + + # Click by text + await message.click(text='👍') + + # Click by data + await message.click(data=b'payload') + + # Click on a button requesting a phone + await message.click(0, share_phone=True) + """ + if not self._client: + return + + if data: + chat = await self.get_input_chat() + if not chat: + return None + + but = types.KeyboardInlineButton('', types.InlineButtonTypeCallback(data)) + return await MessageButton(self._client, but, chat, None, self.id).click( + share_phone=share_phone, share_geo=share_geo, password=password, open_url=open_url) + + if sum(int(x is not None) for x in (i, text, filter)) >= 2: + raise ValueError('You can only set either of i, text or filter') + + # Finding the desired poll options and sending them + if self.poll is not None: + def find_options(): + answers = self.poll.poll.answers + if i is not None: + if utils.is_list_like(i): + return [answers[idx].option for idx in i] + return [answers[i].option] + if text is not None: + if callable(text): + for answer in answers: + if text(answer.text): + return [answer.option] + else: + for answer in answers: + if answer.text == text: + return [answer.option] + return + + if filter is not None: + for answer in answers: + if filter(answer): + return [answer.option] + return + + options = find_options() + if options is None: + options = [] + return await self._client( + functions.messages.SendVoteRequest( + peer=self._input_chat, + msg_id=self.id, + options=options + ) + ) + + if not await self.get_buttons(): + return # Accessing the property sets self._buttons[_flat] + + def find_button(): + nonlocal i + if text is not None: + if callable(text): + for button in self._buttons_flat: + if text(button.text): + return button + else: + for button in self._buttons_flat: + if button.text == text: + return button + return + + if filter is not None: + for button in self._buttons_flat: + if filter(button): + return button + return + + if i is None: + i = 0 + if j is None: + return self._buttons_flat[i] + else: + return self._buttons[i][j] + + button = find_button() + if button: + return await button.click( + share_phone=share_phone, share_geo=share_geo, password=password, open_url=open_url) + + async def mark_read(self): + """ + Marks the message as read. Shorthand for + `client.send_read_acknowledge() + ` + with both ``entity`` and ``message`` already set. + """ + if self._client: + await self._client.send_read_acknowledge( + await self.get_input_chat(), max_id=self.id) + + async def pin(self, *, notify=False, pm_oneside=False): + """ + Pins the message. Shorthand for + `telethon.client.messages.MessageMethods.pin_message` + with both ``entity`` and ``message`` already set. + """ + # TODO Constantly checking if client is a bit annoying, + # maybe just make it illegal to call messages from raw API? + # That or figure out a way to always set it directly. + if self._client: + return await self._client.pin_message( + await self.get_input_chat(), self.id, notify=notify, pm_oneside=pm_oneside) + + async def unpin(self): + """ + Unpins the message. Shorthand for + `telethon.client.messages.MessageMethods.unpin_message` + with both ``entity`` and ``message`` already set. + """ + if self._client: + return await self._client.unpin_message( + await self.get_input_chat(), self.id) + + # endregion Public Methods + + # region Private Methods + + async def _reload_message(self): + """ + Re-fetches this message to reload the sender and chat entities, + along with their input versions. + """ + if not self._client: + return + + try: + chat = await self.get_input_chat() if self.is_channel else None + msg = await self._client.get_messages(chat, ids=self.id) + except ValueError: + return # We may not have the input chat/get message failed + if not msg: + return # The message may be deleted and it will be None + + self._sender = msg._sender + self._input_sender = msg._input_sender + self._chat = msg._chat + self._input_chat = msg._input_chat + self._via_bot = msg._via_bot + self._via_input_bot = msg._via_input_bot + self._forward = msg._forward + self._action_entities = msg._action_entities + + async def _refetch_sender(self): + await self._reload_message() + + def _set_buttons(self, chat, bot): + """ + Helper methods to set the buttons given the input sender and chat. + """ + if self._client and isinstance(self.reply_markup, ( + types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + self._buttons = [[ + MessageButton(self._client, button, chat, bot, self.id) + for button in row.buttons + ] for row in self.reply_markup.rows] + self._buttons_flat = [x for row in self._buttons for x in row] + + def _needed_markup_bot(self): + """ + Returns the input peer of the bot that's needed for the reply markup. + + This is necessary for :tl:`InlineButtonTypeSwitchInline` since we need + to know what bot we want to start. Raises ``ValueError`` if the bot + cannot be found but is needed. Returns `None` if it's not needed. + """ + if self._client and not isinstance(self.reply_markup, ( + types.ReplyInlineMarkup, types.ReplyKeyboardMarkup)): + return None + + for row in self.reply_markup.rows: + for button in row.buttons: + if isinstance(button.type, types.InlineButtonTypeSwitchInline): + # no via_bot_id means the bot sent the message itself (#1619) + if button.type.same_peer or not self.via_bot_id: + bot = self.input_sender + if not bot: + raise ValueError('No input sender') + return bot + else: + try: + return self._client._mb_entity_cache.get( + utils.resolve_id(self.via_bot_id)[0])._as_input_peer() + except AttributeError: + raise ValueError('No input sender') from None + + def _document_by_attribute(self, kind, condition=None): + """ + Helper method to return the document only if it has an attribute + that's an instance of the given kind, and passes the condition. + """ + doc = self.document + if doc: + for attr in doc.attributes: + if isinstance(attr, kind): + if not condition or condition(attr): + return doc + return None + + # endregion Private Methods diff --git a/module/scripts/lib/telethon/tl/custom/messagebutton.py b/module/scripts/lib/telethon/tl/custom/messagebutton.py new file mode 100644 index 0000000..17b07a7 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/messagebutton.py @@ -0,0 +1,154 @@ +from .. import types, functions +from ... import password as pwd_mod +from ...errors import BotResponseTimeoutError +try: + import webbrowser +except ModuleNotFoundError: + pass +import sys +import os + + +class MessageButton: + """ + .. note:: + + `Message.buttons ` + are instances of this type. If you want to **define** a reply + markup for e.g. sending messages, refer to `Button + ` instead. + + Custom class that encapsulates a message button providing + an abstraction to easily access some commonly needed features + (such as clicking the button itself). + + Attributes: + + button (:tl:`KeyboardButton`, :tl:`KeyboardInlineButton`): + The original :tl:`KeyboardButton` object. + """ + def __init__(self, client, original, chat, bot, msg_id): + self.button = original + self._bot = bot + self._chat = chat + self._msg_id = msg_id + self._client = client + + @property + def client(self): + """ + Returns the `telethon.client.telegramclient.TelegramClient` + instance that created this instance. + """ + return self._client + + @property + def text(self): + """The text string of the button.""" + return self.button.text + + @property + def data(self): + """The `bytes` data for :tl:`InlineButtonTypeCallback` objects.""" + if isinstance(self.button.type, types.InlineButtonTypeCallback): + return self.button.type.data + + @property + def inline_query(self): + """The query `str` for :tl:`InlineButtonTypeSwitchInline` objects.""" + if isinstance(self.button.type, types.InlineButtonTypeSwitchInline): + return self.button.type.query + + @property + def url(self): + """The url `str` for :tl:`InlineButtonTypeUrl` objects.""" + if isinstance(self.button.type, types.InlineButtonTypeUrl): + return self.button.type.url + + async def click(self, share_phone=None, share_geo=None, *, password=None, open_url=None): + """ + Emulates the behaviour of clicking this button. + + If it's a normal :tl:`ButtonTypeDefault` with text, a message will be + sent, and the sent `Message ` returned. + + If it's an inline :tl:`InlineButtonTypeCallback` with text and data, + it will be "clicked" and the :tl:`BotCallbackAnswer` returned. + + If it's an inline :tl:`InlineButtonTypeSwitchInline` button, the + :tl:`StartBotRequest` will be invoked and the resulting updates + returned. + + If it's a :tl:`InlineButtonTypeUrl`, the ``URL`` of the button will + be returned. If you pass ``open_url=True`` the URL of the button will + be passed to ``webbrowser.open`` and return `True` on success. + + If it's a :tl:`ButtonTypeRequestPhone`, you must indicate that you + want to ``share_phone=True`` in order to share it. Sharing it is not a + default because it is a privacy concern and could happen accidentally. + + You may also use ``share_phone=phone`` to share a specific number, in + which case either `str` or :tl:`InputMediaContact` should be used. + + If it's a :tl:`ButtonTypeRequestGeoLocation`, you must pass a + tuple in ``share_geo=(longitude, latitude)``. Note that Telegram seems + to have some heuristics to determine impossible locations, so changing + this value a lot quickly may not work as expected. You may also pass a + :tl:`InputGeoPoint` if you find the order confusing. + """ + if isinstance(self.button.type, types.ButtonTypeDefault): + return await self._client.send_message( + self._chat, self.button.text, parse_mode=None) + elif isinstance(self.button.type, types.InlineButtonTypeCallback): + if password is not None: + pwd = await self._client(functions.account.GetPasswordRequest()) + password = pwd_mod.compute_check(pwd, password) + + req = functions.messages.GetBotCallbackAnswerRequest( + peer=self._chat, msg_id=self._msg_id, data=self.button.type.data, + password=password + ) + try: + return await self._client(req) + except BotResponseTimeoutError: + return None + elif isinstance(self.button.type, types.InlineButtonTypeSwitchInline): + return await self._client(functions.messages.StartBotRequest( + bot=self._bot, peer=self._chat, start_param=self.button.type.query + )) + elif isinstance(self.button.type, types.InlineButtonTypeUrl): + if open_url: + if "webbrowser" in sys.modules: + return webbrowser.open(self.button.type.url) + return self.button.type.url + elif isinstance(self.button.type, types.InlineButtonTypeGame): + req = functions.messages.GetBotCallbackAnswerRequest( + peer=self._chat, msg_id=self._msg_id, game=True + ) + try: + return await self._client(req) + except BotResponseTimeoutError: + return None + elif isinstance(self.button.type, types.ButtonTypeRequestPhone): + if not share_phone: + raise ValueError('cannot click on phone buttons unless share_phone=True') + + if share_phone == True or isinstance(share_phone, str): + me = await self._client.get_me() + share_phone = types.InputMediaContact( + phone_number=me.phone if share_phone == True else share_phone, + first_name=me.first_name or '', + last_name=me.last_name or '', + vcard='' + ) + + return await self._client.send_file(self._chat, share_phone) + elif isinstance(self.button.type, types.ButtonTypeRequestGeoLocation): + if not share_geo: + raise ValueError('cannot click on geo buttons unless share_geo=(longitude, latitude)') + + if isinstance(share_geo, (tuple, list)): + long, lat = share_geo + share_geo = types.InputMediaGeoPoint(types.InputGeoPoint(lat=lat, long=long)) + + return await self._client.send_file(self._chat, share_geo) diff --git a/module/scripts/lib/telethon/tl/custom/participantpermissions.py b/module/scripts/lib/telethon/tl/custom/participantpermissions.py new file mode 100644 index 0000000..d371977 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/participantpermissions.py @@ -0,0 +1,138 @@ +from .. import types + + +def _admin_prop(field_name, doc): + """ + Helper method to build properties that return `True` if the user is an + administrator of a normal chat, or otherwise return `True` if the user + has a specific permission being an admin of a channel. + """ + def fget(self): + if not self.is_admin: + return False + if self.is_chat: + return True + + return getattr(self.participant.admin_rights, field_name) + + return {'fget': fget, 'doc': doc} + + +class ParticipantPermissions: + """ + Participant permissions information. + + The properties in this objects are boolean values indicating whether the + user has the permission or not. + + Example + .. code-block:: python + + permissions = ... + + if permissions.is_banned: + "this user is banned" + elif permissions.is_admin: + "this user is an administrator" + """ + def __init__(self, participant, chat: bool): + self.participant = participant + self.is_chat = chat + + @property + def is_admin(self): + """ + Whether the user is an administrator of the chat or not. The creator + also counts as begin an administrator, since they have all permissions. + """ + return self.is_creator or isinstance(self.participant, ( + types.ChannelParticipantAdmin, + types.ChatParticipantAdmin + )) + + @property + def is_creator(self): + """ + Whether the user is the creator of the chat or not. + """ + return isinstance(self.participant, ( + types.ChannelParticipantCreator, + types.ChatParticipantCreator + )) + + @property + def has_default_permissions(self): + """ + Whether the user is a normal user of the chat (not administrator, but + not banned either, and has no restrictions applied). + """ + return isinstance(self.participant, ( + types.ChannelParticipant, + types.ChatParticipant, + types.ChannelParticipantSelf + )) + + @property + def is_banned(self): + """ + Whether the user is banned in the chat. + """ + return isinstance(self.participant, types.ChannelParticipantBanned) + + @property + def has_left(self): + """ + Whether the user left the chat. + """ + return isinstance(self.participant, types.ChannelParticipantLeft) + + @property + def add_admins(self): + """ + Whether the administrator can add new administrators with the same or + less permissions than them. + """ + if not self.is_admin: + return False + + if self.is_chat: + return self.is_creator + + return self.participant.admin_rights.add_admins + + ban_users = property(**_admin_prop('ban_users', """ + Whether the administrator can ban other users or not. + """)) + + pin_messages = property(**_admin_prop('pin_messages', """ + Whether the administrator can pin messages or not. + """)) + + invite_users = property(**_admin_prop('invite_users', """ + Whether the administrator can add new users to the chat. + """)) + + delete_messages = property(**_admin_prop('delete_messages', """ + Whether the administrator can delete messages from other participants. + """)) + + edit_messages = property(**_admin_prop('edit_messages', """ + Whether the administrator can edit messages. + """)) + + post_messages = property(**_admin_prop('post_messages', """ + Whether the administrator can post messages in the broadcast channel. + """)) + + change_info = property(**_admin_prop('change_info', """ + Whether the administrator can change the information about the chat, + such as title or description. + """)) + + anonymous = property(**_admin_prop('anonymous', """ + Whether the administrator will remain anonymous when sending messages. + """)) + + manage_call = property(**_admin_prop('manage_call', """ + Whether the user will be able to manage group calls. + """)) diff --git a/module/scripts/lib/telethon/tl/custom/qrlogin.py b/module/scripts/lib/telethon/tl/custom/qrlogin.py new file mode 100644 index 0000000..cd54027 --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/qrlogin.py @@ -0,0 +1,119 @@ +import asyncio +import base64 +import datetime + +from .. import types, functions +from ... import events + + +class QRLogin: + """ + QR login information. + + Most of the time, you will present the `url` as a QR code to the user, + and while it's being shown, call `wait`. + """ + def __init__(self, client, ignored_ids): + self._client = client + self._request = functions.auth.ExportLoginTokenRequest( + self._client.api_id, self._client.api_hash, ignored_ids) + self._resp = None + + async def recreate(self): + """ + Generates a new token and URL for a new QR code, useful if the code + has expired before it was imported. + """ + self._resp = await self._client(self._request) + + @property + def token(self) -> bytes: + """ + The binary data representing the token. + + It can be used by a previously-authorized client in a call to + :tl:`auth.importLoginToken` to log the client that originally + requested the QR login. + """ + return self._resp.token + + @property + def url(self) -> str: + """ + The ``tg://login`` URI with the token. When opened by a Telegram + application where the user is logged in, it will import the login + token. + + If you want to display a QR code to the user, this is the URL that + should be launched when the QR code is scanned (the URL that should + be contained in the QR code image you generate). + + Whether you generate the QR code image or not is up to you, and the + library can't do this for you due to the vast ways of generating and + displaying the QR code that exist. + + The URL simply consists of `token` base64-encoded. + """ + return 'tg://login?token={}'.format(base64.urlsafe_b64encode(self._resp.token).decode('utf-8').rstrip('=')) + + @property + def expires(self) -> datetime.datetime: + """ + The `datetime` at which the QR code will expire. + + If you want to try again, you will need to call `recreate`. + """ + return self._resp.expires + + async def wait(self, timeout: float = None): + """ + Waits for the token to be imported by a previously-authorized client, + either by scanning the QR, launching the URL directly, or calling the + import method. + + This method **must** be called before the QR code is scanned, and + must be executing while the QR code is being scanned. Otherwise, the + login will not complete. + + Will raise `asyncio.TimeoutError` if the login doesn't complete on + time. + + Arguments + timeout (float): + The timeout, in seconds, to wait before giving up. By default + the library will wait until the token expires, which is often + what you want. + + Returns + On success, an instance of :tl:`User`. On failure it will raise. + """ + if timeout is None: + timeout = (self._resp.expires - datetime.datetime.now(tz=datetime.timezone.utc)).total_seconds() + + event = asyncio.Event() + + async def handler(_update): + event.set() + + self._client.add_event_handler(handler, events.Raw(types.UpdateLoginToken)) + + try: + # Will raise timeout error if it doesn't complete quick enough, + # which we want to let propagate + await asyncio.wait_for(event.wait(), timeout=timeout) + finally: + self._client.remove_event_handler(handler) + + # We got here without it raising timeout error, so we can proceed + resp = await self._client(self._request) + if isinstance(resp, types.auth.LoginTokenMigrateTo): + await self._client._switch_dc(resp.dc_id) + resp = await self._client(functions.auth.ImportLoginTokenRequest(resp.token)) + # resp should now be auth.loginTokenSuccess + + if isinstance(resp, types.auth.LoginTokenSuccess): + user = resp.authorization.user + await self._client._on_login(user) + return user + + raise TypeError('Login token response was unexpected: {}'.format(resp)) diff --git a/module/scripts/lib/telethon/tl/custom/sendergetter.py b/module/scripts/lib/telethon/tl/custom/sendergetter.py new file mode 100644 index 0000000..7d389ec --- /dev/null +++ b/module/scripts/lib/telethon/tl/custom/sendergetter.py @@ -0,0 +1,102 @@ +import abc + +from ... import utils + + +class SenderGetter(abc.ABC): + """ + Helper base class that introduces the `sender`, `input_sender` + and `sender_id` properties and `get_sender` and `get_input_sender` + methods. + """ + def __init__(self, sender_id=None, *, sender=None, input_sender=None): + self._sender_id = sender_id + self._sender = sender + self._input_sender = input_sender + self._client = None + + @property + def sender(self): + """ + Returns the :tl:`User` or :tl:`Channel` that sent this object. + It may be `None` if Telegram didn't send the sender. + + If you only need the ID, use `sender_id` instead. + + If you need to call a method which needs + this chat, use `input_sender` instead. + + If you're using `telethon.events`, use `get_sender()` instead. + """ + return self._sender + + async def get_sender(self): + """ + Returns `sender`, but will make an API call to find the + sender unless it's already cached. + + If you only need the ID, use `sender_id` instead. + + If you need to call a method which needs + this sender, use `get_input_sender()` instead. + """ + # ``sender.min`` is present both in :tl:`User` and :tl:`Channel`. + # It's a flag that will be set if only minimal information is + # available (such as display name, but username may be missing), + # in which case we want to force fetch the entire thing because + # the user explicitly called a method. If the user is okay with + # cached information, they may use the property instead. + if (self._sender is None or getattr(self._sender, 'min', None)) \ + and await self.get_input_sender(): + # self.get_input_sender may refresh in which case the sender may no longer be min + # However it could still incur a cost so the cheap check is done twice instead. + if self._sender is None or getattr(self._sender, 'min', None): + try: + self._sender =\ + await self._client.get_entity(self._input_sender) + except ValueError: + await self._refetch_sender() + return self._sender + + @property + def input_sender(self): + """ + This :tl:`InputPeer` is the input version of the user/channel who + sent the message. Similarly to `input_chat + `, this doesn't + have things like username or similar, but still useful in some cases. + + Note that this might not be available if the library can't + find the input chat, or if the message a broadcast on a channel. + """ + if self._input_sender is None and self._sender_id and self._client: + try: + self._input_sender = self._client._mb_entity_cache.get( + utils.resolve_id(self._sender_id)[0])._as_input_peer() + except AttributeError: + pass + return self._input_sender + + async def get_input_sender(self): + """ + Returns `input_sender`, but will make an API call to find the + input sender unless it's already cached. + """ + if self.input_sender is None and self._sender_id and self._client: + await self._refetch_sender() + return self._input_sender + + @property + def sender_id(self): + """ + Returns the marked sender integer ID, if present. + + If there is a sender in the object, `sender_id` will *always* be set, + which is why you should use it instead of `sender.id `. + """ + return self._sender_id + + async def _refetch_sender(self): + """ + Re-fetches sender information through other means. + """ diff --git a/module/scripts/lib/telethon/tl/functions/__init__.py b/module/scripts/lib/telethon/tl/functions/__init__.py new file mode 100644 index 0000000..78b7cdb --- /dev/null +++ b/module/scripts/lib/telethon/tl/functions/__init__.py @@ -0,0 +1,717 @@ +"""File generated by TLObjects' generator. All changes will be ERASED""" +from ...tl.tlobject import TLObject +from ...tl.tlobject import TLRequest +from typing import Optional, List, Union, TYPE_CHECKING +from . import account, aicompose, auth, bots, channels, chatlists, communities, contacts, ephemeral, folders, fragment, help, langpack, messages, payments, phone, photos, premium, smsjobs, stats, stickers, stories, updates, upload, users +import os +import struct +from datetime import datetime +if TYPE_CHECKING: + from ...tl.types import TypeInputClientProxy, TypeJSONValue, TypeMessageRange, TypeType, TypeX + + + +class DestroyAuthKeyRequest(TLRequest): + CONSTRUCTOR_ID = 0xd1435160 + SUBCLASS_OF_ID = 0x8291e68e + + def to_dict(self): + return { + '_': 'DestroyAuthKeyRequest' + } + + def _bytes(self): + return b''.join(( + b'`QC\xd1', + )) + + @classmethod + def from_reader(cls, reader): + return cls() + + +class DestroySessionRequest(TLRequest): + CONSTRUCTOR_ID = 0xe7512126 + SUBCLASS_OF_ID = 0xaf0ce7bd + + def __init__(self, session_id: int): + """ + :returns DestroySessionRes: Instance of either DestroySessionOk, DestroySessionNone. + """ + self.session_id = session_id + + def to_dict(self): + return { + '_': 'DestroySessionRequest', + 'session_id': self.session_id + } + + def _bytes(self): + return b''.join(( + b'&!Q\xe7', + struct.pack(': This type has no constructors. + """ + self.wallpapers = wallpapers + + def to_dict(self): + return { + '_': 'GetMultiWallPapersRequest', + 'wallpapers': [] if self.wallpapers is None else [x.to_dict() if isinstance(x, TLObject) else x for x in self.wallpapers] + } + + def _bytes(self): + return b''.join(( + b'\xdcq\xade', + b'\x15\xc4\xb5\x1c',struct.pack(': This type has no constructors. + """ + self.types = types + + def to_dict(self): + return { + '_': 'GetSecureValueRequest', + 'types': [] if self.types is None else [x.to_dict() if isinstance(x, TLObject) else x for x in self.types] + } + + def _bytes(self): + return b''.join(( + b'\xc2[fs', + b'\x15\xc4\xb5\x1c',struct.pack('', + self.serialize_bytes(self.username), + )) + + @classmethod + def from_reader(cls, reader): + _username = reader.tgread_string() + return cls(username=_username) + + +class UpdateWebBrowserSettingsRequest(TLRequest): + CONSTRUCTOR_ID = 0x9adf82fe + SUBCLASS_OF_ID = 0x44799578 + + def __init__(self, open_external_browser: Optional[bool]=None, display_close_button: Optional[bool]=None): + """ + :returns account.WebBrowserSettings: Instance of either WebBrowserSettingsNotModified, WebBrowserSettings. + """ + self.open_external_browser = open_external_browser + self.display_close_button = display_close_button + + def to_dict(self): + return { + '_': 'UpdateWebBrowserSettingsRequest', + 'open_external_browser': self.open_external_browser, + 'display_close_button': self.display_close_button + } + + def _bytes(self): + return b''.join(( + b'\xfe\x82\xdf\x9a', + struct.pack('', + )) + + @classmethod + def from_reader(cls, reader): + return cls() + + +class RecoverPasswordRequest(TLRequest): + CONSTRUCTOR_ID = 0x37096c70 + SUBCLASS_OF_ID = 0xb9e04e39 + + def __init__(self, code: str, new_settings: Optional['TypePasswordInputSettings']=None): + """ + :returns auth.Authorization: Instance of either Authorization, AuthorizationSignUpRequired. + """ + self.code = code + self.new_settings = new_settings + + def to_dict(self): + return { + '_': 'RecoverPasswordRequest', + 'code': self.code, + 'new_settings': self.new_settings.to_dict() if isinstance(self.new_settings, TLObject) else self.new_settings + } + + def _bytes(self): + return b''.join(( + b'pl\t7', + struct.pack(': This type has no constructors. + """ + self.scope = scope + self.lang_code = lang_code + + def to_dict(self): + return { + '_': 'GetBotCommandsRequest', + 'scope': self.scope.to_dict() if isinstance(self.scope, TLObject) else self.scope, + 'lang_code': self.lang_code + } + + def _bytes(self): + return b''.join(( + b'\xd6\rL\xe3', + self.scope._bytes(), + self.serialize_bytes(self.lang_code), + )) + + @classmethod + def from_reader(cls, reader): + _scope = reader.tgread_object() + _lang_code = reader.tgread_string() + return cls(scope=_scope, lang_code=_lang_code) + + +class GetBotInfoRequest(TLRequest): + CONSTRUCTOR_ID = 0xdcd914fd + SUBCLASS_OF_ID = 0xca7b2235 + + def __init__(self, lang_code: str, bot: Optional['TypeInputUser']=None): + """ + :returns bots.BotInfo: Instance of BotInfo. + """ + self.lang_code = lang_code + self.bot = bot + + async def resolve(self, client, utils): + if self.bot: + self.bot = utils.get_input_user(await client.get_input_entity(self.bot)) + + def to_dict(self): + return { + '_': 'GetBotInfoRequest', + 'lang_code': self.lang_code, + 'bot': self.bot.to_dict() if isinstance(self.bot, TLObject) else self.bot + } + + def _bytes(self): + return b''.join(( + b'\xfd\x14\xd9\xdc', + struct.pack(': This type has no constructors. + """ + self.bot = bot + + async def resolve(self, client, utils): + self.bot = utils.get_input_user(await client.get_input_entity(self.bot)) + + def to_dict(self): + return { + '_': 'GetPreviewMediasRequest', + 'bot': self.bot.to_dict() if isinstance(self.bot, TLObject) else self.bot + } + + def _bytes(self): + return b''.join(( + b'MY\xa5\xa2', + self.bot._bytes(), + )) + + @classmethod + def from_reader(cls, reader): + _bot = reader.tgread_object() + return cls(bot=_bot) + + +class GetRequestedWebViewButtonRequest(TLRequest): + CONSTRUCTOR_ID = 0xbf25b7f3 + SUBCLASS_OF_ID = 0xbad74a3 + + def __init__(self, bot: 'TypeInputUser', webapp_req_id: str): + """ + :returns KeyboardButton: Instance of KeyboardButton. + """ + self.bot = bot + self.webapp_req_id = webapp_req_id + + async def resolve(self, client, utils): + self.bot = utils.get_input_user(await client.get_input_entity(self.bot)) + + def to_dict(self): + return { + '_': 'GetRequestedWebViewButtonRequest', + 'bot': self.bot.to_dict() if isinstance(self.bot, TLObject) else self.bot, + 'webapp_req_id': self.webapp_req_id + } + + def _bytes(self): + return b''.join(( + b'\xf3\xb7%\xbf', + self.bot._bytes(), + self.serialize_bytes(self.webapp_req_id), + )) + + @classmethod + def from_reader(cls, reader): + _bot = reader.tgread_object() + _webapp_req_id = reader.tgread_string() + return cls(bot=_bot, webapp_req_id=_webapp_req_id) + + +class InvokeWebViewCustomMethodRequest(TLRequest): + CONSTRUCTOR_ID = 0x87fc5e7 + SUBCLASS_OF_ID = 0xad0352e8 + + def __init__(self, bot: 'TypeInputUser', custom_method: str, params: 'TypeDataJSON'): + """ + :returns DataJSON: Instance of DataJSON. + """ + self.bot = bot + self.custom_method = custom_method + self.params = params + + async def resolve(self, client, utils): + self.bot = utils.get_input_user(await client.get_input_entity(self.bot)) + + def to_dict(self): + return { + '_': 'InvokeWebViewCustomMethodRequest', + 'bot': self.bot.to_dict() if isinstance(self.bot, TLObject) else self.bot, + 'custom_method': self.custom_method, + 'params': self.params.to_dict() if isinstance(self.params, TLObject) else self.params + } + + def _bytes(self): + return b''.join(( + b'\xe7\xc5\x7f\x08', + self.bot._bytes(), + self.serialize_bytes(self.custom_method), + self.params._bytes(), + )) + + @classmethod + def from_reader(cls, reader): + _bot = reader.tgread_object() + _custom_method = reader.tgread_string() + _params = reader.tgread_object() + return cls(bot=_bot, custom_method=_custom_method, params=_params) + + +class ReorderPreviewMediasRequest(TLRequest): + CONSTRUCTOR_ID = 0xb627f3aa + SUBCLASS_OF_ID = 0xf5b399ac + + def __init__(self, bot: 'TypeInputUser', lang_code: str, order: List['TypeInputMedia']): + """ + :returns Bool: This type has no constructors. + """ + self.bot = bot + self.lang_code = lang_code + self.order = order + + async def resolve(self, client, utils): + self.bot = utils.get_input_user(await client.get_input_entity(self.bot)) + _tmp = [] + for _x in self.order: + _tmp.append(utils.get_input_media(_x)) + + self.order = _tmp + + def to_dict(self): + return { + '_': 'ReorderPreviewMediasRequest', + 'bot': self.bot.to_dict() if isinstance(self.bot, TLObject) else self.bot, + 'lang_code': self.lang_code, + 'order': [] if self.order is None else [x.to_dict() if isinstance(x, TLObject) else x for x in self.order] + } + + def _bytes(self): + return b''.join(( + b"\xaa\xf3'\xb6", + self.bot._bytes(), + self.serialize_bytes(self.lang_code), + b'\x15\xc4\xb5\x1c',struct.pack(': This type has no constructors. + """ + self.chatlist = chatlist + + def to_dict(self): + return { + '_': 'GetLeaveChatlistSuggestionsRequest', + 'chatlist': self.chatlist.to_dict() if isinstance(self.chatlist, TLObject) else self.chatlist + } + + def _bytes(self): + return b''.join(( + b'\x14\xd7\xbc\xfd', + self.chatlist._bytes(), + )) + + @classmethod + def from_reader(cls, reader): + _chatlist = reader.tgread_object() + return cls(chatlist=_chatlist) + + +class HideChatlistUpdatesRequest(TLRequest): + CONSTRUCTOR_ID = 0x66e486fb + SUBCLASS_OF_ID = 0xf5b399ac + + def __init__(self, chatlist: 'TypeInputChatlist'): + """ + :returns Bool: This type has no constructors. + """ + self.chatlist = chatlist + + def to_dict(self): + return { + '_': 'HideChatlistUpdatesRequest', + 'chatlist': self.chatlist.to_dict() if isinstance(self.chatlist, TLObject) else self.chatlist + } + + def _bytes(self): + return b''.join(( + b'\xfb\x86\xe4f', + self.chatlist._bytes(), + )) + + @classmethod + def from_reader(cls, reader): + _chatlist = reader.tgread_object() + return cls(chatlist=_chatlist) + + +class JoinChatlistInviteRequest(TLRequest): + CONSTRUCTOR_ID = 0xa6b1e39a + SUBCLASS_OF_ID = 0x8af52aac + + def __init__(self, slug: str, peers: List['TypeInputPeer']): + """ + :returns Updates: Instance of either UpdatesTooLong, UpdateShortMessage, UpdateShortChatMessage, UpdateShort, UpdatesCombined, Updates, UpdateShortSentMessage. + """ + self.slug = slug + self.peers = peers + + async def resolve(self, client, utils): + _tmp = [] + for _x in self.peers: + _tmp.append(utils.get_input_peer(await client.get_input_entity(_x))) + + self.peers = _tmp + + def to_dict(self): + return { + '_': 'JoinChatlistInviteRequest', + 'slug': self.slug, + 'peers': [] if self.peers is None else [x.to_dict() if isinstance(x, TLObject) else x for x in self.peers] + } + + def _bytes(self): + return b''.join(( + b'\x9a\xe3\xb1\xa6', + self.serialize_bytes(self.slug), + b'\x15\xc4\xb5\x1c',struct.pack('