From 4d186cc518bfaf2132bbbce51da75fac8ad6336e Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Thu, 16 Apr 2020 21:41:15 +0300 Subject: [PATCH 01/75] Fix a bug when special rual rules of .editorconfig were not applied to files with certain type --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index 8e451be..bc77a30 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,5 +8,5 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.{html, css, md, sh, conf}] +[*.{html,css,md,sh,conf}] indent_size = 2 From 46e909525cf02a8e76c5abcf9ede0ea96abdab9f Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Thu, 16 Apr 2020 21:49:23 +0300 Subject: [PATCH 02/75] Refactoring of .env.example --- .env.example | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 63d6b16..7eb76c6 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ -# Flask secret key. -# You must generate it like this in order to use cryptography package: +# Single secret key +# You must generate it like this as follows +# in order to use `cryptography` package: # ``` # from cryptography.fernet import Fernet # key = Fernet.generate_key().decode() @@ -7,14 +8,15 @@ # ``` FLASK_SECRET_KEY= -# Address of database. PostgreSQL is expected in production, but not required +# Address of database. +# PostgreSQL is expected in production, but not required DATABASE_URL=postgresql+psycopg2://:@:/ -# API token of Telegram bot from @BotFather +# API token received from @BotFather for Telegram bot TELEGRAM_API_BOT_TOKEN= -# ID of app registerd in Yandex to access Yandex API +# ID of app registerd in Yandex to access Yandex OAuth API YANDEX_OAUTH_API_APP_ID= -# Password of app registerd in Yandex to access Yandex API +# Password of app registerd in Yandex to access Yandex OAuth API YANDEX_OAUTH_API_APP_PASSWORD= From 7cd579120a32b0f43ec367166cf181cb69e5f946 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Thu, 16 Apr 2020 21:54:45 +0300 Subject: [PATCH 03/75] Add migrations folder in flake config --- .flake8 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.flake8 b/.flake8 index 7b7f914..7f41022 100644 --- a/.flake8 +++ b/.flake8 @@ -4,5 +4,7 @@ exclude = # Folders __pycache__ venv + migrations + # Files *.pyc From 6445fe2bd14a4f83afe667878613273af95e2311 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Thu, 16 Apr 2020 22:49:20 +0300 Subject: [PATCH 04/75] Refactoring of manage.py --- manage.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/manage.py b/manage.py index 9998121..692aab7 100644 --- a/manage.py +++ b/manage.py @@ -15,7 +15,7 @@ ) -app = create_app() +app = create_app("development") class PossibleInfiniteLoopError(Exception): @@ -26,10 +26,10 @@ class PossibleInfiniteLoopError(Exception): pass -class InvalidTableError(Exception): +class InvalidTableDataError(Exception): """ Indicates that table in DB is invalid - (data is empty, some required data is NULL, etc,) + (data is empty, some required data is NULL, etc.) """ pass @@ -62,9 +62,9 @@ def cli(): help="How many fakes should be added" ) @with_app_context -def add_fake_users(count): +def add_fake_users(count: int) -> None: """ - Add fake users in DB. + Adds fake users in DB. """ i = 0 error_count = 0 @@ -98,9 +98,9 @@ def add_fake_users(count): help="How many fakes should be added" ) @with_app_context -def add_fake_chats(count): +def add_fake_chats(count: int) -> None: """ - Add fake chats in DB. + Adds fake chats in DB. """ i = 0 error_count = 0 @@ -115,7 +115,7 @@ def add_fake_chats(count): user = UserQuery.get_random_user() if (user is None): - raise InvalidTableError( + raise InvalidTableDataError( "Random user is none. Users table is empty?" ) @@ -141,9 +141,9 @@ def add_fake_chats(count): help="How many fakes should be added" ) @with_app_context -def add_fake_yd_tokens(count): +def add_fake_yd_tokens(count: int) -> None: """ - Add fake Yandex.Disk tokens in DB. + Adds fake Yandex.Disk tokens in DB. """ free_users_count = UserQuery.get_users_without_yd_token_count() @@ -188,7 +188,12 @@ def add_fake_yd_tokens(count): help="How many Y.D. token fakes should be added" ) @click.pass_context -def add_fake_data(context, users_count, chats_count, yd_tokens_count): +def add_fake_data( + context: dict, + users_count: int, + chats_count: int, + yd_tokens_count: int +) -> None: """ Adds fake data in all DB tables. """ @@ -199,7 +204,7 @@ def add_fake_data(context, users_count, chats_count, yd_tokens_count): @cli.command() @with_app_context -def clear_users(): +def clear_users() -> None: """ Removes all users from a table. """ @@ -211,7 +216,7 @@ def clear_users(): @cli.command() @with_app_context -def clear_chats(): +def clear_chats() -> None: """ Removes all chats from a table. """ @@ -223,7 +228,7 @@ def clear_chats(): @cli.command() @with_app_context -def clear_yd_tokens(): +def clear_yd_tokens() -> None: """ Removes all Yandex.Disk tokens from a table. """ @@ -235,7 +240,7 @@ def clear_yd_tokens(): @cli.command() @click.pass_context -def clear_db(context): +def clear_db(context: dict) -> None: """ Removes all data from DB tables. """ From 0e96a6ccf83473298c323e7d833c3b7570a45465 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 11:41:41 +0300 Subject: [PATCH 05/75] Add mypy package --- requirements.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/requirements.txt b/requirements.txt index 4697d15..b840056 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,8 @@ Jinja2==2.11.1 Mako==1.1.2 MarkupSafe==1.1.1 mccabe==0.6.1 +mypy==0.770 +mypy-extensions==0.4.3 psycopg2-binary==2.8.4 pycodestyle==2.5.0 pycparser==2.20 @@ -32,5 +34,7 @@ requests==2.23.0 six==1.14.0 SQLAlchemy==1.3.15 text-unidecode==1.3 +typed-ast==1.4.1 +typing-extensions==3.7.4.2 urllib3==1.25.8 Werkzeug==1.0.0 From 5c8d9dd7e7ffe3d891d49f4aee25a52a5d12ba42 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 16:29:26 +0300 Subject: [PATCH 06/75] Remove mypy --- requirements.txt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index b840056..4697d15 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,8 +20,6 @@ Jinja2==2.11.1 Mako==1.1.2 MarkupSafe==1.1.1 mccabe==0.6.1 -mypy==0.770 -mypy-extensions==0.4.3 psycopg2-binary==2.8.4 pycodestyle==2.5.0 pycparser==2.20 @@ -34,7 +32,5 @@ requests==2.23.0 six==1.14.0 SQLAlchemy==1.3.15 text-unidecode==1.3 -typed-ast==1.4.1 -typing-extensions==3.7.4.2 urllib3==1.25.8 Werkzeug==1.0.0 From 3dc51d084e7ed7896381a9da32108f72285b82f9 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 16:29:43 +0300 Subject: [PATCH 07/75] Add linters bash scripts --- README.md | 5 +++++ scripts/linters/all.sh | 3 +++ scripts/linters/flake8.sh | 3 +++ 3 files changed, 11 insertions(+) create mode 100644 scripts/linters/all.sh create mode 100644 scripts/linters/flake8.sh diff --git a/README.md b/README.md index 881166b..b8f8913 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - [Server](#server) - [Database](#database) - [Deployment](#deployment) + - [Before](#before) - [Heroku](#heroku) - [Contribution](#contribution) - [License](#license) @@ -163,6 +164,10 @@ Development and testing databases will be located at `src/development.sqlite` an Regardless of any platform you choose for hosting, it is recommend to manually configure number of workers, number of workers connections and number of threads for both `gunicorn` and `nginx`. +### Before + +It is recommend to run linters with `./scripts/linters/all.sh` before deployment and resolve all errors and warnings. + ### Heroku 1. If you don't have [Heroku](https://heroku.com/) installed, then it is a time to do that. diff --git a/scripts/linters/all.sh b/scripts/linters/all.sh new file mode 100644 index 0000000..1653a9f --- /dev/null +++ b/scripts/linters/all.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +./scripts/linters/flake8.sh diff --git a/scripts/linters/flake8.sh b/scripts/linters/flake8.sh new file mode 100644 index 0000000..cc1e57e --- /dev/null +++ b/scripts/linters/flake8.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +flake8 From b48dd95e3e0cc53fdc89e09169107a83a77dc9bb Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 16:30:21 +0300 Subject: [PATCH 08/75] Add .slugignore for Heroku --- .slugignore | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .slugignore diff --git a/.slugignore b/.slugignore new file mode 100644 index 0000000..2049c4b --- /dev/null +++ b/.slugignore @@ -0,0 +1,11 @@ +# Files +.editorconfig +.env.example +.flake8 +CHANGELOG.md +LICENSE +README.md + +# Folders +.vscode +info From 1dcd8102d11d9d7236fbb33aeb9b7c2f4f6b0357 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 16:30:44 +0300 Subject: [PATCH 09/75] Add rules for .ini files in .editorconfig --- .editorconfig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.editorconfig b/.editorconfig index bc77a30..e4ac243 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,5 +8,5 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[*.{html,css,md,sh,conf}] +[*.{html,css,md,sh,conf,ini}] indent_size = 2 From 8f0c9432eb15db462a5f4719288d14cb7848fde3 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 16:31:56 +0300 Subject: [PATCH 10/75] Rename db/db.py in db/database.py in order to avoid autocompletion errors --- src/db/__init__.py | 2 +- src/db/{db.py => database.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/db/{db.py => database.py} (100%) diff --git a/src/db/__init__.py b/src/db/__init__.py index db79e11..ee22204 100644 --- a/src/db/__init__.py +++ b/src/db/__init__.py @@ -1,4 +1,4 @@ -from .db import db +from .database import db from .migrate import migrate from .models import ( User, diff --git a/src/db/db.py b/src/db/database.py similarity index 100% rename from src/db/db.py rename to src/db/database.py From e108db60179fc40736bf6c36dde1236af6d95b70 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 17:27:59 +0300 Subject: [PATCH 11/75] Update bot logo --- info/logo.png | Bin 20516 -> 66211 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/info/logo.png b/info/logo.png index fef2a08100868ec1f1fb4d43d7e0f5076641763c..aebbafcb969ab4033649d2ea1c21731ccf79014a 100644 GIT binary patch literal 66211 zcmeFYg;$hs)HaHeN`rI?2uOE_v~+g~(%lUs-5??@Ez;eMbcZy<&@lAS&CECa-gCZl z{)l%0Yt~{dxc9xE9oN40^+cGo#t92c>s!BU3VTU2f(tC?N^;n=5 z=gpS2290fO!RO`Q)ISiS4zR4tYykkRc|p%Zt?W ^@Jm5ueje>k>2F?6P%z`|9=A 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

=9$r!-in7^u>ud`VPYc+mFXGX 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 Lf_R0ff&c#kJ!FYj literal 20516 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 From e726511300b63ddbb6402e8153c44b7e12e0c919 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov Date: Sat, 18 Apr 2020 18:16:52 +0300 Subject: [PATCH 12/75] Update favicon and add new favicons for different platforms --- src/app.py | 2 +- src/static/favicon.ico | Bin 15086 -> 0 bytes src/static/favicons/android-chrome-192x192.png | Bin 0 -> 7289 bytes src/static/favicons/android-chrome-512x512.png | Bin 0 -> 17670 bytes src/static/favicons/apple-touch-icon.png | Bin 0 -> 5444 bytes src/static/favicons/favicon-16x16.png | Bin 0 -> 781 bytes src/static/favicons/favicon-32x32.png | Bin 0 -> 1393 bytes src/static/favicons/favicon.ico | Bin 0 -> 15086 bytes src/static/favicons/mstile-150x150.png | Bin 0 -> 4897 bytes src/static/favicons/safari-pinned-tab.svg | 1 + src/templates/html5.html | 9 ++++++++- 11 files changed, 10 insertions(+), 2 deletions(-) delete mode 100644 src/static/favicon.ico create mode 100644 src/static/favicons/android-chrome-192x192.png create mode 100644 src/static/favicons/android-chrome-512x512.png create mode 100644 src/static/favicons/apple-touch-icon.png create mode 100644 src/static/favicons/favicon-16x16.png create mode 100644 src/static/favicons/favicon-32x32.png create mode 100644 src/static/favicons/favicon.ico create mode 100644 src/static/favicons/mstile-150x150.png create mode 100644 src/static/favicons/safari-pinned-tab.svg diff --git a/src/app.py b/src/app.py index da67b0c..684b66b 100644 --- a/src/app.py +++ b/src/app.py @@ -64,6 +64,6 @@ def favicon(): return redirect( url_for( "static", - filename="favicon.ico" + filename="favicons/favicon.ico" ) ) diff --git a/src/static/favicon.ico b/src/static/favicon.ico deleted file mode 100644 index 70c8b30dc14c17ae5c31315a6b3a794a6278ea59..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15086 zcmeHN3vg7`8U8l`!kd6R3IYkwIz>cz4b{>xh*X|p6`_Dqg$f0cHya+Bs3S1oRH+Ui zFSRX|g3+N`Xn_JF;}q<)APkgFfrMIlG!TLzWJ5x}e&61`VFR0Fmn=AT_DsHc1H2Ri!trO?{D^U^6Wq+H) zzAl{Se0*NKuOJ3cFZ3{PONqNM-9Kkp`pV-)G|#UIENlrZZUem1)VbFE*#VxsmQNn` z5R=G9i(AP>$0pY3i}ALf^Hfg1Xk5}BIJgNoe*(DhZ{X|^;Gcg4b}a(d4Fq0pM!k3( zUc6RJ9u5*yIexQS#7EP$gX{?BsGNSu_k(dLw-glsr@jDoE(Df!1yZXA^Rbt6JV8F9 zZSS=QwzD0lo+E9Q=|J?q*%Qb+Q!WdoKjfVS_N@kf*`0EUcWlL3SMPBieQrgq)QGp2 zmi7&Ue(5MR4%p%aihQM{R@nIZw4;w#?{sw`J&QbiY+GOZhwg7_IQ+9Pc+mXXXO?vX4rOpX(RRZr z1-Zb+XI%PKmk=35H1^palZTuh%?+QA4U-4i^nyCT`jNniPl4jX%DK?KtNlrObo8J) zBMN%ZI+{cHIyN$Fe#~%H<5#vXsgL+bYv}NCo-u|($um-YR7|_8gU!@I zLV%8&tz^p^f@u$KH==z%b+n+6C|NTNXJ}E&u2Qlgy%7 z^xfela)_{aaQh`tFKrSH54SbU>}h!WVZ)Xu4Evrk9HhS}pHcqGpGMe~Uk$OpFYaSw zAN_8XZI{#EP~=|>B=!(RM7VsEuA%0(xVAkW z=ZT6h%4sSrjrr@-#8%=AQA}LVi};h>e@sjxen@KvUS^w*3{j^ zaAGzg8xdv^@@4NLWIM4ElR!)YF$u&Z5R*Vm0x=1EV-j$JN_4jTC5m;H1~e-%#x_~g zp-WTE4~C>boe-!QbaXx>(gJmg)885d>IBwuSmRmq&pfKr*$A$8^E!>UvRJ1C)w#S* z;CX7b^3w;D_~|ASEc!jVwH(4^q7PAePV07M+Bj#d#3T@t zK;#l&jKafsP!pmzF`Ae}OeV$>1BlK#S5xj82=y1@h%hYoMmgp`xmtalw({_TY05o#+fro&niCCNAy>Ph+U&? zIpbMn=X7b&eEU{0m(D!gxnscb-N5c8%oRQeyjV~1?u(@3cckM7#7*TpCv~-@XP@W% zp<-|^)CKyxkZ0&)XYvB`rEXuZon*UL1T-{f-_PSc%;h98ckq*PWTElJ zqiFHc<&&MtR<7l~gK7ALG}MoPhG*H=A2U>r^7-zDBjY2??_J|xbm;8UV-d`+eEmK` zUZfw&&&{Jw4wH^zjU7raW#jWPs69)x zgxb5x(v!j1dnEf~bM1dVR)^tVa))>>IYl)yMt}#>H#)-QJ7Jx z6vZhNmPjT_;}rkLy9nREi9|P|5B-*&m`XWK4DX$gGsD{Q-MNj=<`S3b>s-X*5YKj zPvd#s1J1cEoz?vH_e(wg3 zQ6~f)ojo4=vU3x|^8SX?I@9a=D3XigGwYttcRv*3dC6G0SD(%r-P6>tfIiu7o^O%= zkw3#ZgHSNj@(O18^XMUl@PDgLIwHZJRDZ)H4c!#;bm!nGkf yni4&T?t!+x#l!v9VApR)abl$0c(}<3yM+{@fms$$FJ+PIT*5~b^V;p_*8c-$5mM*? diff --git a/src/static/favicons/android-chrome-192x192.png b/src/static/favicons/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..9f233af2b5f950e96c5d5969c7268b5ea669c5af GIT binary patch literal 7289 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 N%*ev9%D^M({{gOrC3*k= literal 0 HcmV?d00001 diff --git a/src/static/favicons/android-chrome-512x512.png b/src/static/favicons/android-chrome-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..9fba9f2adc9d9764985c6466ccc39e076c6078fb GIT binary patch literal 17670 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 hjvu}UD-M4SvUZgtPtE+0N z0)V;$iAY1N-lk&E$WE-d)QuR$ARCGe!0oHpHKr2cqoQc`C!l&rdlqrFa7d@40Cd_n z71hC_yCHx-sARewnZ-ro|1W6tW*rm?rKGI#Uu4KM3I!0gghu}>6RJba093{mht!DWFc}uFxVZ=X0yY?E@xgMIXZ1({*hn_u%w`X z0hAKBRD)YRcy%EWMawuer3m)WL5pZ+X13j@7ATb{-yS$SfjZNUb4+!*&<4@(krD`) zLZJYXCqlltn8Rc;*~lXuA!>040c&6uQe~Uge=M%WJFz?hJq*zwhMvj6@Qe}r9)?k?&1zj;ThDKpxFMPZ zT{0X9WEG3u`6GZOfc4ch;Zv|H0P}OYk$s0^+UokIMdDXkccmTTdGpxTH%lKuX(u?8 zk}i!{Bo9mHm+~}9&5=&Lcr<4>z-TGs&|6UoSY5oSfuWV6`BK+KD_a?P{Gnf2%eH`! znOqY|UvQ%Ss^&|wt*gf=caQw6@Zv@azc?m)i0xvu*u6fzQvPWyqQWxrX7_@9cJiXM zV?lbkFd8>xPeeYZg1ZCJhJLJn@OpIA3v2CkG;vFe5~D))tfaZQd8=zmnuNwEtCU%- zG|Lm33ps~ADXgsj&^xerzwOTfw&|-4?2^l}?Yj!sWs8Mk!s(p5zB_ETQD)`li3_z8 za@_`Tc!!M!-f(!Vv}u~(yr&|_NTS2hJw6_Ohx$(b%`EE$^<`*a)X^I8HLI3#dejWT z<+>~^G1D=6Pf_7M<5YRfTv2yQ!ugoV=>51}5{*EsKG2wNUNf{W6r^@pxpD40-QEAH z#F+Au3AY~cO~&KAvjmEHKN>FPH$_J5P%jg%Q*T|Hi8nK@z@^cu_#`Yh`|6lHZx}N7 z9$q~;&;xkCBNV5Qde7aOxs_L#%3IoViW}<;9+Y3q%PHzNIbZU8VzuOp&I5Z%+tEG} zt)DTfQ)!waCvi9a%4FT~(Uph-uAc9*DE${=oGWSU+K0+|qm0qViIxHO3eu{c#wG*7 z=a)(ZDI`w_9WNH4>X99ul(^gOhrm5G!>^ZNQ2JtJw9BHGz`VChw$PDC;rZPK@ zCQ)<7=pSFNjW0K620i3`U=*uB23oXq^Zu@IX+k>QEw^UbYtCvWRH>ntVqI3{EFaN- z{aWC>RlOwkH|wyBMN}**nbT;bHdG*t$x!b?)jp9ZT4Hm%I{7`d-@@JRzA`^1EF~OD z^1G)Hd{$k}B29LI&5!%qK$L`Acra|Dc9^Wjp=zWucTM4SjD*YQd=73-{iy`HCy&OA z)#F4)oMnXt>ha<^fqEa+uyZC>pYToj8+jo)X#fBK literal 0 HcmV?d00001 diff --git a/src/static/favicons/favicon.ico b/src/static/favicons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..7ce809c26b54e23a393a9313fbe5ba938a6a1081 GIT binary patch literal 15086 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 literal 0 HcmV?d00001 diff --git a/src/static/favicons/mstile-150x150.png b/src/static/favicons/mstile-150x150.png new file mode 100644 index 0000000000000000000000000000000000000000..ce516e176e4f29ff5742348341cf7f7716da7e42 GIT binary patch literal 4897 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 Gr~D5ou74Q- literal 0 HcmV?d00001 diff --git a/src/static/favicons/safari-pinned-tab.svg b/src/static/favicons/safari-pinned-tab.svg new file mode 100644 index 0000000..8e321a9 --- /dev/null +++ b/src/static/favicons/safari-pinned-tab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/templates/html5.html b/src/templates/html5.html index c160cdc..7e97d89 100644 --- a/src/templates/html5.html +++ b/src/templates/html5.html @@ -6,7 +6,14 @@ - + + + + + + + + {% block title %} From 1946e30ff4dee8e8e8cc1a89683a21458ee6ae38 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 18 Apr 2020 18:17:35 +0300 Subject: [PATCH 13/75] Add manifests files --- src/static/manifests/browserconfig.xml | 9 ++++++ src/static/manifests/manifest.webmanifest | 34 +++++++++++++++++++++++ src/templates/html5.html | 3 ++ 3 files changed, 46 insertions(+) create mode 100644 src/static/manifests/browserconfig.xml create mode 100644 src/static/manifests/manifest.webmanifest diff --git a/src/static/manifests/browserconfig.xml b/src/static/manifests/browserconfig.xml new file mode 100644 index 0000000..6a92474 --- /dev/null +++ b/src/static/manifests/browserconfig.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<browserconfig> + <msapplication> + <tile> + <square150x150logo src="/static/favicons/mstile-150x150.png"/> + <TileColor>#00a300</TileColor> + </tile> + </msapplication> +</browserconfig> diff --git a/src/static/manifests/manifest.webmanifest b/src/static/manifests/manifest.webmanifest new file mode 100644 index 0000000..f59d647 --- /dev/null +++ b/src/static/manifests/manifest.webmanifest @@ -0,0 +1,34 @@ +{ + "name": "Yandex.Disk Telegram Bot", + "short_name": "YD Telegram Bot", + "description": "Integration of Yandex.Disk right into Telegram.", + "categories": [ + "utilities" + ], + "lang": "en-US", + "icons": [ + { + "src": "/static/favicons/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + }, + { + "src": "/static/favicons/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/static/favicons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static/favicons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "browser" +} diff --git a/src/templates/html5.html b/src/templates/html5.html index 7e97d89..0c7a93c 100644 --- a/src/templates/html5.html +++ b/src/templates/html5.html @@ -7,6 +7,9 @@ <meta name="viewport" content="width=device-width, initial-scale=1.0, shrink-to-fit=no"> <meta http-equiv="X-UA-Compatible" content="ie=edge"> + <link rel="manifest" href="{{ url_for('static', filename='manifests/manifest.webmanifest') }}"> + <meta name="msapplication-config" content="{{ url_for('static', filename='manifests/browserconfig.xml') }}"> + <link rel="shortcut icon" href="{{ url_for('static', filename='favicons/favicon.ico') }}"> <link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='favicons/favicon-32x32.png') }}"> <link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='favicons/favicon-16x16.png') }}"> From 65ae3a438ccfd350c1baf60d30cb7be0854376de Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 18 Apr 2020 18:44:30 +0300 Subject: [PATCH 14/75] Update changelog --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca8439..34350bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +# 1.1.0 + +## Telegram Bot + +### Added + +- Favicons for different platforms. + +### Changed + +- Logo. +- Favicon. + +## Project + +### Improved + +- Big refactoring of everything. +- File structure. + +### Changed + +- Decrease size of slug on Heroku by removing unused files and folders. + + # 1.0.0 (April 15, 2020) Initial release! From 139daccb938bd51da5d87ad8b49e539a6258d038 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 18 Apr 2020 23:28:21 +0300 Subject: [PATCH 15/75] Update info.json --- info/info.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/info/info.json b/info/info.json index c666f70..0ef2e75 100644 --- a/info/info.json +++ b/info/info.json @@ -1,7 +1,7 @@ { "name": "Yandex.Disk Bot", - "version": "1.0.0", - "description": "This bot integrates Yandex.Disk right into Telegram.", + "version": "1.1.0", + "description": "This bot integrates Yandex.Disk into Telegram.", "about": "Work with Yandex.Disk.", "telegram": "Ya_Disk_Bot", "commands": [ @@ -23,31 +23,31 @@ }, { "command": "upload_photo", - "description": "Upload a photo. Original name will be not saved, quality of photo will be decreased. Send photo(s) with this command" + "description": "Upload a photo with quality loss" }, { "command": "upload_file", - "description": "Upload a file. Original name will be saved. For photos, original quality will be saved. Send file(s) with this command" + "description": "Upload a file with original state" }, { "command": "upload_audio", - "description": "Upload an audio. Original name will be saved, original type may be changed. Send audio file(s) with this command" + "description": "Upload an audio with original quality" }, { "command": "upload_video", - "description": "Upload a video. Original name will be not saved, original type may be changed. Send video file(s) with this command" + "description": "Upload a video with original quality" }, { "command": "upload_voice", - "description": "Upload a voice. Send voice file(s) with this command" + "description": "Upload a voice message" }, { "command": "create_folder", - "description": "Create a folder. Send folder name with this command. Folder name should starts from root, nested folders should be separated with backslash character" + "description": "Create a folder using OS path" }, { "command": "yandex_disk_authorization", - "description": "Give me an access to your Yandex.Disk" + "description": "Grant me an access to your Yandex.Disk" }, { "command": "yandex_disk_revoke", From 52731091bd8cb0f810f614713775c889fd6682c6 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 18 Apr 2020 23:28:59 +0300 Subject: [PATCH 16/75] Change web manifest description --- src/static/manifests/manifest.webmanifest | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/static/manifests/manifest.webmanifest b/src/static/manifests/manifest.webmanifest index f59d647..d8068de 100644 --- a/src/static/manifests/manifest.webmanifest +++ b/src/static/manifests/manifest.webmanifest @@ -1,7 +1,7 @@ { "name": "Yandex.Disk Telegram Bot", "short_name": "YD Telegram Bot", - "description": "Integration of Yandex.Disk right into Telegram.", + "description": "Integration of Yandex.Disk into Telegram.", "categories": [ "utilities" ], From 0e3b5e6653af04b8c181be33fbdbc1582a6e1971 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 18 Apr 2020 23:31:15 +0300 Subject: [PATCH 17/75] Change names of YD access and revoke commands --- info/info.json | 4 ++-- src/blueprints/telegram_bot/webhook/commands/common/names.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/info/info.json b/info/info.json index 0ef2e75..fae9d18 100644 --- a/info/info.json +++ b/info/info.json @@ -46,11 +46,11 @@ "description": "Create a folder using OS path" }, { - "command": "yandex_disk_authorization", + "command": "grant_access", "description": "Grant me an access to your Yandex.Disk" }, { - "command": "yandex_disk_revoke", + "command": "revoke_access", "description": "Revoke my access to your Yandex.Disk" } ] diff --git a/src/blueprints/telegram_bot/webhook/commands/common/names.py b/src/blueprints/telegram_bot/webhook/commands/common/names.py index 54bb163..549f2e0 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/names.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/names.py @@ -10,8 +10,8 @@ class CommandNames(Enum): HELP = "/help" ABOUT = "/about" SETTINGS = "/settings" - YD_AUTH = "/yandex_disk_authorization" - YD_REVOKE = "/yandex_disk_revoke" + YD_AUTH = "/grant_access" + YD_REVOKE = "/revoke_access" UPLOAD_PHOTO = "/upload_photo" UPLOAD_FILE = "/upload_file" UPLOAD_AUDIO = "/upload_audio" From dae686bfc36d34ff61a8301b54815000102fae32 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 00:20:36 +0300 Subject: [PATCH 18/75] Change revoke command name in yd_auth view --- src/blueprints/telegram_bot/yandex_disk_auth/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueprints/telegram_bot/yandex_disk_auth/views.py b/src/blueprints/telegram_bot/yandex_disk_auth/views.py index b532488..2f2c2d6 100644 --- a/src/blueprints/telegram_bot/yandex_disk_auth/views.py +++ b/src/blueprints/telegram_bot/yandex_disk_auth/views.py @@ -240,7 +240,7 @@ def handle_success(): f"on {current_date} at {current_time} {current_timezone}." "\n\n" "If it wasn't you, you can detach this access with " - "/yandex_disk_revoke" + "/revoke_access" ) ) From 18f851e8bcfaca3f26ae68cf7907c723f8e3fc76 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 11:42:55 +0300 Subject: [PATCH 19/75] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34350bb..23b98bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ### Changed +- Title. +- Description. - Logo. - Favicon. From f0865b1ce6792720f0f073a3b24e27559b9a9fb8 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 14:50:33 +0300 Subject: [PATCH 20/75] Refactoring of README.md --- README.md | 58 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index b8f8913..eeeaca7 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ </h1> <p align="center"> - A bot for Telegram that integrates Yandex.Disk right into Telegram. + A Telegram bot that integrates Yandex.Disk into Telegram. </p> @@ -28,11 +28,11 @@ ## Features -- uploading of photos; -- uploading of files; -- uploading of audio; -- uploading of video; -- uploading of voice; +- uploading of photos. +- uploading of files. +- uploading of audio. +- uploading of video. +- uploading of voice. - creating of folders. @@ -43,15 +43,15 @@ - [venv](https://docs.python.org/3/library/venv.html) - [git](https://git-scm.com/) - [curl](https://curl.haxx.se/) (optional) -- [nginx](https://nginx.org/) (optional) +- [nginx 1.16+](https://nginx.org/) (optional) - [postgreSQL 10+](https://www.postgresql.org/) (optional) -- [heroku](https://www.heroku.com/) (optional) +- [heroku 7.39+](https://www.heroku.com/) (optional) -It is expected that all of the above software is available as a global variable: `python3`, [`python3 -m pip`](https://github.com/pypa/pip/issues/5599#issuecomment-597042338), `python3 -m venv`, `git`, `curl`, `nginx`, `psql`, `heroku`. +It is expected that all of the above software is available as a global variable: `python3`, `python3 -m pip`, `python3 -m venv`, `git`, `curl`, `nginx`, `psql`, `heroku`. See [this](https://github.com/pypa/pip/issues/5599#issuecomment-597042338) why you should use such syntax: `python3 -m <module>`. -If you want to host this server somewhere, then you need install additional software. See your host installation guide. +All subsequent instructions is for Unix-like systems, primarily for Linux. You may need to make some changes on your own if you work on non-Linux operating system. -All subsequent instructions is for Unix systems (primarily for Linux). You may need to make some changes on your own if you work on Windows. +If you want to host this server somewhere outside of Heroku, then you may need to install additional software. See your host installation guide. ## Installation @@ -77,7 +77,7 @@ After that step we will use `python` instead of `python3` and `pip` instead of ` - edit executable paths in `.vscode/settings.json` - edit names in `./scripts` files -You may also want to upgrade `pip`, because [there can be](https://github.com/pypa/pip/issues/5221) an old version (9.0.1) instead of new one. Run `pip install --upgrade pip`. +You probably need to upgrade `pip`, because [you may have](https://github.com/pypa/pip/issues/5221) an old version (9.0.1) instead of new one. Run `pip install --upgrade pip`. 3. Install requirements. @@ -96,9 +96,11 @@ flask db upgrade `python manage.py --help` -6. If needed, perform [integration with external API's](#integration-with-external-apis). +That's all you need for development. If you want create production-ready server, then: -7. See [Local usage](#local-usage) or [Deployment](#deployment). +1. Perform [integration with external API's](#integration-with-external-apis). + +2. See [Local usage](#local-usage) or [Deployment](#deployment). ## Integration with external API's @@ -108,6 +110,7 @@ flask db upgrade 1. Register your bot in chat with [@BotFather](http://t.me/BotFather) and get API token. 2. [Set a webhook](https://core.telegram.org/bots/api#setwebhook): + ```shell ./scripts/telegram/set_webhook.sh <TELEGRAM_BOT_TOKEN> <SERVER_URL> <MAX_CONNECTIONS> ``` @@ -117,22 +120,20 @@ Russian users may need a proxy: ./scripts/telegram/set_webhook.sh <TELEGRAM_BOT_TOKEN> <SERVER_URL> <MAX_CONNECTIONS> "--proxy <PROXY>" ``` -For parameter `MAX_CONNECTIONS` it is recommended to use maxium number of simultaneous connections to the selected database. For example, "Heroku Postgres" extension at "Hobby Dev" plan have connection limit of 20. So, you should use `20` as value for key `MAX_CONNECTIONS` in order to avoid possible problems with `too many connections` error. +For parameter `MAX_CONNECTIONS` it is recommended to use maxium number of simultaneous connections to the selected database. For example, "Heroku Postgres" extension at "Hobby Dev" plan have connection limit of 20. So, you should use `20` as value for `MAX_CONNECTIONS` parameter in order to avoid possible `Too many connections` error. From Telegram documentation: > If you'd like to make sure that the Webhook request comes from Telegram, we recommend using a secret path in the URL, e.g. `https://www.example.com/<token>`. Since nobody else knows your bot‘s token, you can be pretty sure it’s us. -So, instead of `/telegram_bot/webhook` you can use something like this `/telegram_bot/webhook_fd1k3Bfa01WQl5S`. +So, instead of `/telegram_bot/webhook` you can use something like this: `/telegram_bot/webhook_fd1k3Bfa01WQl5S`. ### Yandex.Disk -1. Register your app in [Yandex](https://yandex.ru/dev/oauth/). - -2. Most likely it will take a while for Yandex moderators to check your app. +1. Register your app in [Yandex](https://yandex.ru/dev/oauth/). Most likely it will take a while for Yandex moderators to check your app. -3. Get your app ID and password at special Yandex page for your app. +2. Get your app ID and password at special Yandex page for your app. -4. At special Yandex page for your app find "Callback URI" settings and add this URI: `https://<your site>/telegram_bot_yandex_disk_auth`. +3. At special Yandex page for your app find "Callback URI" setting and add this URI: `https://<your site>/telegram_bot/yandex_disk_authorization`. ## Local usage @@ -151,38 +152,41 @@ Usually you will want to run both `gunicorn` and `nginx`. To do so run scripts i Run `./scripts/server/stop_nginx.sh` in order to stop nginx. -nginx uses simple configuration from `./src/configs/nginx.conf`. You can ignore this and use any configuration for nginx that is appropriate to you. However, it is recommend to use exact configuration as in current version for `flask` and `gunicorn`. Instead, make PR if you think that something is wrong with these two configurations. +nginx uses simple configuration from `./src/configs/nginx.conf`. You can ignore this and use any configuration for nginx that is appropriate to you. However, it is recommended to use exact configurations as in app for both `flask` and `gunicorn`. If you think these configurations is not right, then make PR instead. ### Database -In both development and testing environments `SQLite` is used. For production `PostgreSQL` is recommended, but you can use any of [supported databases](https://docs.sqlalchemy.org/en/13/core/engines.html#supported-databases). App already configured for both `SQLite` and `PostgreSQL`, for another databases you may have to install additional Python packages. +In both development and testing environments `SQLite` is used. For production `PostgreSQL` is recommended, but you can use any of [supported databases](https://docs.sqlalchemy.org/en/13/core/engines.html#supported-databases). App already configured for both `SQLite` and `PostgreSQL`, for another database you may have to install additional Python packages. Development and testing databases will be located at `src/development.sqlite` and `src/testing.sqlite` respectively. ## Deployment -Regardless of any platform you choose for hosting, it is recommend to manually configure number of workers, number of workers connections and number of threads for both `gunicorn` and `nginx`. +Regardless of any platform you choose for hosting, it is recommended to manually configure number of workers, number of workers connections and number of threads for both `gunicorn` and `nginx`. ### Before -It is recommend to run linters with `./scripts/linters/all.sh` before deployment and resolve all errors and warnings. +It is recommended to run linters with `./scripts/linters/all.sh` before deployment and resolve all errors and warnings. ### Heroku 1. If you don't have [Heroku](https://heroku.com/) installed, then it is a time to do that. 2. If you don't have Heroku remote, then add it: + - for existing app: ```git git remote add heroku <URL> ``` + - for new app: ``` heroku create ``` 3. We need both python and nginx build packs. Python build pack should be added automatically, but we will do it manually. For nginx build pack you can use whatever you want: [official one](https://github.com/heroku/heroku-buildpack-nginx), [my own one](https://github.com/Amaimersion/heroku-buildpack-nginx-for-yandex-disk-telegram-bot) or create your own one. In case of not using my own nginx build pack don't forget about compatibility (config paths, environment variables names, etc.). + ``` heroku buildpacks:set heroku/python heroku buildpacks:add https://github.com/Amaimersion/heroku-buildpack-nginx-for-yandex-disk-telegram-bot.git @@ -197,6 +201,7 @@ heroku addons:create heroku-postgresql:hobby-dev Later you can view the DB content by using `heroku pg:psql`. 5. Switch to new branch special for Heroku (don't ever push it!): + ```git git checkout -b heroku ``` @@ -204,17 +209,20 @@ git checkout -b heroku 6. Make sure `.env` file is created and filled. Remove it from `.gitignore`. Don't forget: don't ever push it anywhere but Heroku. 7. Add changes for pushing to Heroku: + - if you edited files on heroku branch: ```git git add . git commit -m <message> ``` + - if you want push changes from another branch: ```git git merge <another branch> -m <message> ``` 8. Upload files to Heroku: + ```git git push heroku heroku:master ``` From 8a167e38d4aa3734603a877cebafb5edffb21979 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 14:54:22 +0300 Subject: [PATCH 21/75] Add README.md for info folder --- info/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 info/README.md diff --git a/info/README.md b/info/README.md new file mode 100644 index 0000000..58bab86 --- /dev/null +++ b/info/README.md @@ -0,0 +1 @@ +This information used in Telegram Bot description and Yandex App description. From 023ecfbaf85768f37827608671c18f8e7676f9d1 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 14:57:24 +0300 Subject: [PATCH 22/75] Add README.md for scripts folder --- scripts/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 scripts/README.md diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..c455881 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1 @@ +These scripts is not hard-logic utilities. It is just automation of routine terminal commands. i.e., you can just run this scripts instead of typing same command again and again. From eda751bebea5620047fb2d6b145c86926def51e4 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 14:59:39 +0300 Subject: [PATCH 23/75] Refactoring of README.md in scripts folder --- scripts/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/README.md b/scripts/README.md index c455881..0557bac 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1 +1,8 @@ -These scripts is not hard-logic utilities. It is just automation of routine terminal commands. i.e., you can just run this scripts instead of typing same command again and again. +## About + +These scripts is not hard-logic utilities. It is just automation of routine terminal commands. i.e., you can just run these scripts instead of typing same command again and again. + + +## Notes + +- you should run these scripts from root directory. From 36a2878e98c8c670b09df227a5853fda8fb7320e Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 14:59:58 +0300 Subject: [PATCH 24/75] Refactoring of README.md in info folder --- info/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/info/README.md b/info/README.md index 58bab86..a06b195 100644 --- a/info/README.md +++ b/info/README.md @@ -1 +1,3 @@ +## About + This information used in Telegram Bot description and Yandex App description. From 43231fbafd7afd6ad466d283770a44123d6e528a Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 19 Apr 2020 15:01:46 +0300 Subject: [PATCH 25/75] Refactoring of README.md in scripts/env folder --- scripts/env/README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/env/README.md b/scripts/env/README.md index 6c83702..bef289c 100644 --- a/scripts/env/README.md +++ b/scripts/env/README.md @@ -1 +1,8 @@ -These scripts should be called with either `source` or `.` in order environment variables can be exported globally. +## About + +These scripts sets required environment variables for specific server environment. + + +## Notes + +- these scripts should be called with either `source` or `.` in order environment variables can be exported globally. From d077e2481593e7a97e9f4014dc5dcdb92e85c1a9 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Mon, 20 Apr 2020 00:29:56 +0300 Subject: [PATCH 26/75] Refactoring of app.py --- src/app.py | 18 +++++++++++------- src/configs/__init__.py | 1 + 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app.py b/src/app.py index 684b66b..014e45a 100644 --- a/src/app.py +++ b/src/app.py @@ -4,9 +4,13 @@ import os -from flask import Flask, redirect, url_for +from flask import ( + Flask, + redirect, + url_for +) -from .configs.flask import config as Config +from .configs import flask_config from .db import db, migrate from .blueprints import telegram_bot_blueprint @@ -25,19 +29,19 @@ def create_app(config_name: str = None) -> Flask: return app -def configure_app(app: Flask, config_name: str): +def configure_app(app: Flask, config_name: str = None) -> None: """ Configures app. """ if (not isinstance(config_name, str)): config_name = os.getenv("CONFIG_NAME", "default") - config = Config[config_name] + config = flask_config[config_name] app.config.from_object(config) -def configure_db(app: Flask): +def configure_db(app: Flask) -> None: """ Configures database. """ @@ -45,7 +49,7 @@ def configure_db(app: Flask): migrate.init_app(app, db) -def configure_blueprints(app: Flask): +def configure_blueprints(app: Flask) -> None: """ Configures blueprints. """ @@ -55,7 +59,7 @@ def configure_blueprints(app: Flask): ) -def configure_redirects(app: Flask): +def configure_redirects(app: Flask) -> None: """ Configures redirects. """ diff --git a/src/configs/__init__.py b/src/configs/__init__.py index e69de29..dc109be 100644 --- a/src/configs/__init__.py +++ b/src/configs/__init__.py @@ -0,0 +1 @@ +from .flask import config as flask_config From 026312e39463b5734354f6dbcb0b245a003d776b Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Tue, 21 Apr 2020 23:04:48 +0300 Subject: [PATCH 27/75] Make project as self-installable --- requirements.txt | 1 + scripts/requirements/create.sh | 7 ++++++- setup.py | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 setup.py diff --git a/requirements.txt b/requirements.txt index 4697d15..82cde38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,3 +34,4 @@ SQLAlchemy==1.3.15 text-unidecode==1.3 urllib3==1.25.8 Werkzeug==1.0.0 +-e . diff --git a/scripts/requirements/create.sh b/scripts/requirements/create.sh index ee0d2c5..3146435 100644 --- a/scripts/requirements/create.sh +++ b/scripts/requirements/create.sh @@ -1,3 +1,8 @@ #!/bin/bash -pip freeze > requirements.txt +# we use `--exclude-editable` because after +# `pip install -e .` `pip freeze` gives +# `-e git+https://github.com/Amaimersion/yandex-disk-telegram-bot.git@d077e2481593e7a97e9f4014dc5dcdb92e85c1a9#egg=yandex_disk_telegram_bot` +# instead of `-e .`. +pip freeze > requirements.txt --exclude-editable +echo "-e ." >> requirements.txt diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b0d10fb --- /dev/null +++ b/setup.py @@ -0,0 +1,7 @@ +from setuptools import setup, find_packages + + +setup( + name="yandex-disk-telegram-bot", + packages=find_packages() +) From 73972b37d88f96447fcade120cd4e7bc4028d9e2 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Tue, 21 Apr 2020 23:12:48 +0300 Subject: [PATCH 28/75] Refactoring of relative imports --- src/blueprints/telegram_bot/webhook/commands/about.py | 2 +- src/blueprints/telegram_bot/webhook/commands/common/api.py | 2 +- .../telegram_bot/webhook/commands/common/decorators.py | 4 ++-- .../telegram_bot/webhook/commands/common/responses.py | 2 +- .../telegram_bot/webhook/commands/create_folder.py | 2 +- src/blueprints/telegram_bot/webhook/commands/help.py | 2 +- src/blueprints/telegram_bot/webhook/commands/settings.py | 2 +- src/blueprints/telegram_bot/webhook/commands/unknown.py | 2 +- src/blueprints/telegram_bot/webhook/commands/upload.py | 2 +- src/blueprints/telegram_bot/webhook/commands/yd_auth.py | 4 ++-- src/blueprints/telegram_bot/webhook/commands/yd_revoke.py | 4 ++-- src/blueprints/telegram_bot/webhook/views.py | 2 +- src/blueprints/telegram_bot/yandex_disk_auth/views.py | 6 +++--- src/db/models/chat.py | 2 +- src/db/models/user.py | 4 ++-- src/db/models/yandex_disk_token.py | 2 +- src/db/queries/chat.py | 4 ++-- src/db/queries/user.py | 2 +- src/db/queries/yandex_disk_token.py | 2 +- 19 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index 4ebf1c4..1f041ec 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -1,6 +1,6 @@ from flask import g -from .....api import telegram +from src.api import telegram def handle(): diff --git a/src/blueprints/telegram_bot/webhook/commands/common/api.py b/src/blueprints/telegram_bot/webhook/commands/common/api.py index 8c88d23..8981674 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/api.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/api.py @@ -3,7 +3,7 @@ from flask import current_app -from ......api import yandex +from src.api import yandex class YandexAPIRequestError(Exception): diff --git a/src/blueprints/telegram_bot/webhook/commands/common/decorators.py b/src/blueprints/telegram_bot/webhook/commands/common/decorators.py index 629633a..11f5032 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/decorators.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/decorators.py @@ -2,14 +2,14 @@ from flask import g -from ......db import ( +from src.db import ( db, User, UserQuery, Chat, ChatQuery ) -from ......db.models import ( +from src.db.models import ( ChatType ) from .responses import cancel_command diff --git a/src/blueprints/telegram_bot/webhook/commands/common/responses.py b/src/blueprints/telegram_bot/webhook/commands/common/responses.py index 6c8fbfd..4fb9e9a 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/responses.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/responses.py @@ -1,4 +1,4 @@ -from ......api import telegram +from src.api import telegram def abort_command(chat_telegram_id: int) -> None: diff --git a/src/blueprints/telegram_bot/webhook/commands/create_folder.py b/src/blueprints/telegram_bot/webhook/commands/create_folder.py index fb8dac4..ece234b 100644 --- a/src/blueprints/telegram_bot/webhook/commands/create_folder.py +++ b/src/blueprints/telegram_bot/webhook/commands/create_folder.py @@ -1,6 +1,6 @@ from flask import g -from .....api import telegram +from src.api import telegram from .common.responses import ( cancel_command ) diff --git a/src/blueprints/telegram_bot/webhook/commands/help.py b/src/blueprints/telegram_bot/webhook/commands/help.py index 5e01a02..5a7e3d4 100644 --- a/src/blueprints/telegram_bot/webhook/commands/help.py +++ b/src/blueprints/telegram_bot/webhook/commands/help.py @@ -1,6 +1,6 @@ from flask import g, current_app -from .....api import telegram +from src.api import telegram from .common.names import CommandNames diff --git a/src/blueprints/telegram_bot/webhook/commands/settings.py b/src/blueprints/telegram_bot/webhook/commands/settings.py index aa8e255..715f09b 100644 --- a/src/blueprints/telegram_bot/webhook/commands/settings.py +++ b/src/blueprints/telegram_bot/webhook/commands/settings.py @@ -1,6 +1,6 @@ from flask import g -from .....api import telegram +from src.api import telegram from .common.decorators import register_guest, get_db_data from .common.responses import request_private_chat diff --git a/src/blueprints/telegram_bot/webhook/commands/unknown.py b/src/blueprints/telegram_bot/webhook/commands/unknown.py index f8a5262..108c0c0 100644 --- a/src/blueprints/telegram_bot/webhook/commands/unknown.py +++ b/src/blueprints/telegram_bot/webhook/commands/unknown.py @@ -1,6 +1,6 @@ from flask import g -from .....api import telegram +from src.api import telegram def handle(): diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index e0b229b..e06be01 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -3,7 +3,7 @@ from flask import g, current_app -from .....api import telegram +from src.api import telegram from .common.decorators import ( yd_access_token_required, get_db_data diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index 1ac8d42..ee4b1da 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -6,11 +6,11 @@ from flask import g, current_app import jwt -from .....db import ( +from src.db import ( db, YandexDiskToken ) -from .....api import telegram, yandex +from src.api import telegram, yandex from .common.decorators import ( register_guest, get_db_data diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py index d097044..7941352 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py @@ -2,8 +2,8 @@ from flask import g -from .....db import db -from .....api import telegram +from src.db import db +from src.api import telegram from .common.decorators import get_db_data from .common.names import CommandNames diff --git a/src/blueprints/telegram_bot/webhook/views.py b/src/blueprints/telegram_bot/webhook/views.py index 81ab60e..fad5839 100644 --- a/src/blueprints/telegram_bot/webhook/views.py +++ b/src/blueprints/telegram_bot/webhook/views.py @@ -6,7 +6,7 @@ make_response ) -from .. import telegram_bot_blueprint as bp +from src.blueprints.telegram_bot import telegram_bot_blueprint as bp from . import commands from .commands.common.names import CommandNames diff --git a/src/blueprints/telegram_bot/yandex_disk_auth/views.py b/src/blueprints/telegram_bot/yandex_disk_auth/views.py index 2f2c2d6..b16f6f5 100644 --- a/src/blueprints/telegram_bot/yandex_disk_auth/views.py +++ b/src/blueprints/telegram_bot/yandex_disk_auth/views.py @@ -9,9 +9,9 @@ ) import jwt -from ....db import db, UserQuery, ChatQuery -from ....api import yandex, telegram -from .. import telegram_bot_blueprint as bp +from src.db import db, UserQuery, ChatQuery +from src.api import yandex, telegram +from src.blueprints.telegram_bot import telegram_bot_blueprint as bp TEMPLATES = { diff --git a/src/db/models/chat.py b/src/db/models/chat.py index da4ba19..e733e49 100644 --- a/src/db/models/chat.py +++ b/src/db/models/chat.py @@ -1,6 +1,6 @@ from enum import IntEnum, unique -from .. import db +from src.db import db @unique diff --git a/src/db/models/user.py b/src/db/models/user.py index e4f4f3a..630b2d3 100644 --- a/src/db/models/user.py +++ b/src/db/models/user.py @@ -2,8 +2,8 @@ from sqlalchemy.sql import func -from ...localization import SupportedLanguages -from .. import db +from src.localization import SupportedLanguages +from src.db import db @unique diff --git a/src/db/models/yandex_disk_token.py b/src/db/models/yandex_disk_token.py index 214ade5..7fd46b8 100644 --- a/src/db/models/yandex_disk_token.py +++ b/src/db/models/yandex_disk_token.py @@ -9,7 +9,7 @@ InvalidToken as InvalidTokenFernetError ) -from .. import db +from src.db import db class YandexDiskToken(db.Model): diff --git a/src/db/queries/chat.py b/src/db/queries/chat.py index d1740af..a6fd14a 100644 --- a/src/db/queries/chat.py +++ b/src/db/queries/chat.py @@ -1,7 +1,7 @@ from typing import List, Union, NewType -from .. import Chat -from ..models import ChatType +from src.db import Chat +from src.db.models import ChatType ChatOrNone = NewType("ChatOrNone", Union[Chat, None]) diff --git a/src/db/queries/user.py b/src/db/queries/user.py index 2b7323f..36fbd24 100644 --- a/src/db/queries/user.py +++ b/src/db/queries/user.py @@ -2,7 +2,7 @@ from sqlalchemy.sql.expression import func -from .. import User, YandexDiskToken +from src.db import User, YandexDiskToken UserOrNone = NewType("UserOrNone", Union[User, None]) diff --git a/src/db/queries/yandex_disk_token.py b/src/db/queries/yandex_disk_token.py index 9db72c8..83c6736 100644 --- a/src/db/queries/yandex_disk_token.py +++ b/src/db/queries/yandex_disk_token.py @@ -1,4 +1,4 @@ -from .. import YandexDiskToken +from src.db import YandexDiskToken def delete_all_yd_tokens() -> int: From d00de8099497ef0a26ff2f39ffa6edd960003566 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Tue, 21 Apr 2020 23:23:49 +0300 Subject: [PATCH 29/75] Fix python autocomplete path in vscode/settings.json --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 9f90bdd..51dbe5c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "python.pythonPath": "./venv/bin/python", "python.autoComplete.extraPaths": [ - "./src" + "./src/*" ], "cornflakes.linter.executablePath": "./venv/bin/flake8", "files.exclude": { From 6d1a027e4f4edc8db08ffa5e3367c1c9955db061 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 23 Apr 2020 18:03:03 +0300 Subject: [PATCH 30/75] Refactoring of API module --- src/api/auth.py | 14 ++ src/api/exceptions.py | 5 + src/api/request.py | 85 +++++++++ src/api/telegram/__init__.py | 2 +- src/api/telegram/exceptions.py | 17 +- src/api/telegram/methods.py | 25 ++- src/api/telegram/request.py | 85 --------- src/api/telegram/requests.py | 93 ++++++++++ src/api/yandex/__init__.py | 5 +- src/api/yandex/exceptions.py | 19 --- src/api/yandex/methods.py | 22 +-- src/api/yandex/request.py | 161 ------------------ src/api/yandex/requests.py | 124 ++++++++++++++ .../webhook/commands/common/api.py | 6 +- .../webhook/commands/create_folder.py | 4 +- .../telegram_bot/webhook/commands/upload.py | 14 +- .../telegram_bot/webhook/commands/yd_auth.py | 2 +- .../telegram_bot/yandex_disk_auth/views.py | 2 +- 18 files changed, 372 insertions(+), 313 deletions(-) create mode 100644 src/api/auth.py create mode 100644 src/api/exceptions.py create mode 100644 src/api/request.py delete mode 100644 src/api/telegram/request.py create mode 100644 src/api/telegram/requests.py delete mode 100644 src/api/yandex/exceptions.py delete mode 100644 src/api/yandex/request.py create mode 100644 src/api/yandex/requests.py diff --git a/src/api/auth.py b/src/api/auth.py new file mode 100644 index 0000000..fdf8736 --- /dev/null +++ b/src/api/auth.py @@ -0,0 +1,14 @@ +from requests.auth import AuthBase + + +class HTTPOAuthAuth(AuthBase): + """ + Attaches HTTP OAuth Authentication to the given Request object. + """ + def __init__(self, token: str) -> None: + self.token = token + + def __call__(self, request: dict) -> dict: + request.headers["Authorization"] = f"OAuth {self.token}" + + return request diff --git a/src/api/exceptions.py b/src/api/exceptions.py new file mode 100644 index 0000000..869f03e --- /dev/null +++ b/src/api/exceptions.py @@ -0,0 +1,5 @@ +class RequestException(Exception): + """ + Expected exception that occurred while handling request. + """ + pass diff --git a/src/api/request.py b/src/api/request.py new file mode 100644 index 0000000..a8ee217 --- /dev/null +++ b/src/api/request.py @@ -0,0 +1,85 @@ +import typing + +import requests + + +CONTENT_TYPE = typing.Literal[ + "none", + "bytes", + "json", + "text" +] + + +class RequestResult(typing.TypedDict): + # https://requests.readthedocs.io/en/master/api/#requests.Response.ok + ok: bool + # https://requests.readthedocs.io/en/master/api/#requests.Response.reason + reason: str + # https://requests.readthedocs.io/en/master/api/#requests.Response.status_code + status_code: int + # Depends on `content_type` argument + content: typing.Any + + +def request( + raise_for_status=True, + content_type: CONTENT_TYPE = "none", + **kwargs +) -> RequestResult: + """ + Makes HTTP request. + + :param raise_for_status: + Raises exception if response code is 400 <= x < 600. + :param content_type: + How to decode response content. + :param **kwargs: + See https://requests.readthedocs.io/en/master/api/#requests.request + + :returns: + Result of request. + See `RequestResult` documentation for more. + + :raises ValueError: + If required content_type is `json` and response + body does not contain valid json. + :raises requests.RequestException: + https://requests.readthedocs.io/en/master/api/#exceptions + """ + response = requests.request(**kwargs) + + if (raise_for_status): + response.raise_for_status() + + content = { + "none": lambda: None, + "bytes": lambda: response.content, + "json": lambda: response.json(), + "text": lambda: response.text + } + result: RequestResult = { + "ok": response.ok, + "reason": response.reason, + "status_code": response.status_code, + "content": content[content_type]() + } + + return result + + +def create_url(*args: str) -> str: + """ + Creates URL for HTTP request. + + - doesn't contains trailing slash at the end. + + :param *args: Segments to join. Order will be saved. + + :returns: Created URL. + """ + separator = "/" + + return separator.join( + [x[:-1] if x.endswith(separator) else x for x in args] + ) diff --git a/src/api/telegram/__init__.py b/src/api/telegram/__init__.py index d5ff275..e5ffae3 100644 --- a/src/api/telegram/__init__.py +++ b/src/api/telegram/__init__.py @@ -3,6 +3,6 @@ get_file, send_chat_action ) -from .request import ( +from .requests import ( create_file_download_url ) diff --git a/src/api/telegram/exceptions.py b/src/api/telegram/exceptions.py index b5e9a36..8f61a90 100644 --- a/src/api/telegram/exceptions.py +++ b/src/api/telegram/exceptions.py @@ -1,19 +1,14 @@ -class TelegramApiException(Exception): +class TelegramBotApiException(Exception): """ - Expected exception that occurred while handling Telegram API request. + Expected exception that occurred while + handling Telegram Bot API request. """ pass -class InvalidResponseFormatException(TelegramApiException): +class RequestFailed(TelegramBotApiException): """ - Format of response differ from Telegram response. - """ - pass - - -class MethodExecutionFailedException(TelegramApiException): - """ - Telegram successfully called a method, but it failed to end. + Telegram indicates that some error occurred + because of something (see error message). """ pass diff --git a/src/api/telegram/methods.py b/src/api/telegram/methods.py index a10a4bb..a18ed59 100644 --- a/src/api/telegram/methods.py +++ b/src/api/telegram/methods.py @@ -1,31 +1,28 @@ -import requests +from .requests import make_request -from .request import make_request -from .exceptions import TelegramApiException - - -def send_message(**kwargs) -> None: +def send_message(**kwargs): """ https://core.telegram.org/bots/api/#sendmessage + + - see `api/request.py` documentation for more. """ - try: - make_request("sendMessage", kwargs) - except requests.RequestException as error: - print(error) - except TelegramApiException as error: - print(error) + return make_request("sendMessage", kwargs) -def get_file(**kwargs) -> dict: +def get_file(**kwargs): """ https://core.telegram.org/bots/api#getfile + + - see `api/request.py` documentation for more. """ return make_request("getFile", kwargs) -def send_chat_action(**kwargs) -> dict: +def send_chat_action(**kwargs): """ https://core.telegram.org/bots/api/#sendchataction + + - see `api/request.py` documentation for more. """ return make_request("sendChatAction", kwargs) diff --git a/src/api/telegram/request.py b/src/api/telegram/request.py deleted file mode 100644 index 7899427..0000000 --- a/src/api/telegram/request.py +++ /dev/null @@ -1,85 +0,0 @@ -import os - -import requests -from flask import current_app - -from .exceptions import ( - InvalidResponseFormatException, - MethodExecutionFailedException -) - - -def create_url(method_name: str) -> str: - """ - Creates Telegram Bot API URL for calling. - - :param method_name: Name of API method in URL. - """ - bot_token = os.getenv("TELEGRAM_API_BOT_TOKEN", "") - base_url = "https://api.telegram.org" - bot_url = "bot{token}".format(token=bot_token) - url = f"{base_url}/{bot_url}/{method_name}" - - return url - - -def create_file_download_url(file_path: str) -> str: - """ - :param file_path: `file_path` property of `File` object. - - :returns: URL for downloading of Telegram file. - """ - bot_token = os.getenv("TELEGRAM_API_BOT_TOKEN", "") - base_url = "https://api.telegram.org/file" - bot_url = "bot{token}".format(token=bot_token) - url = f"{base_url}/{bot_url}/{file_path}" - - return url - - -def make_request(method_name: str, data: dict) -> dict: - """ - Makes HTTP request to Telegram API. - - :param method_name: Name of API method in URL. - :param data: JSON data to send. - - :returns: Response data from Telegram. - - :raises requests.RequestException: - https://requests.readthedocs.io/en/master/api/#exceptions - :raises TelegramApiException: - See `exceptions.py` for documentation. - """ - timeout = current_app.config.get("TELEGRAM_API_TIMEOUT", 5) - url = create_url(method_name) - response = requests.post( - url, - json=data, - timeout=timeout, - allow_redirects=False, - verify=True - ) - - response.raise_for_status() - - response_data = {} - - try: - response_data = response.json() - except ValueError: - raise InvalidResponseFormatException("Not a JSON response") - - ok = response_data.get("ok") - - if (ok is None): - raise InvalidResponseFormatException('Response without "ok" key') - - if (not ok): - error_code = response_data.get("error_code", "?") - description = response_data.get("description", "?") - message = f"{method_name} failed with {error_code} ({description})" - - raise MethodExecutionFailedException(message) - - return response_data["result"] diff --git a/src/api/telegram/requests.py b/src/api/telegram/requests.py new file mode 100644 index 0000000..e6df05a --- /dev/null +++ b/src/api/telegram/requests.py @@ -0,0 +1,93 @@ +from os import environ + +from flask import current_app + +from src.api.request import ( + create_url, + request +) +from .exceptions import ( + RequestFailed +) + + +def create_bot_url(method_name: str) -> str: + """ + Creates Telegram Bot API URL for request. + + :param method_name: Name of API method in URL. + """ + token = environ["TELEGRAM_API_BOT_TOKEN"] + + return create_url( + "https://api.telegram.org", + f"bot{token}", + method_name + ) + + +def create_file_download_url(file_path: str) -> str: + """ + Creates Telegram URL for downloading of file. + + - contains sensitive information (bot token)! + + :param file_path: `file_path` property of `File` object. + """ + token = environ["TELEGRAM_API_BOT_TOKEN"] + + return create_url( + "https://api.telegram.org/file", + f"bot{token}", + file_path + ) + + +def make_request(method_name: str, data: dict) -> dict: + """ + Makes HTTP request to Telegram Bot API. + + - see `api/request.py` documentation for more. + + :param method_name: Name of API method in URL. + :param data: JSON data to send. + + :raises TelegramBotApiException: + See `telegram/exceptions.py` documentation for more. + """ + url = create_bot_url(method_name) + timeout = current_app.config["TELEGRAM_API_TIMEOUT"] + result = request( + content_type="json", + method="POST", + url=url, + json=data, + timeout=timeout, + allow_redirects=False, + verify=True + ) + + ok = result["content"]["ok"] + + if not ok: + raise RequestFailed( + create_error_text( + result.content + ) + ) + + # https://core.telegram.org/bots/api/#making-requests + result["content"] = result["content"]["result"] + + return result + + +def create_error_text(error_response: dict) -> str: + """ + Creates error text for Telegram Bot error response. + See https://core.telegram.org/bots/api/#making-requests + """ + description = error_response.get("description", "?") + error_code = error_response.get("error_code", "?") + + return f"{error_code} ({description})" diff --git a/src/api/yandex/__init__.py b/src/api/yandex/__init__.py index 146137b..e255de6 100644 --- a/src/api/yandex/__init__.py +++ b/src/api/yandex/__init__.py @@ -1,7 +1,8 @@ from .methods import ( get_access_token, - upload_file_with_url + upload_file_with_url, + create_folder ) -from .request import ( +from .requests import ( make_link_request ) diff --git a/src/api/yandex/exceptions.py b/src/api/yandex/exceptions.py deleted file mode 100644 index a0c5f86..0000000 --- a/src/api/yandex/exceptions.py +++ /dev/null @@ -1,19 +0,0 @@ -class YandexOauthAPIException(Exception): - """ - Expected exception that occurred while handling Yandex Oauth request. - """ - pass - - -class InvalidResponseFormatException(YandexOauthAPIException): - """ - Format of response differ from Yandex Oauth response. - """ - pass - - -class DataConflictException(YandexOauthAPIException): - """ - Response data conflicts with internal operations. - """ - pass diff --git a/src/api/yandex/methods.py b/src/api/yandex/methods.py index 4f0827b..a278d17 100644 --- a/src/api/yandex/methods.py +++ b/src/api/yandex/methods.py @@ -1,21 +1,23 @@ -import requests +from .requests import ( + make_oauth_request, + make_disk_request +) -from .request import make_oauth_request, make_disk_request -from .exceptions import YandexOauthAPIException - -def get_access_token(**kwargs) -> dict: +def get_access_token(**kwargs): """ https://yandex.ru/dev/oauth/doc/dg/reference/auto-code-client-docpage/#auto-code-client__get-token + + - see `api/request.py` documentation for more. """ return make_oauth_request("token", kwargs) -def upload_file_with_url(user_token, **kwargs) -> dict: +def upload_file_with_url(user_token: str, **kwargs): """ - https://yandex.ru/dev/disk/api/reference/upload-ext-docpage/ + https://yandex.ru/dev/disk/api/reference/upload-ext-docpage - - adds `HTTP_STATUS_CODE` key in response data. + - see `api/request.py` documentation for more. """ return make_disk_request( http_method="POST", @@ -27,9 +29,9 @@ def upload_file_with_url(user_token, **kwargs) -> dict: def create_folder(user_token, **kwargs) -> dict: """ - https://yandex.ru/dev/disk/api/reference/create-folder-docpage/ + https://yandex.ru/dev/disk/api/reference/create-folder-docpage - - adds `HTTP_STATUS_CODE` key in response data. + - see `api/request.py` documentation for more. """ return make_disk_request( http_method="PUT", diff --git a/src/api/yandex/request.py b/src/api/yandex/request.py deleted file mode 100644 index f155932..0000000 --- a/src/api/yandex/request.py +++ /dev/null @@ -1,161 +0,0 @@ -import os - -import requests -from requests.auth import AuthBase, HTTPBasicAuth -from flask import current_app - -from .exceptions import ( - InvalidResponseFormatException, - DataConflictException -) - - -class HTTPOAuthAuth(AuthBase): - """ - Attaches HTTP Oauth Authentication to the given Request object. - """ - def __init__(self, token): - self.token = token - - def __call__(self, request): - request.headers["Authorization"] = f"OAuth {self.token}" - - return request - - -def create_oauth_url(method_name: str) -> str: - """ - Creates Yandex OAuth URL for request. - - :param method_name: Name of API method in URL. - """ - base_url = "https://oauth.yandex.ru" - url = f"{base_url}/{method_name}" - - return url - - -def create_disk_url(method_name: str) -> str: - """ - Creates Yandex.Disk URL for request. - - :param method_name: Name of API method in URL. - """ - base_url = "https://cloud-api.yandex.net/v1/disk" - url = f"{base_url}/{method_name}" - - return url - - -def make_oauth_request(method_name: str, data: dict) -> dict: - """ - Makes HTTP request to Yandex OAuth. - - :param method_name: Name of API method in URL. - :param data: Data to send. - - :raises requests.RequestException: - https://requests.readthedocs.io/en/master/api/#exceptions - :raises YandexOauthAPIException: - See `exceptions.py` for documentation. - """ - url = create_oauth_url(method_name) - timeout = current_app.config["YANDEX_OAUTH_API_TIMEOUT"] - id = os.getenv("YANDEX_OAUTH_API_APP_ID", "") - password = os.getenv("YANDEX_OAUTH_API_APP_PASSWORD", "") - response = requests.post( - url, - data=data, - timeout=timeout, - auth=HTTPBasicAuth(id, password), - allow_redirects=False, - verify=True - ) - - response.raise_for_status() - - response_data = {} - - try: - response_data = response.json() - except ValueError: - raise InvalidResponseFormatException("Not a JSON response") - - return response_data - - -def make_disk_request( - http_method: str, - api_method: str, - data: dict, - token: str -) -> dict: - """ - Makes HTTP request to Yandex.Disk. - - - it will not raise if status is not 2xx! - - adds `HTTP_STATUS_CODE` key in response data. - - :param http_method: Name of HTTP method for request. - :param api_method: Name of API method in URL. - :param data: JSON data to send. - :param token: OAuth token to access the API. - - :raises requests.RequestException: - https://requests.readthedocs.io/en/master/api/#exceptions - :raises YandexOauthAPIException: - See `exceptions.py` for documentation. - """ - url = create_disk_url(api_method) - timeout = current_app.config["YANDEX_DISK_API_TIMEOUT"] - response = requests.request( - http_method.upper(), - url, - params=data, - timeout=timeout, - auth=HTTPOAuthAuth(token), - allow_redirects=False, - verify=True - ) - response_data = {} - http_code_key = "HTTP_STATUS_CODE" - - try: - response_data = response.json() - except ValueError: - raise InvalidResponseFormatException("Not a JSON response") - - if (http_code_key in response_data): - raise DataConflictException("Special key for HTTP Code already exists") - - response_data[http_code_key] = response.status_code - - return response_data - - -def make_link_request(data: dict, user_token: str) -> dict: - """ - https://yandex.ru/dev/disk/api/reference/response-objects-docpage/#link - """ - if (data["templated"]): - raise NotImplementedError("Templating not implemented") - - timeout = current_app.config["YANDEX_DISK_API_TIMEOUT"] - url = data["href"] - method = data["method"].upper() - response = requests.request( - method, - url, - timeout=timeout, - auth=HTTPOAuthAuth(user_token), - allow_redirects=False, - verify=True - ) - response_data = {} - - try: - response_data = response.json() - except ValueError: - raise InvalidResponseFormatException("Not a JSON response") - - return response_data diff --git a/src/api/yandex/requests.py b/src/api/yandex/requests.py new file mode 100644 index 0000000..7d76fe5 --- /dev/null +++ b/src/api/yandex/requests.py @@ -0,0 +1,124 @@ +from os import environ + +from requests.auth import HTTPBasicAuth +from flask import current_app + +from src.api.request import ( + create_url, + request +) +from src.api.auth import HTTPOAuthAuth + + +def create_oauth_url(method_name: str) -> str: + """ + Creates Yandex OAuth URL for request. + + :param method_name: Name of API method in URL. + """ + return create_url( + "https://oauth.yandex.ru", + method_name + ) + + +def create_disk_url(method_name: str) -> str: + """ + Creates Yandex.Disk URL for request. + + :param method_name: Name of API method in URL. + """ + return create_url( + "https://cloud-api.yandex.net/v1/disk", + method_name + ) + + +def make_oauth_request(method_name: str, data: dict): + """ + Makes HTTP request to Yandex OAuth. + + - see `api/request.py` documentation for more. + + :param method_name: Name of API method in URL. + :param data: Data to send. + """ + url = create_oauth_url(method_name) + timeout = current_app.config["YANDEX_OAUTH_API_TIMEOUT"] + id = environ["YANDEX_OAUTH_API_APP_ID"] + password = environ["YANDEX_OAUTH_API_APP_PASSWORD"] + + return request( + content_type="json", + method="POST", + url=url, + data=data, + timeout=timeout, + auth=HTTPBasicAuth(id, password), + allow_redirects=False, + verify=True + ) + + +def make_disk_request( + http_method: str, + api_method: str, + data: dict, + token: str +): + """ + Makes HTTP request to Yandex.Disk. + + - it will not raise in case of error HTTP code. + - see `api/request.py` documentation for more. + + :param http_method: Name of HTTP method for request. + :param api_method: Name of API method in URL. + :param data: JSON data to send. + :param token: OAuth token to access the API. + """ + url = create_disk_url(api_method) + timeout = current_app.config["YANDEX_DISK_API_TIMEOUT"] + + return request( + raise_for_status=False, + content_type="json", + method=http_method.upper(), + url=url, + params=data, + timeout=timeout, + auth=HTTPOAuthAuth(token), + allow_redirects=False, + verify=True + ) + + +def make_link_request(data: dict, token: str): + """ + https://yandex.ru/dev/disk/api/reference/response-objects-docpage/#link + + - it will not raise in case of error HTTP code. + - see `api/request.py` documentation for more. + + :param data: Data of link to handle. + :param token: OAuth token to access the API. + + :raises NotImplementedError: If link requires templating. + """ + if (data["templated"]): + raise NotImplementedError("Templating not implemented") + + url = data["href"] + method = data["method"].upper() + timeout = current_app.config["YANDEX_DISK_API_TIMEOUT"] + + return request( + raise_for_status=False, + content_type="json", + method=method, + url=url, + timeout=timeout, + auth=HTTPOAuthAuth(token), + allow_redirects=False, + verify=True + ) diff --git a/src/blueprints/telegram_bot/webhook/commands/common/api.py b/src/blueprints/telegram_bot/webhook/commands/common/api.py index 8981674..6ee8838 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/api.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/api.py @@ -85,7 +85,7 @@ def create_folder(access_token: str, folder_name: str) -> int: except Exception as e: raise YandexAPIRequestError(e) - last_status_code = response["HTTP_STATUS_CODE"] + last_status_code = response["status_code"] if ( (last_status_code == 201) or @@ -139,7 +139,7 @@ def upload_file_with_url( access_token, url=download_url, path=full_path - ) + )["content"] except Exception as e: raise YandexAPIRequestError(e) @@ -172,7 +172,7 @@ def upload_file_with_url( result = yandex.make_link_request( data=operation_status_link, user_token=access_token - ) + )["content"] except Exception as e: raise YandexAPIRequestError(e) diff --git a/src/blueprints/telegram_bot/webhook/commands/create_folder.py b/src/blueprints/telegram_bot/webhook/commands/create_folder.py index ece234b..8dc80b5 100644 --- a/src/blueprints/telegram_bot/webhook/commands/create_folder.py +++ b/src/blueprints/telegram_bot/webhook/commands/create_folder.py @@ -47,11 +47,13 @@ def handle(): if hasattr(e, "message"): error_text = e.message - return telegram.send_message( + telegram.send_message( chat_id=chat.telegram_id, text=error_text ) + return + text = None if (last_status_code == 201): diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index e06be01..0fc3c6a 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -107,7 +107,7 @@ def upload(self) -> None: try: file = telegram.get_file( file_id=attachment["file_id"] - ) + )["content"] except Exception as error: print(error) return cancel_command(chat.telegram_id) @@ -139,31 +139,37 @@ def long_task(): "due to an unknown Yandex error." ) - return telegram.send_message( + telegram.send_message( chat_id=chat.telegram_id, text=error_text ) + + return except YandexAPIUploadFileError as error: error_text = str(error) or ( "I can't upload this due to an unknown Yandex error." ) - return telegram.send_message( + telegram.send_message( chat_id=chat.telegram_id, reply_to_message_id=message["message_id"], text=error_text ) + + return except YandexAPIExceededNumberOfStatusChecksError: error_text = ( "I can't track operation status of this anymore. " "Perform manual checking." ) - return telegram.send_message( + telegram.send_message( chat_id=chat.telegram_id, reply_to_message_id=message["message_id"], text=error_text ) + + return except (YandexAPIRequestError, Exception) as error: print(error) return cancel_command(chat.telegram_id) diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index ee4b1da..477c1fa 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -185,7 +185,7 @@ def refresh_access_token(yd_token) -> bool: yandex_response = yandex.get_access_token( grant_type="refresh_token", refresh_token=refresh_token - ) + )["content"] except Exception as e: print(e) return False diff --git a/src/blueprints/telegram_bot/yandex_disk_auth/views.py b/src/blueprints/telegram_bot/yandex_disk_auth/views.py index b16f6f5..682a0be 100644 --- a/src/blueprints/telegram_bot/yandex_disk_auth/views.py +++ b/src/blueprints/telegram_bot/yandex_disk_auth/views.py @@ -189,7 +189,7 @@ def handle_success(): yandex_response = yandex.get_access_token( grant_type="authorization_code", code=code - ) + )["content"] except Exception: return render_template( TEMPLATES["error"], From d698480b829f99c59aacc86f57599f8f1b2e5665 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 23 Apr 2020 18:26:17 +0300 Subject: [PATCH 31/75] Refactoring of API module --- src/api/yandex/methods.py | 6 +++--- src/api/yandex/requests.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/api/yandex/methods.py b/src/api/yandex/methods.py index a278d17..e1ce723 100644 --- a/src/api/yandex/methods.py +++ b/src/api/yandex/methods.py @@ -23,11 +23,11 @@ def upload_file_with_url(user_token: str, **kwargs): http_method="POST", api_method="resources/upload", data=kwargs, - token=user_token + user_token=user_token ) -def create_folder(user_token, **kwargs) -> dict: +def create_folder(user_token: str, **kwargs): """ https://yandex.ru/dev/disk/api/reference/create-folder-docpage @@ -37,5 +37,5 @@ def create_folder(user_token, **kwargs) -> dict: http_method="PUT", api_method="resources", data=kwargs, - token=user_token + user_token=user_token ) diff --git a/src/api/yandex/requests.py b/src/api/yandex/requests.py index 7d76fe5..c332b1b 100644 --- a/src/api/yandex/requests.py +++ b/src/api/yandex/requests.py @@ -64,7 +64,7 @@ def make_disk_request( http_method: str, api_method: str, data: dict, - token: str + user_token: str ): """ Makes HTTP request to Yandex.Disk. @@ -75,7 +75,7 @@ def make_disk_request( :param http_method: Name of HTTP method for request. :param api_method: Name of API method in URL. :param data: JSON data to send. - :param token: OAuth token to access the API. + :param user_token: User OAuth token to access the API. """ url = create_disk_url(api_method) timeout = current_app.config["YANDEX_DISK_API_TIMEOUT"] @@ -87,13 +87,13 @@ def make_disk_request( url=url, params=data, timeout=timeout, - auth=HTTPOAuthAuth(token), + auth=HTTPOAuthAuth(user_token), allow_redirects=False, verify=True ) -def make_link_request(data: dict, token: str): +def make_link_request(data: dict, user_token: str): """ https://yandex.ru/dev/disk/api/reference/response-objects-docpage/#link @@ -101,7 +101,7 @@ def make_link_request(data: dict, token: str): - see `api/request.py` documentation for more. :param data: Data of link to handle. - :param token: OAuth token to access the API. + :param user_token: User OAuth token to access the API. :raises NotImplementedError: If link requires templating. """ @@ -118,7 +118,7 @@ def make_link_request(data: dict, token: str): method=method, url=url, timeout=timeout, - auth=HTTPOAuthAuth(token), + auth=HTTPOAuthAuth(user_token), allow_redirects=False, verify=True ) From 1afb0c78fd015c28d940a94fe5de860f9e4d4bfe Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 23 Apr 2020 18:53:38 +0300 Subject: [PATCH 32/75] Refactoring of Flask config --- src/configs/flask.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/configs/flask.py b/src/configs/flask.py index d690004..f8bd26d 100644 --- a/src/configs/flask.py +++ b/src/configs/flask.py @@ -12,17 +12,17 @@ class Config: """ - Notes for configuration: - - keep in mind that Heroku have 30 seconds as request timeout. - So, if your configuration can exceed 30 seconds, then request - will be terminated by Heroku. + Notes: + - keep in mind that Heroku have 30 seconds request timeout. + So, if your configuration value can exceed 30 seconds, then + request will be terminated by Heroku. """ # Flask DEBUG = False TESTING = False SECRET_KEY = os.getenv("FLASK_SECRET_KEY") - # SQLAlchemy + # Flask SQLAlchemy SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL") SQLALCHEMY_TRACK_MODIFICATIONS = False @@ -31,10 +31,10 @@ class Config: # after a given number of seconds TELEGRAM_API_TIMEOUT = 5 - # Yandex Oauth API + # Yandex OAuth API # stop waiting for a Yandex response # after a given number of seconds - YANDEX_OAUTH_API_TIMEOUT = 30 + YANDEX_OAUTH_API_TIMEOUT = 15 # Yandex.Disk API # stop waiting for a Yandex response @@ -54,8 +54,8 @@ class Config: YANDEX_DISK_API_CHECK_OPERATION_STATUS_MAX_ATTEMPTS = 5 # interval in seconds between checks of operation status. # It is blocks request until check ending! - # So, if max. attempts is 5 and interval is 10, - # then request will be blocked maxium for (5 * 10) seconds. + # For example, if max. attempts is 5 and interval is 2, + # then request will be blocked maximum for (5 * 2) seconds. YANDEX_DISK_API_CHECK_OPERATION_STATUS_INTERVAL = 2 # in this folder files will be uploaded by default # if user not specified custom folder. @@ -63,7 +63,7 @@ class Config: # Project # name of app that will be used in HTML and so on - PROJECT_APP_NAME = "Yandex.Disk Bot" + PROJECT_APP_NAME = "Yandex.Disk Telegram Bot" class ProductionConfig(Config): From b05424dbe67445736fe71ed0404d96a99b1d7a44 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 23 Apr 2020 18:53:44 +0300 Subject: [PATCH 33/75] Update changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23b98bd..3399bcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ ### Changed -- Decrease size of slug on Heroku by removing unused files and folders. +- Decreased size of slug on Heroku by removing unused files and folders. +- Decreased number of seconds for Yandex.OAuth request timeout. # 1.0.0 (April 15, 2020) From 1437061e0c43fe4c1a98be23f6ccb8ebced2bffd Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 23 Apr 2020 19:27:03 +0300 Subject: [PATCH 34/75] Refactoring of nginx config --- src/configs/nginx.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/configs/nginx.conf b/src/configs/nginx.conf index bbadf67..a4953cc 100644 --- a/src/configs/nginx.conf +++ b/src/configs/nginx.conf @@ -30,8 +30,8 @@ http { keepalive_timeout 15s; location /static/ { - root src; - autoindex off; + root src; + autoindex off; } location / { From f7b1ab736ca986d7af90d0356fc9dd491bcf3cb9 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 23 Apr 2020 19:39:17 +0300 Subject: [PATCH 35/75] Rename db folder to database --- manage.py | 2 +- src/app.py | 2 +- .../telegram_bot/webhook/commands/common/decorators.py | 4 ++-- src/blueprints/telegram_bot/webhook/commands/yd_auth.py | 2 +- src/blueprints/telegram_bot/webhook/commands/yd_revoke.py | 2 +- src/blueprints/telegram_bot/yandex_disk_auth/views.py | 2 +- src/{db => database}/__init__.py | 0 src/{db => database}/database.py | 0 src/{db => database}/migrate.py | 0 src/{db => database}/models/__init__.py | 0 src/{db => database}/models/chat.py | 2 +- src/{db => database}/models/user.py | 2 +- src/{db => database}/models/yandex_disk_token.py | 2 +- src/{db => database}/queries/__init__.py | 0 src/{db => database}/queries/chat.py | 4 ++-- src/{db => database}/queries/user.py | 2 +- src/{db => database}/queries/yandex_disk_token.py | 2 +- 17 files changed, 14 insertions(+), 14 deletions(-) rename src/{db => database}/__init__.py (100%) rename src/{db => database}/database.py (100%) rename src/{db => database}/migrate.py (100%) rename src/{db => database}/models/__init__.py (100%) rename src/{db => database}/models/chat.py (98%) rename src/{db => database}/models/user.py (99%) rename src/{db => database}/models/yandex_disk_token.py (99%) rename src/{db => database}/queries/__init__.py (100%) rename src/{db => database}/queries/chat.py (91%) rename src/{db => database}/queries/user.py (97%) rename src/{db => database}/queries/yandex_disk_token.py (84%) diff --git a/manage.py b/manage.py index 692aab7..79e5a96 100644 --- a/manage.py +++ b/manage.py @@ -4,7 +4,7 @@ from sqlalchemy.exc import IntegrityError from src.app import create_app -from src.db import ( +from src.database import ( db, User, Chat, diff --git a/src/app.py b/src/app.py index 014e45a..2d900ad 100644 --- a/src/app.py +++ b/src/app.py @@ -11,7 +11,7 @@ ) from .configs import flask_config -from .db import db, migrate +from .database import db, migrate from .blueprints import telegram_bot_blueprint diff --git a/src/blueprints/telegram_bot/webhook/commands/common/decorators.py b/src/blueprints/telegram_bot/webhook/commands/common/decorators.py index 11f5032..d22f4a4 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/decorators.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/decorators.py @@ -2,14 +2,14 @@ from flask import g -from src.db import ( +from src.database import ( db, User, UserQuery, Chat, ChatQuery ) -from src.db.models import ( +from src.database.models import ( ChatType ) from .responses import cancel_command diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index 477c1fa..cc3850c 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -6,7 +6,7 @@ from flask import g, current_app import jwt -from src.db import ( +from src.database import ( db, YandexDiskToken ) diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py index 7941352..a8aeda6 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py @@ -2,7 +2,7 @@ from flask import g -from src.db import db +from src.database import db from src.api import telegram from .common.decorators import get_db_data from .common.names import CommandNames diff --git a/src/blueprints/telegram_bot/yandex_disk_auth/views.py b/src/blueprints/telegram_bot/yandex_disk_auth/views.py index 682a0be..e9a71b0 100644 --- a/src/blueprints/telegram_bot/yandex_disk_auth/views.py +++ b/src/blueprints/telegram_bot/yandex_disk_auth/views.py @@ -9,7 +9,7 @@ ) import jwt -from src.db import db, UserQuery, ChatQuery +from src.database import db, UserQuery, ChatQuery from src.api import yandex, telegram from src.blueprints.telegram_bot import telegram_bot_blueprint as bp diff --git a/src/db/__init__.py b/src/database/__init__.py similarity index 100% rename from src/db/__init__.py rename to src/database/__init__.py diff --git a/src/db/database.py b/src/database/database.py similarity index 100% rename from src/db/database.py rename to src/database/database.py diff --git a/src/db/migrate.py b/src/database/migrate.py similarity index 100% rename from src/db/migrate.py rename to src/database/migrate.py diff --git a/src/db/models/__init__.py b/src/database/models/__init__.py similarity index 100% rename from src/db/models/__init__.py rename to src/database/models/__init__.py diff --git a/src/db/models/chat.py b/src/database/models/chat.py similarity index 98% rename from src/db/models/chat.py rename to src/database/models/chat.py index e733e49..902ce7e 100644 --- a/src/db/models/chat.py +++ b/src/database/models/chat.py @@ -1,6 +1,6 @@ from enum import IntEnum, unique -from src.db import db +from src.database import db @unique diff --git a/src/db/models/user.py b/src/database/models/user.py similarity index 99% rename from src/db/models/user.py rename to src/database/models/user.py index 630b2d3..8e3983a 100644 --- a/src/db/models/user.py +++ b/src/database/models/user.py @@ -3,7 +3,7 @@ from sqlalchemy.sql import func from src.localization import SupportedLanguages -from src.db import db +from src.database import db @unique diff --git a/src/db/models/yandex_disk_token.py b/src/database/models/yandex_disk_token.py similarity index 99% rename from src/db/models/yandex_disk_token.py rename to src/database/models/yandex_disk_token.py index 7fd46b8..d61e215 100644 --- a/src/db/models/yandex_disk_token.py +++ b/src/database/models/yandex_disk_token.py @@ -9,7 +9,7 @@ InvalidToken as InvalidTokenFernetError ) -from src.db import db +from src.database import db class YandexDiskToken(db.Model): diff --git a/src/db/queries/__init__.py b/src/database/queries/__init__.py similarity index 100% rename from src/db/queries/__init__.py rename to src/database/queries/__init__.py diff --git a/src/db/queries/chat.py b/src/database/queries/chat.py similarity index 91% rename from src/db/queries/chat.py rename to src/database/queries/chat.py index a6fd14a..6533a51 100644 --- a/src/db/queries/chat.py +++ b/src/database/queries/chat.py @@ -1,7 +1,7 @@ from typing import List, Union, NewType -from src.db import Chat -from src.db.models import ChatType +from src.database import Chat +from src.database.models import ChatType ChatOrNone = NewType("ChatOrNone", Union[Chat, None]) diff --git a/src/db/queries/user.py b/src/database/queries/user.py similarity index 97% rename from src/db/queries/user.py rename to src/database/queries/user.py index 36fbd24..3e73c88 100644 --- a/src/db/queries/user.py +++ b/src/database/queries/user.py @@ -2,7 +2,7 @@ from sqlalchemy.sql.expression import func -from src.db import User, YandexDiskToken +from src.database import User, YandexDiskToken UserOrNone = NewType("UserOrNone", Union[User, None]) diff --git a/src/db/queries/yandex_disk_token.py b/src/database/queries/yandex_disk_token.py similarity index 84% rename from src/db/queries/yandex_disk_token.py rename to src/database/queries/yandex_disk_token.py index 83c6736..53a3f19 100644 --- a/src/db/queries/yandex_disk_token.py +++ b/src/database/queries/yandex_disk_token.py @@ -1,4 +1,4 @@ -from src.db import YandexDiskToken +from src.database import YandexDiskToken def delete_all_yd_tokens() -> int: From 636770b293a6c0e82b6fbe4caf944308594151ce Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 23 Apr 2020 22:31:09 +0300 Subject: [PATCH 36/75] Refactoring of database module --- ...add_banned_as_group_type_for_user_model.py | 34 +++++++++++++++ src/database/migrate.py | 6 +-- src/database/models/__init__.py | 7 +++ src/database/models/chat.py | 4 +- src/database/models/user.py | 9 ++-- src/database/models/yandex_disk_token.py | 43 +++++++++++-------- src/database/queries/chat.py | 2 +- src/database/queries/user.py | 4 +- src/database/queries/yandex_disk_token.py | 2 +- 9 files changed, 79 insertions(+), 32 deletions(-) create mode 100644 migrations/versions/67ffbddd6efe_add_banned_as_group_type_for_user_model.py diff --git a/migrations/versions/67ffbddd6efe_add_banned_as_group_type_for_user_model.py b/migrations/versions/67ffbddd6efe_add_banned_as_group_type_for_user_model.py new file mode 100644 index 0000000..813a4a1 --- /dev/null +++ b/migrations/versions/67ffbddd6efe_add_banned_as_group_type_for_user_model.py @@ -0,0 +1,34 @@ +"""Add BANNED as group type for user model + +Revision ID: 67ffbddd6efe +Revises: 375f2ceebbe2 +Create Date: 2020-04-23 22:17:46.495174 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '67ffbddd6efe' +down_revision = '375f2ceebbe2' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("group", + existing_type=sa.Enum("INVALID", "USER", "TESTER", "ADMIN", name="usergroup"), + type_=sa.Enum("INVALID", "BANNED", "USER", "TESTER", "ADMIN", name="usergroup"), + existing_nullable=False + ) + + +def downgrade(): + with op.batch_alter_table("users") as batch_op: + batch_op.alter_column("group", + existing_type=sa.Enum("INVALID", "BANNED", "USER", "TESTER", "ADMIN", name="usergroup"), + type_=sa.Enum("INVALID", "USER", "TESTER", "ADMIN", name="usergroup"), + existing_nullable=False + ) diff --git a/src/database/migrate.py b/src/database/migrate.py index 2879424..111e719 100644 --- a/src/database/migrate.py +++ b/src/database/migrate.py @@ -1,11 +1,7 @@ from flask_migrate import Migrate # we need to import every model in order Migrate knows them -from .models import ( - User, - Chat, - YandexDiskToken -) +from .models import * # noqa migrate = Migrate( diff --git a/src/database/models/__init__.py b/src/database/models/__init__.py index 8f7c808..fdae210 100644 --- a/src/database/models/__init__.py +++ b/src/database/models/__init__.py @@ -11,3 +11,10 @@ DataCorruptedError, InvalidTokenError ) + + +__all__ = [ + "User", + "Chat", + "YandexDiskToken" +] diff --git a/src/database/models/chat.py b/src/database/models/chat.py index 902ce7e..cc8350c 100644 --- a/src/database/models/chat.py +++ b/src/database/models/chat.py @@ -15,9 +15,9 @@ class ChatType(IntEnum): CHANNEL = 4 @staticmethod - def get(type_name: str): + def get(type_name: str) -> int: """ - Return type by name. + Return type by Telegram name. """ type_name = type_name.lower() types = { diff --git a/src/database/models/user.py b/src/database/models/user.py index 8e3983a..e1f7e8f 100644 --- a/src/database/models/user.py +++ b/src/database/models/user.py @@ -2,8 +2,8 @@ from sqlalchemy.sql import func -from src.localization import SupportedLanguages from src.database import db +from src.localization import SupportedLanguages @unique @@ -15,9 +15,10 @@ class UserGroup(IntEnum): i.e., greater number means higher access (more rights). """ INVALID = 0 - USER = 1 - TESTER = 2 - ADMIN = 3 + BANNED = 1 + USER = 2 + TESTER = 3 + ADMIN = 4 class User(db.Model): diff --git a/src/database/models/yandex_disk_token.py b/src/database/models/yandex_disk_token.py index d61e215..8f4cc43 100644 --- a/src/database/models/yandex_disk_token.py +++ b/src/database/models/yandex_disk_token.py @@ -1,4 +1,3 @@ -import os from typing import Union from flask import current_app @@ -101,7 +100,7 @@ def __setitem__(self, key, value): return setattr(self, key, value) @staticmethod - def create_fake(user): + def create_fake(user) -> dict: """ Creates fake Yandex.Disk token. @@ -212,8 +211,10 @@ def get_access_token(self) -> Union[str, None]: """ Returns decrypted access token. - :raises DataCorruptedError: Data in DB is corrupted. - :raises InvalidTokenError: Encrypted token is invalid or expired. + :raises DataCorruptedError: + Data in DB is corrupted. + :raises InvalidTokenError: + Encrypted token is invalid or expired. """ return self._get_token( token_attribute_name="_access_token", @@ -233,8 +234,10 @@ def get_refresh_token(self) -> Union[str, None]: """ Returns decrypted refresh token. - :raises DataCorruptedError: Data in DB is corrupted. - :raises InvalidTokenError: Encrypted token is invalid. + :raises DataCorruptedError: + Data in DB is corrupted. + :raises InvalidTokenError: + Encrypted token is invalid. """ return self._get_token( token_attribute_name="_refresh_token" @@ -253,8 +256,10 @@ def get_insert_token(self) -> Union[str, None]: """ Returns decrypted insert token. - :raises DataCorruptedError: Data in DB is corrupted. - :raises InvalidTokenError: Encrypted token is invalid or expired. + :raises DataCorruptedError: + Data in DB is corrupted. + :raises InvalidTokenError: + Encrypted token is invalid or expired. """ return self._get_token( token_attribute_name="_insert_token", @@ -292,7 +297,7 @@ def clear_access_token(self) -> None: """ Clears all data that belongs to access token. - Perform a commit in order to save changes! + - perform a commit in order to save changes! """ self.access_token_type = null() @@ -305,7 +310,7 @@ def clear_refresh_token(self) -> None: """ Clears all data that belongs to refresh token. - Perform a commit in order to save changes! + - perform a commit in order to save changes! """ return self._clear_token( token_attribute_name="_refresh_token" @@ -315,7 +320,7 @@ def clear_insert_token(self) -> None: """ Clears all data that belongs to insert token. - Perform a commit in order to save changes! + - perform a commit in order to save changes! """ return self._clear_token( token_attribute_name="_insert_token", @@ -326,7 +331,7 @@ def clear_all_tokens(self) -> None: """ Clears all data that belongs to any kind of token. - Perform a commit in order to save changes! + - perform a commit in order to save changes! """ self.clear_access_token() self.clear_refresh_token() @@ -336,7 +341,8 @@ def _set_token(self, **kwargs) -> None: """ Sets encrypted token. - :param token_attribute_name: Name of token attribute in class. + :param token_attribute_name: + Name of token attribute in class. :param value: Value to set. """ fernet = Fernet(current_app.secret_key.encode()) @@ -394,9 +400,11 @@ def _get_token(self, **kwargs) -> Union[str, None]: def _have_token(self, **kwargs) -> bool: """ - :param token_attribute_name: Name of token attribute in class. + :param token_attribute_name: + Name of token attribute in class. - :returns: `True` if token contains any value, `False` otherwise. + :returns: + `True` if token contains any value, `False` otherwise. """ token_attribute_name = kwargs["token_attribute_name"] value = self[token_attribute_name] @@ -410,9 +418,10 @@ def _clear_token(self, **kwargs) -> None: """ Clears token data. - Perform a commit in order to save changes! + - perform a commit in order to save changes! - :param token_attribute_name: Name of token attribute in class. + :param token_attribute_name: + Name of token attribute in class. :param expires_attribute_name: Optional. Token lifetime in seconds. If specified, expiration date will be cleared. diff --git a/src/database/queries/chat.py b/src/database/queries/chat.py index 6533a51..40058de 100644 --- a/src/database/queries/chat.py +++ b/src/database/queries/chat.py @@ -11,7 +11,7 @@ def delete_all_chats() -> int: """ Deletes all chats from a table. - You have to commit DB changes! + - you have to commit DB changes! :returns: Count of deleted chats. """ diff --git a/src/database/queries/user.py b/src/database/queries/user.py index 3e73c88..8c864eb 100644 --- a/src/database/queries/user.py +++ b/src/database/queries/user.py @@ -38,7 +38,7 @@ def get_user_by_telegram_id(telegram_id: int) -> UserOrNone: def exists(telegram_id: int) -> bool: """ - Checks if user is exists in DB. + Checks if user exists in DB. """ return (get_user_by_telegram_id(telegram_id) is not None) @@ -88,7 +88,7 @@ def delete_all_users() -> int: """ Deletes all users from a table. - You have to commit DB changes! + - you have to commit DB changes! :returns: Count of deleted users. """ diff --git a/src/database/queries/yandex_disk_token.py b/src/database/queries/yandex_disk_token.py index 53a3f19..504319d 100644 --- a/src/database/queries/yandex_disk_token.py +++ b/src/database/queries/yandex_disk_token.py @@ -5,7 +5,7 @@ def delete_all_yd_tokens() -> int: """ Deletes all Y.D. tokens from a table. - You have to commit DB changes! + - you have to commit DB changes! :returns: Count of deleted Y.D. tokens. """ From d4a2ba0ed8c48e289934c55cf55007a057f88e63 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 26 Apr 2020 00:16:36 +0300 Subject: [PATCH 37/75] Add privacy policy and terms conditions --- CHANGELOG.md | 1 + src/app.py | 9 ++++- src/blueprints/__init__.py | 1 + src/blueprints/legal/__init__.py | 2 + src/blueprints/legal/bp.py | 7 ++++ src/blueprints/legal/views.py | 23 +++++++++++ src/static/legal/privacy_policy.txt | 48 +++++++++++++++++++++++ src/static/legal/terms_and_conditions.txt | 29 ++++++++++++++ 8 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/blueprints/legal/__init__.py create mode 100644 src/blueprints/legal/bp.py create mode 100644 src/blueprints/legal/views.py create mode 100644 src/static/legal/privacy_policy.txt create mode 100644 src/static/legal/terms_and_conditions.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 3399bcb..b2bb79d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Privacy Policy and Terms & Conditions. - Favicons for different platforms. ### Changed diff --git a/src/app.py b/src/app.py index 2d900ad..3a47dbe 100644 --- a/src/app.py +++ b/src/app.py @@ -12,7 +12,10 @@ from .configs import flask_config from .database import db, migrate -from .blueprints import telegram_bot_blueprint +from .blueprints import ( + telegram_bot_blueprint, + legal_blueprint +) def create_app(config_name: str = None) -> Flask: @@ -57,6 +60,10 @@ def configure_blueprints(app: Flask) -> None: telegram_bot_blueprint, url_prefix="/telegram_bot" ) + app.register_blueprint( + legal_blueprint, + url_prefix="/legal" + ) def configure_redirects(app: Flask) -> None: diff --git a/src/blueprints/__init__.py b/src/blueprints/__init__.py index c87f6c5..ae78a10 100644 --- a/src/blueprints/__init__.py +++ b/src/blueprints/__init__.py @@ -1 +1,2 @@ from .telegram_bot import telegram_bot_blueprint +from .legal import legal_blueprint diff --git a/src/blueprints/legal/__init__.py b/src/blueprints/legal/__init__.py new file mode 100644 index 0000000..9471fd5 --- /dev/null +++ b/src/blueprints/legal/__init__.py @@ -0,0 +1,2 @@ +from .bp import bp as legal_blueprint +from . import views diff --git a/src/blueprints/legal/bp.py b/src/blueprints/legal/bp.py new file mode 100644 index 0000000..b34d7df --- /dev/null +++ b/src/blueprints/legal/bp.py @@ -0,0 +1,7 @@ +from flask import Blueprint + + +bp = Blueprint( + "legal", + __name__ +) diff --git a/src/blueprints/legal/views.py b/src/blueprints/legal/views.py new file mode 100644 index 0000000..4f0d171 --- /dev/null +++ b/src/blueprints/legal/views.py @@ -0,0 +1,23 @@ +from flask import redirect, url_for + +from src.blueprints.legal import legal_blueprint as bp + + +@bp.route("/privacy-policy") +def privacy_policy(): + return redirect( + url_for( + "static", + filename="legal/privacy_policy.txt" + ) + ) + + +@bp.route("/terms-and-conditions") +def terms_and_conditions(): + return redirect( + url_for( + "static", + filename="legal/terms_and_conditions.txt" + ) + ) diff --git a/src/static/legal/privacy_policy.txt b/src/static/legal/privacy_policy.txt new file mode 100644 index 0000000..9431da8 --- /dev/null +++ b/src/static/legal/privacy_policy.txt @@ -0,0 +1,48 @@ +Privacy Policy + +Sergey Kuznetsov built the "Yandex.Disk Telegram Bot" app as a Free app. This SERVICE is provided by Sergey Kuznetsov at no cost and is intended for use AS IS. This service available in Telegram only under "@Ya_Disk_Bot" username. + +This page is used to inform visitors regarding my policies with the collection, use, and disclosure of Personal Information if anyone decided to use my Service. + +If you choose to use my Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that I collect is used for providing and improving the Service. I will not use or share your information with anyone except as described in this Privacy Policy. + +The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible at Yandex.Disk Telegram Bot unless otherwise defined in this Privacy Policy. + + +Information Collection and Use + +For a better experience, while using our Service, I may require you to provide us with certain personally identifiable information, including but not limited to Telegram User ID, Telegram Chat ID, Yandex.Disk Access Token, Yandex.Disk Refresh Token. The information that I request will be retained on my server which is located in Europe. Some information, including but not limited to Yandex.Disk Access Token, Yandex.Disk Refresh Token, will be retained in encrypted form. + + +Cookies + +Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory. + +This Service does not use these “cookies” explicitly. However, the app may use third party code and libraries that use “cookies” to collect information and improve their services. You have the option to either accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may not be able to use some portions of this Service. + + +Security + +I value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee its absolute security. + + +Links to Other Sites + +This Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by me. Therefore, I strongly advise you to review the Privacy Policy of these websites. I have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. + + +Children’s Privacy + +These Services do not address anyone under the age of 13. I do not knowingly collect personally identifiable information from children under 13. In the case I discover that a child under 13 has provided me with personal information, I immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact me so that I will be able to do necessary actions. + + +Changes to This Privacy Policy + +I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page. + +This policy is effective as of 25.04.2020 + + +Contact Us + +If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me at amatsukine@gmail.com diff --git a/src/static/legal/terms_and_conditions.txt b/src/static/legal/terms_and_conditions.txt new file mode 100644 index 0000000..1b9d4c9 --- /dev/null +++ b/src/static/legal/terms_and_conditions.txt @@ -0,0 +1,29 @@ +Terms & Conditions + +By using the app, these terms will automatically apply to you – you should make sure therefore that you read them carefully before using the app. + +Sergey Kuznetsov is committed to ensuring that the app is as useful and efficient as possible. For that reason, we reserve the right to make changes to the app, at any time and for any reason. + +The "Yandex.Disk Telegram Bot" app stores and processes personal data that you have provided to us, in order to provide my Service. It’s your responsibility to keep your phone and access to the app secure. + +You should be aware that there are certain things that Sergey Kuznetsov will not take responsibility for. Certain functions of the app will require the app to have an active internet connection. The connection can be Wi-Fi, or provided by your mobile network provider, but Sergey Kuznetsov cannot take responsibility for the app not working at full functionality if you don’t have access to Wi-Fi, and you don’t have any of your data allowance left. + +If you’re using the app outside of an area with Wi-Fi, you should remember that your terms of the agreement with your mobile network provider will still apply. As a result, you may be charged by your mobile provider for the cost of data for the duration of the connection while accessing the app, or other third party charges. In using the app, you’re accepting responsibility for any such charges, including roaming data charges if you use the app outside of your home territory (i.e. region or country) without turning off data roaming. If you are not the bill payer for the device on which you’re using the app, please be aware that we assume that you have received permission from the bill payer for using the app. + +Along the same lines, Sergey Kuznetsov cannot always take responsibility for the way you use the app i.e. You need to make sure that your device stays charged – if it runs out of battery and you can’t turn it on to avail the Service, Sergey Kuznetsov cannot accept responsibility. + +With respect to Sergey Kuznetsov’s responsibility for your use of the app, when you’re using the app, it’s important to bear in mind that although we endeavour to ensure that it is updated and correct at all times, we do rely on third parties to provide information to us so that we can make it available to you. Sergey Kuznetsov accepts no liability for any loss, direct or indirect, you experience as a result of relying wholly on this functionality of the app. + +At some point, we may wish to update the app. The app is currently available on Telegram. Sergey Kuznetsov does not promise that it will always update the app. However, you promise to always accept updates to the application when offered to you, We may also wish to stop providing the app, and may terminate use of it at any time without giving notice of termination to you. Unless we tell you otherwise, upon any termination, (a) the rights and licenses granted to you in these terms will end; (b) you must stop using the app, and (if needed) delete it from your device. + + +Changes to This Terms and Conditions + +I may update our Terms and Conditions from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Terms and Conditions on this page. + +These terms and conditions are effective as of 25.04.2020 + + +Contact Us + +If you have any questions or suggestions about my Terms and Conditions, do not hesitate to contact me at amatsukine@gmail.com From 77420a6fc28e49ea3285be95ca8a73c42dea7a36 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 26 Apr 2020 00:26:11 +0300 Subject: [PATCH 38/75] Rename blueprints/telegram_bot/yandex_disk_auth to yd_auth --- src/blueprints/telegram_bot/__init__.py | 2 +- .../telegram_bot/{yandex_disk_auth => yd_auth}/__init__.py | 0 .../telegram_bot/{yandex_disk_auth => yd_auth}/views.py | 0 3 files changed, 1 insertion(+), 1 deletion(-) rename src/blueprints/telegram_bot/{yandex_disk_auth => yd_auth}/__init__.py (100%) rename src/blueprints/telegram_bot/{yandex_disk_auth => yd_auth}/views.py (100%) diff --git a/src/blueprints/telegram_bot/__init__.py b/src/blueprints/telegram_bot/__init__.py index 59b387b..2714cfe 100644 --- a/src/blueprints/telegram_bot/__init__.py +++ b/src/blueprints/telegram_bot/__init__.py @@ -1,3 +1,3 @@ from .bp import bp as telegram_bot_blueprint from .webhook import views as webhook_views -from .yandex_disk_auth import views as yd_auth_views +from .yd_auth import views as yd_auth_views diff --git a/src/blueprints/telegram_bot/yandex_disk_auth/__init__.py b/src/blueprints/telegram_bot/yd_auth/__init__.py similarity index 100% rename from src/blueprints/telegram_bot/yandex_disk_auth/__init__.py rename to src/blueprints/telegram_bot/yd_auth/__init__.py diff --git a/src/blueprints/telegram_bot/yandex_disk_auth/views.py b/src/blueprints/telegram_bot/yd_auth/views.py similarity index 100% rename from src/blueprints/telegram_bot/yandex_disk_auth/views.py rename to src/blueprints/telegram_bot/yd_auth/views.py From ccb72781e60f0a5229727111cf739019a55acb32 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 11:00:49 +0300 Subject: [PATCH 39/75] Refactoring of all components of /telegram_bot/webhook --- CHANGELOG.md | 7 + src/api/telegram/__init__.py | 3 +- src/api/telegram/methods.py | 9 + src/api/telegram/requests.py | 2 +- src/api/yandex/__init__.py | 3 +- src/api/yandex/requests.py | 28 +- src/blueprints/telegram_bot/utils.py | 17 ++ .../telegram_bot/webhook/commands/__init__.py | 11 +- .../telegram_bot/webhook/commands/about.py | 38 ++- .../webhook/commands/common/decorators.py | 54 ++-- .../webhook/commands/common/names.py | 2 +- .../webhook/commands/common/responses.py | 63 +++-- .../commands/common/{api.py => yandex_api.py} | 69 ++--- .../webhook/commands/create_folder.py | 43 ++-- .../telegram_bot/webhook/commands/help.py | 52 ++-- .../telegram_bot/webhook/commands/settings.py | 13 +- .../telegram_bot/webhook/commands/unknown.py | 7 +- .../telegram_bot/webhook/commands/upload.py | 171 ++++++++----- .../telegram_bot/webhook/commands/yd_auth.py | 169 +++++++----- .../webhook/commands/yd_revoke.py | 62 ++--- .../webhook/telegram_interface.py | 242 ++++++++++++++++++ src/blueprints/telegram_bot/webhook/views.py | 212 ++------------- src/configs/flask.py | 5 + src/localization/languages.py | 17 ++ 24 files changed, 800 insertions(+), 499 deletions(-) create mode 100644 src/blueprints/telegram_bot/utils.py rename src/blueprints/telegram_bot/webhook/commands/common/{api.py => yandex_api.py} (77%) create mode 100644 src/blueprints/telegram_bot/webhook/telegram_interface.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b2bb79d..de4ff78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## Telegram Bot +### Improved + +- Text of most bot responses. +- When uploading, bot will update single message instead of sending new one every time with operation status. + ### Added - Privacy Policy and Terms & Conditions. - Favicons for different platforms. +- Buttons with useful links in `/help` command. ### Changed @@ -13,6 +19,7 @@ - Description. - Logo. - Favicon. +- Button with URL instead of plain text with URL in `/grant_access` command. ## Project diff --git a/src/api/telegram/__init__.py b/src/api/telegram/__init__.py index e5ffae3..edce8c5 100644 --- a/src/api/telegram/__init__.py +++ b/src/api/telegram/__init__.py @@ -1,7 +1,8 @@ from .methods import ( send_message, get_file, - send_chat_action + send_chat_action, + edit_message_text ) from .requests import ( create_file_download_url diff --git a/src/api/telegram/methods.py b/src/api/telegram/methods.py index a18ed59..7a3152e 100644 --- a/src/api/telegram/methods.py +++ b/src/api/telegram/methods.py @@ -26,3 +26,12 @@ def send_chat_action(**kwargs): - see `api/request.py` documentation for more. """ return make_request("sendChatAction", kwargs) + + +def edit_message_text(**kwargs): + """ + https://core.telegram.org/bots/api/#editmessagetext + + - see `api/request.py` documentation for more. + """ + return make_request("editMessageText", kwargs) diff --git a/src/api/telegram/requests.py b/src/api/telegram/requests.py index e6df05a..5051c18 100644 --- a/src/api/telegram/requests.py +++ b/src/api/telegram/requests.py @@ -30,7 +30,7 @@ def create_file_download_url(file_path: str) -> str: """ Creates Telegram URL for downloading of file. - - contains sensitive information (bot token)! + - contains secret information (bot token)! :param file_path: `file_path` property of `File` object. """ diff --git a/src/api/yandex/__init__.py b/src/api/yandex/__init__.py index e255de6..e63ce3a 100644 --- a/src/api/yandex/__init__.py +++ b/src/api/yandex/__init__.py @@ -4,5 +4,6 @@ create_folder ) from .requests import ( - make_link_request + make_link_request, + create_user_oauth_url ) diff --git a/src/api/yandex/requests.py b/src/api/yandex/requests.py index c332b1b..e5de073 100644 --- a/src/api/yandex/requests.py +++ b/src/api/yandex/requests.py @@ -1,4 +1,5 @@ from os import environ +import base64 from requests.auth import HTTPBasicAuth from flask import current_app @@ -10,9 +11,9 @@ from src.api.auth import HTTPOAuthAuth -def create_oauth_url(method_name: str) -> str: +def create_bot_oauth_url(method_name: str) -> str: """ - Creates Yandex OAuth URL for request. + Creates Yandex OAuth URL for bot request. :param method_name: Name of API method in URL. """ @@ -22,6 +23,27 @@ def create_oauth_url(method_name: str) -> str: ) +def create_user_oauth_url(state: str) -> str: + """ + Creates Yandex OAuth URL for user request. + + - https://yandex.ru/dev/oauth/doc/dg/concepts/about-docpage/ + + :param state: `state` parameter. Will be encoded with base64. + """ + client_id = environ["YANDEX_OAUTH_API_APP_ID"] + state = base64.urlsafe_b64encode( + state.encode() + ).decode() + + return ( + "https://oauth.yandex.ru/authorize?" + "response_type=code" + f"&client_id={client_id}" + f"&state={state}" + ) + + def create_disk_url(method_name: str) -> str: """ Creates Yandex.Disk URL for request. @@ -43,7 +65,7 @@ def make_oauth_request(method_name: str, data: dict): :param method_name: Name of API method in URL. :param data: Data to send. """ - url = create_oauth_url(method_name) + url = create_bot_oauth_url(method_name) timeout = current_app.config["YANDEX_OAUTH_API_TIMEOUT"] id = environ["YANDEX_OAUTH_API_APP_ID"] password = environ["YANDEX_OAUTH_API_APP_PASSWORD"] diff --git a/src/blueprints/telegram_bot/utils.py b/src/blueprints/telegram_bot/utils.py new file mode 100644 index 0000000..770bbdd --- /dev/null +++ b/src/blueprints/telegram_bot/utils.py @@ -0,0 +1,17 @@ +from datetime import datetime, timezone + + +def get_current_datetime() -> dict: + """ + :returns: Information about current date and time. + """ + current_datetime = datetime.now(timezone.utc) + current_date = current_datetime.strftime("%d.%m.%Y") + current_time = current_datetime.strftime("%H:%M:%S") + current_timezone = current_datetime.strftime("%Z") + + return { + "date": current_date, + "time": current_time, + "timezone": current_timezone + } diff --git a/src/blueprints/telegram_bot/webhook/commands/__init__.py b/src/blueprints/telegram_bot/webhook/commands/__init__.py index fbdef81..b549be6 100644 --- a/src/blueprints/telegram_bot/webhook/commands/__init__.py +++ b/src/blueprints/telegram_bot/webhook/commands/__init__.py @@ -1,12 +1,13 @@ +from .common.names import CommandsNames from .unknown import handle as unknown_handler from .help import handle as help_handler from .about import handle as about_handler from .settings import handle as settings_handler from .yd_auth import handle as yd_auth_handler from .yd_revoke import handle as yd_revoke_handler -from .upload import photo_handler as upload_photo_handler -from .upload import file_handler as upload_file_handler -from .upload import audio_handler as upload_audio_handler -from .upload import video_handler as upload_video_handler -from .upload import voice_handler as upload_voice_handler +from .upload import handle_photo as upload_photo_handler +from .upload import handle_file as upload_file_handler +from .upload import handle_audio as upload_audio_handler +from .upload import handle_video as upload_video_handler +from .upload import handle_voice as upload_voice_handler from .create_folder import handle as create_folder_handler diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index 1f041ec..2cd1efb 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -1,4 +1,6 @@ -from flask import g +from os import environ + +from flask import g, url_for from src.api import telegram @@ -8,14 +10,40 @@ def handle(): Handles `/about` command. """ telegram.send_message( - chat_id=g.incoming_chat["id"], + chat_id=g.telegram_chat.id, disable_web_page_preview=True, text=( "I'm free and open-source bot that allows " "you to interact with Yandex.Disk through Telegram." "\n\n" - "Written by Sergey Kuznetsov" + f"Written by {environ['PROJECT_AUTHOR']}" "\n" - "https://github.com/Amaimersion/yandex-disk-telegram-bot" - ) + f"{environ['PROJECT_URL_FOR_CODE']}" + ), + reply_markup=[ + [ + { + "text": "Post an issue", + "url": environ["PROJECT_URL_FOR_ISSUE"] + }, + { + "text": "Request a feature", + "url": environ["PROJECT_URL_FOR_REQUEST"] + }, + { + "text": "Ask a question", + "url": environ["PROJECT_URL_FOR_QUESTION"] + } + ], + [ + { + "text": "Privacy Policy", + "url": url_for("legal.privacy_policy") + }, + { + "text": "Terms And Conditions", + "url": url_for("legal.terms_and_conditions") + } + ] + ] ) diff --git a/src/blueprints/telegram_bot/webhook/commands/common/decorators.py b/src/blueprints/telegram_bot/webhook/commands/common/decorators.py index d22f4a4..a0916e8 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/decorators.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/decorators.py @@ -12,35 +12,36 @@ from src.database.models import ( ChatType ) +from src.localization import SupportedLanguages from .responses import cancel_command -from .names import CommandNames +from .names import CommandsNames def register_guest(func): """ - Checks if incoming user exists in DB. - If not, then that user will be created and saved. + If incoming Telegram user doesn't exists in DB, + then that user will be created and saved. - Rows will be created in next tables: `users`, `chats`. + - rows will be created in next tables: `users`, `chats` """ - # TODO: check if user came from different chat, - # then also register that chat in db. - @wraps(func) def wrapper(*args, **kwargs): - incoming_user = g.incoming_user - incoming_chat = g.incoming_chat + tg_user = g.telegram_user + tg_chat = g.telegram_chat - if (UserQuery.exists(incoming_user["id"])): + # TODO: check if user came from different chat, + # then also register that chat in db. + if (UserQuery.exists(tg_user.id)): return func(*args, **kwargs) new_user = User( - telegram_id=incoming_user["id"], - is_bot=incoming_user.get("is_bot", False) + telegram_id=tg_user.id, + is_bot=tg_user.is_bot, + language=SupportedLanguages.get(tg_user.language_code) ) Chat( - telegram_id=incoming_chat["id"], - type=ChatType.get(incoming_chat["type"]), + telegram_id=tg_chat.id, + type=ChatType.get(tg_chat.type), user=new_user ) @@ -50,7 +51,7 @@ def wrapper(*args, **kwargs): db.session.commit() except Exception as e: print(e) - return cancel_command(incoming_chat["id"]) + return cancel_command(tg_chat.id) return func(*args, **kwargs) @@ -59,20 +60,20 @@ def wrapper(*args, **kwargs): def get_db_data(func): """ - Gets data from DB based on `g.incoming_user` and - `g.incoming_chat`. Data can be `None` if incoming + Gets data from DB based on `g.telegram_user` and + `g.telegram_chat`. Data can be `None` if incoming data not exists in DB. - DB data will be available as: `g.db_user`, `g.db_incoming_chat`, - `g.db_private_chat`. + DB data will be available as: `g.db_user`, + `g.db_chat`, `g.db_private_chat`. """ @wraps(func) def wrapper(*args, **kwargs): g.db_user = UserQuery.get_user_by_telegram_id( - g.incoming_user["id"] + g.telegram_user.id ) - g.db_incoming_chat = ChatQuery.get_chat_by_telegram_id( - g.incoming_chat["id"] + g.db_chat = ChatQuery.get_chat_by_telegram_id( + g.telegram_chat.id ) g.db_private_chat = ChatQuery.get_private_chat( g.db_user.id @@ -86,21 +87,22 @@ def wrapper(*args, **kwargs): def yd_access_token_required(func): """ Checks if incoming user have Yandex.Disk access token. - If not, then that user will be redirected to another command - for getting Y.D. token. + If not, then that user will be redirected to another + command for getting Y.D. token. """ @wraps(func) def wrapper(*args, **kwargs): user = UserQuery.get_user_by_telegram_id( - g.incoming_user["id"] + g.telegram_user.id ) + # TODO: check if token is expired if ( (user is None) or (user.yandex_disk_token is None) or (not user.yandex_disk_token.have_access_token()) ): - return g.route_to(CommandNames.YD_AUTH) + return g.route_to(CommandsNames.YD_AUTH) return func(*args, **kwargs) diff --git a/src/blueprints/telegram_bot/webhook/commands/common/names.py b/src/blueprints/telegram_bot/webhook/commands/common/names.py index 549f2e0..0ee65e9 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/names.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/names.py @@ -2,7 +2,7 @@ @unique -class CommandNames(Enum): +class CommandsNames(Enum): """ Commands supported by bot. """ diff --git a/src/blueprints/telegram_bot/webhook/commands/common/responses.py b/src/blueprints/telegram_bot/webhook/commands/common/responses.py index 4fb9e9a..2dfb7c2 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/responses.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/responses.py @@ -1,32 +1,65 @@ from src.api import telegram -def abort_command(chat_telegram_id: int) -> None: +def abort_command( + chat_telegram_id: int, + message_telegram_id: int = None +) -> None: """ Aborts command execution due to invalid message data. - Don't confuse with `cancel_command()`. + - don't confuse with `cancel_command()`. + - if `message_telegram_id` specified, then + that message will be updated. """ - telegram.send_message( - chat_id=chat_telegram_id, - text="I can't handle this because of your invalid data" + text = ( + "I can't handle this because " + "you didn't send any suitable data " + "for that command." ) + if (message_telegram_id is None): + telegram.send_message( + chat_id=chat_telegram_id, + text=text + ) + else: + telegram.edit_message_text( + chat_id=chat_telegram_id, + message_id=message_telegram_id, + text=text + ) + -def cancel_command(chat_telegram_id: int) -> None: +def cancel_command( + chat_telegram_id: int, + message_telegram_id: int = None +) -> None: """ Cancels command execution due to internal server error. - Don't confuse with `abort_command()`. + - don't confuse with `abort_command()`. + - if `message_telegram_id` specified, then + that message will be updated. """ - telegram.send_message( - chat_id=chat_telegram_id, - text=( - "I can't process you because of my internal error. " - "Try later please." - ) + text = ( + "At the moment i can't process this " + "because of my internal error. " + "Try later please." ) + if (message_telegram_id is None): + telegram.send_message( + chat_id=chat_telegram_id, + text=text + ) + else: + telegram.edit_message_text( + chat_id=chat_telegram_id, + message_id=message_telegram_id, + text=text + ) + def request_private_chat(chat_telegram_id: int) -> None: """ @@ -35,9 +68,9 @@ def request_private_chat(chat_telegram_id: int) -> None: telegram.send_message( chat_id=chat_telegram_id, text=( - "I need to send you your sensitive information, " + "I need to send you your secret information, " "but i don't know any private chat with you. " - "Please contact me first through private chat (direct message). " + "First, contact me through private chat (direct message). " "After that repeat your request." ) ) diff --git a/src/blueprints/telegram_bot/webhook/commands/common/api.py b/src/blueprints/telegram_bot/webhook/commands/common/yandex_api.py similarity index 77% rename from src/blueprints/telegram_bot/webhook/commands/common/api.py rename to src/blueprints/telegram_bot/webhook/commands/common/yandex_api.py index 6ee8838..a5a61dc 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/api.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/yandex_api.py @@ -53,7 +53,10 @@ class YandexAPIExceededNumberOfStatusChecksError(Exception): pass -def create_folder(access_token: str, folder_name: str) -> int: +def create_folder( + user_access_token: str, + folder_name: str +) -> int: """ Creates folder using Yandex API. @@ -74,35 +77,36 @@ def create_folder(access_token: str, folder_name: str) -> int: allowed_errors = [409] for folder in folders: - response = None + result = None folder_path = f"{folder_path}/{folder}" try: - response = yandex.create_folder( - access_token, + result = yandex.create_folder( + user_access_token, path=folder_path ) - except Exception as e: - raise YandexAPIRequestError(e) + except Exception as error: + raise YandexAPIRequestError(error) - last_status_code = response["status_code"] + response = result["content"] + last_status_code = result["status_code"] if ( (last_status_code == 201) or - (not is_error_yandex_response(response)) or - (last_status_code in allowed_errors) + (last_status_code in allowed_errors) or + (not is_error_yandex_response(response)) ): continue raise YandexAPICreateFolderError( - create_yandex_error_text(response) + create_yandex_error_text(result) ) return last_status_code def upload_file_with_url( - access_token: str, + user_access_token: str, folder_path: str, file_name: str, download_url: str @@ -110,7 +114,7 @@ def upload_file_with_url( """ Uploads a file to Yandex.Disk using file download url. - - before uploading creates a folder; + - before uploading creates a folder. - after uploading will monitor operation status according to app configuration. Because it is synchronous, it may take significant time to end this function! @@ -126,23 +130,24 @@ def upload_file_with_url( :raises: YandexAPIExceededNumberOfStatusChecksError """ create_folder( - access_token=access_token, + user_access_token=user_access_token, folder_name=folder_path ) folders = [x for x in folder_path.split("/") if x] full_path = "/".join(folders + [file_name]) - operation_status_link = None + response = None try: - operation_status_link = yandex.upload_file_with_url( - access_token, + response = yandex.upload_file_with_url( + user_access_token, url=download_url, path=full_path - )["content"] - except Exception as e: - raise YandexAPIRequestError(e) + ) + except Exception as error: + raise YandexAPIRequestError(error) + operation_status_link = response["content"] is_error = is_error_yandex_response(operation_status_link) if (is_error): @@ -152,7 +157,7 @@ def upload_file_with_url( ) ) - result = {} + operation_status = {} attempt = 0 max_attempts = current_app.config[ "YANDEX_DISK_API_CHECK_OPERATION_STATUS_MAX_ATTEMPTS" @@ -162,31 +167,33 @@ def upload_file_with_url( ] while not ( - is_error_yandex_response(result) or - yandex_operation_is_completed(result) or + is_error_yandex_response(operation_status) or + yandex_operation_is_completed(operation_status) or attempt >= max_attempts ): sleep(interval) try: - result = yandex.make_link_request( + response = yandex.make_link_request( data=operation_status_link, - user_token=access_token - )["content"] - except Exception as e: - raise YandexAPIRequestError(e) + user_token=user_access_token + ) + except Exception as error: + raise YandexAPIRequestError(error) + + operation_status = response["content"] - if ("status" in result): - yield result["status"] + if ("status" in operation_status): + yield operation_status["status"] attempt += 1 - is_error = is_error_yandex_response(result) + is_error = is_error_yandex_response(operation_status) if (is_error): raise YandexAPIUploadFileError( create_yandex_error_text( - result + operation_status ) ) elif (attempt >= max_attempts): diff --git a/src/blueprints/telegram_bot/webhook/commands/create_folder.py b/src/blueprints/telegram_bot/webhook/commands/create_folder.py index 8dc80b5..8d7cbe9 100644 --- a/src/blueprints/telegram_bot/webhook/commands/create_folder.py +++ b/src/blueprints/telegram_bot/webhook/commands/create_folder.py @@ -8,12 +8,12 @@ yd_access_token_required, get_db_data ) -from .common.api import ( +from .common.yandex_api import ( create_folder, YandexAPICreateFolderError, YandexAPIRequestError ) -from .common.names import CommandNames +from . import CommandsNames @yd_access_token_required @@ -22,30 +22,30 @@ def handle(): """ Handles `/create_folder` command. """ - message = g.incoming_message + message = g.telegram_message user = g.db_user - chat = g.db_incoming_chat + chat = g.db_chat access_token = user.yandex_disk_token.get_access_token() - message_text = get_text(message) - message_folder_name = message_text.replace( - CommandNames.CREATE_FOLDER.value, + message_text = message.get_text() + folder_name = message_text.replace( + CommandsNames.CREATE_FOLDER.value, "" ).strip() last_status_code = None try: last_status_code = create_folder( - access_token=access_token, - folder_name=message_folder_name + user_access_token=access_token, + folder_name=folder_name ) - except YandexAPIRequestError as e: - print(e) + except YandexAPIRequestError as error: + print(error) return cancel_command(chat.telegram_id) - except YandexAPICreateFolderError as e: - error_text = "Yandex.Disk Error" - - if hasattr(e, "message"): - error_text = e.message + except YandexAPICreateFolderError as error: + error_text = ( + str(error) or + "Unknown Yandex.Disk error" + ) telegram.send_message( chat_id=chat.telegram_id, @@ -67,14 +67,3 @@ def handle(): chat_id=chat.telegram_id, text=text ) - - -def get_text(message: dict) -> str: - """ - Extracts text from a message. - """ - return ( - message.get("text") or - message.get("caption") or - "" - ) diff --git a/src/blueprints/telegram_bot/webhook/commands/help.py b/src/blueprints/telegram_bot/webhook/commands/help.py index 5a7e3d4..6da6734 100644 --- a/src/blueprints/telegram_bot/webhook/commands/help.py +++ b/src/blueprints/telegram_bot/webhook/commands/help.py @@ -1,7 +1,9 @@ +# flake8: noqa + from flask import g, current_app from src.api import telegram -from .common.names import CommandNames +from . import CommandsNames def handle(): @@ -13,58 +15,58 @@ def handle(): ] text = ( - "I can help you to interact with Yandex.Disk." - "\n\n" - "Send me some data (images, files, etc.) and " - "i think i will handle this. " - "Moreover, you can control me by sending these commands:" + "You can control me by sending these commands:" "\n\n" "<b>Yandex.Disk</b>" "\n" - f'By default "<code>{yd_upload_default_folder}</code>" folder is used.' + f'For uploading "{to_code(yd_upload_default_folder)}" folder is used by default.' "\n" - f"{CommandNames.UPLOAD_PHOTO.value} — upload a photo. " + f"{CommandsNames.UPLOAD_PHOTO.value} — upload a photo. " "Original name will be not saved, quality of photo will be decreased. " - "Send photo(s) with this command." + "You can send photo without this command." "\n" - f"{CommandNames.UPLOAD_FILE.value} — upload a file. " + f"{CommandsNames.UPLOAD_FILE.value} — upload a file. " "Original name will be saved. " "For photos, original quality will be saved. " - "Send file(s) with this command." + "You can send file without this command." "\n" - f"{CommandNames.UPLOAD_AUDIO.value} — upload an audio. " + f"{CommandsNames.UPLOAD_AUDIO.value} — upload an audio. " "Original name will be saved, original type may be changed. " - "Send audio file(s) with this command." + "You can send audio file without this command." "\n" - f"{CommandNames.UPLOAD_VIDEO.value} — upload a video. " + f"{CommandsNames.UPLOAD_VIDEO.value} — upload a video. " "Original name will be not saved, original type may be changed. " - "Send video file(s) with this command." + "You can send video file without this command." "\n" - f"{CommandNames.UPLOAD_VOICE.value} — upload a voice. " - "Send voice file(s) with this command." + f"{CommandsNames.UPLOAD_VOICE.value} — upload a voice. " + "You can send voice file without this command." "\n" - f"{CommandNames.CREATE_FOLDER.value}— create a folder. " - "Send folder name with this command. " + f"{CommandsNames.CREATE_FOLDER.value}— create a folder. " + "Send folder name to create with this command. " "Folder name should starts from root, " - 'nested folders should be separated with "<code>/</code>" character.' + f'nested folders should be separated with "{to_code("/")}" character.' "\n\n" "<b>Yandex.Disk Access</b>" "\n" - f"{CommandNames.YD_AUTH.value} — give me an access to your Yandex.Disk" + f"{CommandsNames.YD_AUTH.value} — give me an access to your Yandex.Disk" "\n" - f"{CommandNames.YD_REVOKE.value} — revoke my access to your Yandex.Disk" # noqa + f"{CommandsNames.YD_REVOKE.value} — revoke my access to your Yandex.Disk" "\n\n" "<b>Settings</b>" "\n" - f"{CommandNames.SETTINGS.value} — full list of settings" + f"{CommandsNames.SETTINGS.value} — edit your settings" "\n\n" "<b>Information</b>" "\n" - f"{CommandNames.ABOUT.value} — read about me" + f"{CommandsNames.ABOUT.value} — read about me" ) telegram.send_message( - chat_id=g.incoming_chat["id"], + chat_id=g.telegram_chat.id, parse_mode="HTML", text=text ) + + +def to_code(text: str) -> str: + return f"<code>{text}</code>" diff --git a/src/blueprints/telegram_bot/webhook/commands/settings.py b/src/blueprints/telegram_bot/webhook/commands/settings.py index 715f09b..a9d0f66 100644 --- a/src/blueprints/telegram_bot/webhook/commands/settings.py +++ b/src/blueprints/telegram_bot/webhook/commands/settings.py @@ -1,8 +1,13 @@ from flask import g from src.api import telegram -from .common.decorators import register_guest, get_db_data -from .common.responses import request_private_chat +from .common.decorators import ( + register_guest, + get_db_data +) +from .common.responses import ( + request_private_chat +) @register_guest @@ -12,7 +17,7 @@ def handle(): Handles `/settings` command. """ user = g.db_user - incoming_chat = g.db_incoming_chat + incoming_chat = g.db_chat private_chat = g.db_private_chat if (private_chat is None): @@ -31,7 +36,7 @@ def handle(): f"{user.language.name}" "\n" "<b>Yandex.Disk Access:</b> " - f"{'Granted' if yd_access else 'Not Granted'}" + f"{'Given' if yd_access else 'Revoked'}" ) telegram.send_message( diff --git a/src/blueprints/telegram_bot/webhook/commands/unknown.py b/src/blueprints/telegram_bot/webhook/commands/unknown.py index 108c0c0..f1d70b0 100644 --- a/src/blueprints/telegram_bot/webhook/commands/unknown.py +++ b/src/blueprints/telegram_bot/webhook/commands/unknown.py @@ -1,6 +1,7 @@ from flask import g from src.api import telegram +from . import CommandsNames def handle(): @@ -8,9 +9,9 @@ def handle(): Handles unknown command. """ telegram.send_message( - chat_id=g.incoming_chat["id"], + chat_id=g.telegram_chat.id, text=( - "I can't understand this command. " - "See command list or type /help" + "I don't know this command. " + f"See commands list or type {CommandsNames.HELP.value}" ) ) diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index 0fc3c6a..df721e7 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -4,6 +4,7 @@ from flask import g, current_app from src.api import telegram +from src.blueprints.telegram_bot.webhook import telegram_interface from .common.decorators import ( yd_access_token_required, get_db_data @@ -12,7 +13,7 @@ abort_command, cancel_command ) -from .common.api import ( +from .common.yandex_api import ( upload_file_with_url, YandexAPIRequestError, YandexAPICreateFolderError, @@ -25,6 +26,15 @@ class AttachmentHandler(metaclass=ABCMeta): """ Handles uploading of attachment of Telegram message. """ + def __init__(self) -> None: + # Sended message to Telegram user. + # This message will be updated, rather + # than sending new message every time + self.sended_message: Union[ + telegram_interface.Message, + None + ] = None + @staticmethod @abstractmethod def handle() -> None: @@ -43,7 +53,10 @@ def telegram_action(self) -> str: pass @abstractmethod - def message_is_valid(self, message: dict) -> bool: + def message_is_valid( + self, + message: telegram_interface.Message + ) -> bool: """ :param message: Incoming Telegram message. @@ -52,7 +65,10 @@ def message_is_valid(self, message: dict) -> bool: pass @abstractmethod - def get_attachment(self, message: dict) -> Union[dict, None]: + def get_attachment( + self, + message: telegram_interface.Message + ) -> Union[dict, None]: """ :param message: Incoming Telegram message. @@ -65,11 +81,15 @@ def get_attachment(self, message: dict) -> Union[dict, None]: pass @abstractmethod - def create_file_name(self, attachment: dict, file: dict) -> str: + def create_file_name( + self, + attachment: dict, + file: dict + ) -> str: """ :param attachment: Not `None` value from `self.get_attachment()`. - :param file: Representation of this attachment as a file on Telegram - servers. See https://core.telegram.org/bots/api/#file + :param file: Representation of this attachment as a file on + Telegram servers. See https://core.telegram.org/bots/api/#file :returns: Name of file which will be uploaded. """ @@ -81,11 +101,11 @@ def upload(self) -> None: """ Uploads an attachment. """ - message = g.incoming_message + message = g.telegram_message user = g.db_user - chat = g.db_incoming_chat + chat = g.db_chat - if (not self.message_is_valid(message)): + if not (self.message_is_valid(message)): return abort_command(chat.telegram_id) attachment = self.get_attachment(message) @@ -102,16 +122,17 @@ def upload(self) -> None: print(error) return cancel_command(chat.telegram_id) - file = None + result = None try: - file = telegram.get_file( + result = telegram.get_file( file_id=attachment["file_id"] - )["content"] + ) except Exception as error: print(error) return cancel_command(chat.telegram_id) + file = result["content"] user_access_token = user.yandex_disk_token.get_access_token() folder_path = current_app.config[ "YANDEX_DISK_API_DEFAULT_UPLOAD_FOLDER" @@ -129,9 +150,9 @@ def long_task(): file_name=file_name, download_url=download_url ): - telegram.send_message( - chat_id=chat.telegram_id, - text=f"Status: {status}" + self.send_message( + chat.telegram_id, + f"Status: {status}" ) except YandexAPICreateFolderError as error: error_text = str(error) or ( @@ -139,43 +160,66 @@ def long_task(): "due to an unknown Yandex error." ) - telegram.send_message( - chat_id=chat.telegram_id, - text=error_text + return self.send_message( + chat.telegram_id, + error_text ) - - return except YandexAPIUploadFileError as error: error_text = str(error) or ( - "I can't upload this due to an unknown Yandex error." + "I can't upload this due " + "to an unknown Yandex error." ) - telegram.send_message( - chat_id=chat.telegram_id, - reply_to_message_id=message["message_id"], - text=error_text + return self.send_message( + chat.telegram_id, + error_text ) - - return except YandexAPIExceededNumberOfStatusChecksError: error_text = ( - "I can't track operation status of this anymore. " - "Perform manual checking." + "I can't track operation status of " + "this anymore. Perform manual checking." ) - telegram.send_message( - chat_id=chat.telegram_id, - reply_to_message_id=message["message_id"], - text=error_text + return self.send_message( + chat.telegram_id, + error_text ) - - return except (YandexAPIRequestError, Exception) as error: print(error) - return cancel_command(chat.telegram_id) + sended_message_id = None + + if (self.sended_message is not None): + sended_message_id = self.sended_message.message_id + + return cancel_command( + chat.telegram_id, + sended_message_id + ) long_task() + def send_message(self, chat_id: int, text: str) -> None: + """ + Sends message to Telegram user. + + - if message already was sent, then sent message + will be updated with new text. + """ + if (self.sended_message is None): + result = telegram.send_message( + chat_id=chat_id, + text=text + ) + self.sended_message = telegram_interface.Message( + result["content"] + ) + else: + telegram.edit_message_text( + chat_id=chat_id, + message_id=self.sended_message.message_id, + text=text + ) + class PhotoHandler(AttachmentHandler): """ @@ -190,17 +234,20 @@ def handle(): def telegram_action(self): return "upload_photo" - def message_is_valid(self, message): + def message_is_valid(self, message: telegram_interface.Message): + raw_data = message.raw_data + return ( isinstance( - message.get("photo"), + raw_data.get("photo"), list ) and - len(message["photo"]) > 0 + len(raw_data["photo"]) > 0 ) - def get_attachment(self, message): - photos = message["photo"] + def get_attachment(self, message: telegram_interface.Message): + raw_data = message.raw_data + photos = raw_data["photo"] biggest_photo = photos[0] for photo in photos[1:]: @@ -226,16 +273,16 @@ def handle(): def telegram_action(self): return "upload_document" - def message_is_valid(self, message): + def message_is_valid(self, message: telegram_interface.Message): return ( isinstance( - message.get("document"), + message.raw_data.get("document"), dict ) ) - def get_attachment(self, message): - return message["document"] + def get_attachment(self, message: telegram_interface.Message): + return message.raw_data["document"] def create_file_name(self, attachment, file): return ( @@ -257,16 +304,16 @@ def handle(): def telegram_action(self): return "upload_audio" - def message_is_valid(self, message): + def message_is_valid(self, message: telegram_interface.Message): return ( isinstance( - message.get("audio"), + message.raw_data.get("audio"), dict ) ) - def get_attachment(self, message): - return message["audio"] + def get_attachment(self, message: telegram_interface.Message): + return message.raw_data["audio"] def create_file_name(self, attachment, file): name = file["file_unique_id"] @@ -298,16 +345,16 @@ def handle(): def telegram_action(self): return "upload_video" - def message_is_valid(self, message): + def message_is_valid(self, message: telegram_interface.Message): return ( isinstance( - message.get("video"), + message.raw_data.get("video"), dict ) ) - def get_attachment(self, message): - return message["video"] + def get_attachment(self, message: telegram_interface.Message): + return message.raw_data["video"] def create_file_name(self, attachment, file): name = file["file_unique_id"] @@ -333,16 +380,16 @@ def handle(): def telegram_action(self): return "upload_audio" - def message_is_valid(self, message): + def message_is_valid(self, message: telegram_interface.Message): return ( isinstance( - message.get("voice"), + message.raw_data.get("voice"), dict ) ) - def get_attachment(self, message): - return message["voice"] + def get_attachment(self, message: telegram_interface.Message): + return message.raw_data["voice"] def create_file_name(self, attachment, file): name = file["file_unique_id"] @@ -355,8 +402,8 @@ def create_file_name(self, attachment, file): return name -photo_handler = PhotoHandler.handle -file_handler = FileHandler.handle -audio_handler = AudioHandler.handle -video_handler = VideoHandler.handle -voice_handler = VoiceHandler.handle +handle_photo = PhotoHandler.handle +handle_file = FileHandler.handle +handle_audio = AudioHandler.handle +handle_video = VideoHandler.handle +handle_voice = VoiceHandler.handle diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index cc3850c..d26b446 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -1,9 +1,11 @@ -import os +from os import environ import secrets -import base64 -from datetime import datetime, timezone -from flask import g, current_app +from flask import ( + g, + current_app, + url_for +) import jwt from src.database import ( @@ -11,6 +13,9 @@ YandexDiskToken ) from src.api import telegram, yandex +from src.blueprints.telegram_bot.utils import ( + get_current_datetime +) from .common.decorators import ( register_guest, get_db_data @@ -19,7 +24,7 @@ request_private_chat, cancel_command ) -from .common.names import CommandNames +from . import CommandsNames @register_guest @@ -28,19 +33,22 @@ def handle(): """ Handles `/yandex_disk_authorization` command. - Authorization of bot in user Yandex.Disk + Authorization of bot in user Yandex.Disk. """ user = g.db_user - incoming_chat = g.db_incoming_chat + incoming_chat = g.db_chat private_chat = g.db_private_chat yd_token = user.yandex_disk_token + if (private_chat is None): + return request_private_chat(incoming_chat.telegram_id) + if (yd_token is None): try: yd_token = create_empty_yd_token(user) - except Exception as e: - print(e) - return cancel_command(incoming_chat.telegram_id) + except Exception as error: + print(error) + return cancel_command(private_chat.telegram_id) refresh_needed = False @@ -48,16 +56,15 @@ def handle(): try: yd_token.get_access_token() - if (private_chat): - telegram.send_message( - chat_id=private_chat.telegram_id, - text=( - "You already gave me access to your Yandex.Disk." - "\n" - "If you want to revoke access, then do that with " - f"{CommandNames.YD_REVOKE.value}" - ) + telegram.send_message( + chat_id=private_chat.telegram_id, + text=( + "You already grant me access to your Yandex.Disk." + "\n" + "You can revoke my access with " + f"{CommandsNames.YD_REVOKE.value}" ) + ) # `access_token` is valid return @@ -70,37 +77,39 @@ def handle(): success = refresh_access_token(yd_token) if (success): - if (private_chat): - current_datetime = datetime.now(timezone.utc) - current_date = current_datetime.strftime("%d.%m.%Y") - current_time = current_datetime.strftime("%H:%M:%S") - current_timezone = current_datetime.strftime("%Z") - - telegram.send_message( - chat_id=private_chat.telegram_id, - parse_mode="HTML", - text=( - "<b>Access to Yandex.Disk Updated</b>" - "\n\n" - "Your granted access was updated automatically " - f"on {current_date} at {current_time} " - f"{current_timezone}." - "\n\n" - "If it wasn't you, you can detach this access with " - f"{CommandNames.YD_REVOKE.value}" - ) + current_datetime = get_current_datetime() + date = current_datetime["date"] + time = current_datetime["time"] + timezone = current_datetime["timezone"] + + telegram.send_message( + chat_id=private_chat.telegram_id, + parse_mode="HTML", + text=( + "<b>Access to Yandex.Disk Refreshed</b>" + "\n\n" + "Your granted access was refreshed automatically by me " + f"on {date} at {time} {timezone}." + "\n\n" + "If it wasn't you, you can detach this access with " + f"{CommandsNames.YD_REVOKE.value}" ) + ) return yd_token.clear_all_tokens() yd_token.set_insert_token( secrets.token_hex( - current_app.config["YANDEX_DISK_API_INSERT_TOKEN_BYTES"] + current_app.config[ + "YANDEX_DISK_API_INSERT_TOKEN_BYTES" + ] ) ) yd_token.insert_token_expires_in = ( - current_app.config["YANDEX_DISK_API_INSERT_TOKEN_LIFETIME"] + current_app.config[ + "YANDEX_DISK_API_INSERT_TOKEN_LIFETIME" + ] ) db.session.commit() @@ -108,13 +117,13 @@ def handle(): try: insert_token = yd_token.get_insert_token() - except Exception as e: - print(e) - return cancel_command(incoming_chat.telegram_id) + except Exception as error: + print(error) + return cancel_command(private_chat.telegram_id) if (insert_token is None): print("Error: Insert Token is NULL") - return cancel_command(incoming_chat.telegram_id) + return cancel_command(private_chat.telegram_id) state = jwt.encode( { @@ -124,33 +133,58 @@ def handle(): current_app.secret_key.encode(), algorithm="HS256" ).decode() - yandex_oauth_url = create_yandex_oauth_url(state) + yandex_oauth_url = yandex.create_user_oauth_url(state) insert_token_lifetime = int( yd_token.insert_token_expires_in / 60 ) - if (private_chat is None): - return request_private_chat(incoming_chat.telegram_id) - telegram.send_message( chat_id=private_chat.telegram_id, parse_mode="HTML", disable_web_page_preview=True, text=( - "Follow this link and allow me access to your " - f"Yandex.Disk — {yandex_oauth_url}" + 'Open special link by pressing on "Grant access" ' + "button and grant me access to your Yandex.Disk." "\n\n" "<b>IMPORTANT: don't give this link to anyone, " - "because it contains your sensitive information.</b>" + "because it contains your secret information.</b>" "\n\n" f"<i>This link will expire in {insert_token_lifetime} minutes.</i>" "\n" "<i>This link leads to Yandex page and redirects to bot page.</i>" - ) + "\n\n" + "<b>It is safe to give the access?</b>" + "\n" + "Yes! I'm getting access only to your Yandex.Disk, " + "not to your account. You can revoke my access at any time with " + f"{CommandsNames.YD_REVOKE.value} or in your " + '<a href="https://passport.yandex.ru/profile">Yandex Profile</a>. ' + "By the way, i'm " + f'<a href="{environ["PROJECT_URL_FOR_CODE"]}">open-source</a> ' + "and you can make sure that your data will be safe. " + "You can even create your own bot with my functionality if using " + "me makes you feel uncomfortable (:" + "\n\n" + "By using me, you accept " + f'<a href="{url_for("legal.privacy_policy")}">Privacy Policy</a> and ' # noqa + f'<a href="{url_for("legal.terms_and_conditions")}">Terms And Conditions</a>. ' # noqa + ), + reply_markup=[ + [ + { + "text": "Grant access", + "url": yandex_oauth_url + } + ] + ] ) def create_empty_yd_token(user) -> YandexDiskToken: + """ + Creates empty Yandex.Disk token and binds + this to provided user. + """ new_yd_token = YandexDiskToken(user=user) db.session.add(new_yd_token) @@ -159,37 +193,30 @@ def create_empty_yd_token(user) -> YandexDiskToken: return new_yd_token -def create_yandex_oauth_url(state: str) -> str: - client_id = os.getenv("YANDEX_OAUTH_API_APP_ID", "") - state = base64.urlsafe_b64encode( - state.encode() - ).decode() - - return ( - "https://oauth.yandex.ru/authorize?" - "response_type=code" - f"&client_id={client_id}" - f"&state={state}" - ) - +def refresh_access_token(yd_token: YandexDiskToken) -> bool: + """ + Tries to refresh user access token by using refresh token. -def refresh_access_token(yd_token) -> bool: + :returns: `True` in case of success else `False`. + """ refresh_token = yd_token.get_refresh_token() if (refresh_token is None): return False - yandex_response = None + result = None try: - yandex_response = yandex.get_access_token( + result = yandex.get_access_token( grant_type="refresh_token", refresh_token=refresh_token - )["content"] - except Exception as e: - print(e) + ) + except Exception as error: + print(error) return False + yandex_response = result["content"] + if ("error" in yandex_response): return False diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py index a8aeda6..1e8736a 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py @@ -1,11 +1,11 @@ -from datetime import datetime, timezone - from flask import g from src.database import db from src.api import telegram +from src.blueprints.telegram_bot.utils import get_current_datetime from .common.decorators import get_db_data -from .common.names import CommandNames +from .common.responses import request_private_chat +from . import CommandsNames @get_db_data @@ -13,47 +13,49 @@ def handle(): """ Handles `/yandex_disk_revoke` command. - Revokes bot access to user Yandex.Disk + Revokes bot access to user Yandex.Disk. """ user = g.db_user + incoming_chat = g.db_chat private_chat = g.db_private_chat + if (private_chat is None): + return request_private_chat(incoming_chat.telegram_id) + if ( (user is None) or (user.yandex_disk_token is None) or (not user.yandex_disk_token.have_access_token()) ): - if (private_chat): - telegram.send_message( - chat_id=private_chat.telegram_id, - text=( - "You don't gave me access to your Yandex.Disk." - "\n" - f"You can do that with {CommandNames.YD_AUTH.value}" - ) + telegram.send_message( + chat_id=private_chat.telegram_id, + text=( + "You don't granted me access to your Yandex.Disk." + "\n" + f"You can do that with {CommandsNames.YD_AUTH.value}" ) + ) return user.yandex_disk_token.clear_all_tokens() db.session.commit() - if (private_chat): - current_datetime = datetime.now(timezone.utc) - current_date = current_datetime.strftime("%d.%m.%Y") - current_time = current_datetime.strftime("%H:%M:%S") - current_timezone = current_datetime.strftime("%Z") - - telegram.send_message( - chat_id=private_chat.telegram_id, - parse_mode="HTML", - disable_web_page_preview=True, - text=( - "<b>Access to Yandex.Disk Revoked</b>" - "\n\n" - "You successfully revoked my access to your Yandex.Disk " - f"on {current_date} at {current_time} {current_timezone}." - "\n\n" - "Don't forget to do that at https://passport.yandex.ru/profile" - ) + current_datetime = get_current_datetime() + date = current_datetime["date"] + time = current_datetime["time"] + timezone = current_datetime["timezone"] + + telegram.send_message( + chat_id=private_chat.telegram_id, + parse_mode="HTML", + disable_web_page_preview=True, + text=( + "<b>Access to Yandex.Disk Revoked</b>" + "\n\n" + "You successfully revoked my access to your Yandex.Disk " + f"on {date} at {time} {timezone}." + "\n\n" + "Don't forget to do that at https://passport.yandex.ru/profile" ) + ) diff --git a/src/blueprints/telegram_bot/webhook/telegram_interface.py b/src/blueprints/telegram_bot/webhook/telegram_interface.py new file mode 100644 index 0000000..584b3f7 --- /dev/null +++ b/src/blueprints/telegram_bot/webhook/telegram_interface.py @@ -0,0 +1,242 @@ +from typing import ( + List, + Union +) + +from .commands import CommandsNames + + +class User: + """ + Telegram user. + + https://core.telegram.org/bots/api/#user + """ + def __init__(self, raw_data: dict) -> None: + self.raw_data = raw_data + + @property + def id(self) -> int: + return self.raw_data["id"] + + @property + def is_bot(self) -> bool: + return self.raw_data["is_bot"] + + @property + def language_code(self) -> Union[str, None]: + return self.raw_data.get("language_code") + + +class Chat: + """ + Telegram chat. + + https://core.telegram.org/bots/api/#chat + """ + def __init__(self, raw_data: dict) -> None: + self.raw_data = raw_data + + @property + def id(self) -> int: + return self.raw_data["id"] + + @property + def type(self) -> str: + return self.raw_data["type"] + + +class Entity: + """ + Entity from Telegram message. + + https://core.telegram.org/bots/api/#messageentity + """ + def __init__(self, raw_data: dict) -> None: + self.raw_data = raw_data + + @property + def type(self) -> str: + return self.raw_data["type"] + + @property + def offset(self) -> int: + return self.raw_data["offset"] + + @property + def length(self) -> int: + return self.raw_data["length"] + + def is_bot_command(self) -> bool: + """ + :returns: Entity is a bot command. + """ + return (self.type == "bot_command") + + +class Message: + """ + Telegram message from user. + + https://core.telegram.org/bots/api/#message + """ + def __init__(self, raw_data: dict) -> None: + self.raw_data = raw_data + self.entities: Union[List[Entity], None] = None + + @property + def message_id(self) -> int: + return self.raw_data["message_id"] + + def is_valid(self) -> bool: + """ + :returns: Message is valid for handling. + """ + return ( + isinstance( + self.raw_data.get("from"), + dict + ) + ) + + def get_user(self) -> str: + """ + :returns: Who sent this message. + """ + raw_data = self.raw_data["from"] + + return User(raw_data) + + def get_chat(self) -> Chat: + """ + :returns: Where did this message come from. + """ + raw_data = self.raw_data["chat"] + + return Chat(raw_data) + + def get_text(self) -> str: + """ + :returns: Message text. + """ + return ( + self.raw_data.get("text") or + self.raw_data.get("caption") or + "" + ) + + def get_entities(self) -> List[Entity]: + """ + :returns: Entities from a message. + """ + if (self.entities is None): + self.entities = [] + entities = ( + self.raw_data.get("entities") or + self.raw_data.get("caption_entities") or + [] + ) + + for entity in entities: + self.entities.append( + Entity(entity) + ) + + return self.entities + + def get_bot_command(self, default=CommandsNames.HELP) -> str: + """ + Extracts bot command from a message. + + - first command will be returned even if message + contains more than one command. + + :param default: Default command which will be + returned if message don't contains any bot commands. + + :returns: Bot command from a message. + """ + text = self.get_text() + entities = self.get_entities() + command = default + + for entity in entities: + if not (entity.is_bot_command()): + continue + + offset = entity.offset + length = entity.length + command = text[offset:offset + length] + + # ignore next commands + break + + return command + + def guess_bot_command(self, default=CommandsNames.HELP) -> str: + """ + Tries to guess which bot command + user assumed based on a message. + + :param default: Default command which will be + returned if unable to guess. + + :returns: Guessed bot command based on a message. + """ + command = default + + if ("photo" in self.raw_data): + command = CommandsNames.UPLOAD_PHOTO + elif ("document" in self.raw_data): + command = CommandsNames.UPLOAD_FILE + elif ("audio" in self.raw_data): + command = CommandsNames.UPLOAD_AUDIO + elif ("video" in self.raw_data): + command = CommandsNames.UPLOAD_VIDEO + elif ("voice" in self.raw_data): + command = CommandsNames.UPLOAD_VOICE + + return command + + +class Request: + """ + Incoming Telegram requset through webhook. + + https://core.telegram.org/bots/api/#making-requests + """ + def __init__(self, raw_data: dict) -> None: + self.raw_data = raw_data + + def is_valid(self) -> bool: + """ + :returns: Incoming data is valid for handling. + """ + return ( + isinstance( + self.raw_data.get("update_id"), + int + ) and + ( + isinstance( + self.raw_data.get("message"), + dict + ) or + isinstance( + self.raw_data.get("edited_message"), + dict + ) + ) + ) + + def get_message(self) -> Message: + """ + :returns: Telegram user message. + """ + raw_data = ( + self.raw_data.get("message") or + self.raw_data.get("edited_message") + ) + message = Message(raw_data) + + return message diff --git a/src/blueprints/telegram_bot/webhook/views.py b/src/blueprints/telegram_bot/webhook/views.py index fad5839..9bf07e3 100644 --- a/src/blueprints/telegram_bot/webhook/views.py +++ b/src/blueprints/telegram_bot/webhook/views.py @@ -1,5 +1,3 @@ -from typing import Union - from flask import ( request, g, @@ -7,8 +5,7 @@ ) from src.blueprints.telegram_bot import telegram_bot_blueprint as bp -from . import commands -from .commands.common.names import CommandNames +from . import commands, telegram_interface @bp.route("/webhook", methods=["POST"]) @@ -16,211 +13,50 @@ def webhook(): """ Handles Webhook POST request from Telegram server. - For Webhook we always should return 200 to indicate + - for Webhook we always should return 200 to indicate that we successfully got an update, otherwise Telegram will flood the server. So, not use `abort()` or anything. """ - data = request.get_json( + raw_data = request.get_json( force=True, silent=True, cache=False ) - if (data is None): - return error_response() + if (raw_data is None): + return make_error_response() + + telegram_request = telegram_interface.Request(raw_data) - if (not data_is_valid(data)): - return error_response() + if not (telegram_request.is_valid()): + return make_error_response() - message = get_message(data) + message = telegram_request.get_message() - if (not message_is_valid(message)): - return error_response() + if not (message.is_valid()): + return make_error_response() + g.telegram_message = message + g.telegram_user = message.get_user() + g.telegram_chat = message.get_chat() g.route_to = route_command - g.incoming_message = message - g.incoming_user = message["from"] - g.incoming_chat = message["chat"] - entities = get_entities(message) - message_text = get_text(message) - command = None + command = message.get_bot_command("") - if (message_text): - command = get_command(entities, message_text) - else: - command = guess_command(message) + if not (command): + command = message.guess_bot_command() route_command(command) - return success_response() - - -def data_is_valid(data: dict) -> bool: - """ - Checks submitted data for correctness. - """ - return ( - isinstance( - data.get("update_id"), - int - ) and - ( - isinstance( - data.get("message"), - dict - ) or - isinstance( - data.get("edited_message"), - dict - ) - ) - ) - - -def get_message(data: dict) -> dict: - """ - Extracts user message from submitted data. - """ - return ( - data.get("message") or - data.get("edited_message") - ) - - -def message_is_valid(message: dict) -> bool: - """ - Checks extracted message for correctness. - """ - return ( - isinstance( - message.get("message_id"), - int - ) and - isinstance( - message.get("from"), - dict - ) and - isinstance( - message["from"].get("id"), - int - ) and - isinstance( - message.get("chat"), - dict - ) and - isinstance( - message["chat"].get("id"), - int - ) and - isinstance( - message["chat"].get("type"), - str - ) - ) - - -def get_text(message: dict) -> str: - """ - Extracts text from a message. - """ - return ( - message.get("text") or - message.get("caption") or - "" - ) - - -def get_entities(message: dict) -> list: - """ - Extracts entities from a message. - """ - return ( - message.get("entities") or - message.get("caption_entities") or - [] - ) - - -def entity_is_valid(entity: dict) -> bool: - """ - Checks single entity for correctness. - """ - return ( - isinstance( - entity.get("type"), - str - ) and - isinstance( - entity.get("offset"), - int - ) and - isinstance( - entity.get("length"), - int - ) - ) + return make_success_response() -def get_command( - entities: list, - message_text: str, - default=CommandNames.HELP -) -> str: - """ - Extracts bot command from entities. - - Note: first command will be returned, even if the list - contains more than one command. - - :param entities: Message entities. - :param message_text: Message text. - :param default: Default command which will be returned if - in message no any bot commands. - """ - command = default - - for entity in entities: - if (not entity_is_valid(entity)): - continue - - if (not entity["type"] == "bot_command"): - continue - - offset = entity["offset"] - length = entity["length"] - command = message_text[offset:offset + length] - - # ignore next commands - break - - return command - - -def guess_command(message: dict, default=CommandNames.HELP) -> str: - """ - Tries to guess which command user assumed based on message. - """ - command = default - - if ("photo" in message): - command = CommandNames.UPLOAD_PHOTO - elif ("document" in message): - command = CommandNames.UPLOAD_FILE - elif ("audio" in message): - command = CommandNames.UPLOAD_AUDIO - elif ("video" in message): - command = CommandNames.UPLOAD_VIDEO - elif ("voice" in message): - command = CommandNames.UPLOAD_VOICE - - return command - - -def route_command(command: Union[str, CommandNames]) -> None: +def route_command(command: str) -> None: """ Routes command to specific handler. """ + CommandNames = commands.CommandsNames + if (isinstance(command, CommandNames)): command = command.value @@ -243,7 +79,7 @@ def route_command(command: Union[str, CommandNames]) -> None: method() -def error_response(): +def make_error_response(): """ Creates error response for Telegram Webhook. """ @@ -256,7 +92,7 @@ def error_response(): )) -def success_response(): +def make_success_response(): """ Creates success response for Telegram Webhook. """ diff --git a/src/configs/flask.py b/src/configs/flask.py index f8bd26d..6f22b77 100644 --- a/src/configs/flask.py +++ b/src/configs/flask.py @@ -64,6 +64,11 @@ class Config: # Project # name of app that will be used in HTML and so on PROJECT_APP_NAME = "Yandex.Disk Telegram Bot" + PROJECT_AUTHOR = "Sergey Kuznetsov" + PROJECT_URL_FOR_CODE = "https://github.com/Amaimersion/yandex-disk-telegram-bot" # noqa + PROJECT_URL_FOR_ISSUE = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=bug-report.md" # noqa + PROJECT_URL_FOR_REQUEST = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=feature-request.md" # noqa + PROJECT_URL_FOR_QUESTION = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=question.md" # noqa class ProductionConfig(Config): diff --git a/src/localization/languages.py b/src/localization/languages.py index 92839d8..d7f3bd9 100644 --- a/src/localization/languages.py +++ b/src/localization/languages.py @@ -7,3 +7,20 @@ class SupportedLanguages(IntEnum): Languages supported by app. """ EN = 1 + + @staticmethod + def get(ietf_tag: str) -> int: + """ + Return language by IETF language tag. + + - "EN" will be returned, if specified + language not supported. + """ + ietf_tag = ietf_tag.lower() + languages = { + "en": SupportedLanguages.EN, + "en-us": SupportedLanguages.EN, + "en-gb": SupportedLanguages.EN + } + + return languages.get(ietf_tag, SupportedLanguages.EN) From f04e410880c4e64034a05d12fce68cd088b927c1 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 11:07:34 +0300 Subject: [PATCH 40/75] Fix incorrect usage if environ inctead of current_app.config --- .../telegram_bot/webhook/commands/about.py | 14 ++++++-------- .../telegram_bot/webhook/commands/yd_auth.py | 3 +-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index 2cd1efb..452c817 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -1,6 +1,4 @@ -from os import environ - -from flask import g, url_for +from flask import g, url_for, current_app from src.api import telegram @@ -16,23 +14,23 @@ def handle(): "I'm free and open-source bot that allows " "you to interact with Yandex.Disk through Telegram." "\n\n" - f"Written by {environ['PROJECT_AUTHOR']}" + f"Written by {current_app.config['PROJECT_AUTHOR']}" "\n" - f"{environ['PROJECT_URL_FOR_CODE']}" + f"{current_app.config['PROJECT_URL_FOR_CODE']}" ), reply_markup=[ [ { "text": "Post an issue", - "url": environ["PROJECT_URL_FOR_ISSUE"] + "url": current_app.config["PROJECT_URL_FOR_ISSUE"] }, { "text": "Request a feature", - "url": environ["PROJECT_URL_FOR_REQUEST"] + "url": current_app.config["PROJECT_URL_FOR_REQUEST"] }, { "text": "Ask a question", - "url": environ["PROJECT_URL_FOR_QUESTION"] + "url": current_app.config["PROJECT_URL_FOR_QUESTION"] } ], [ diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index d26b446..087e202 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -1,4 +1,3 @@ -from os import environ import secrets from flask import ( @@ -160,7 +159,7 @@ def handle(): f"{CommandsNames.YD_REVOKE.value} or in your " '<a href="https://passport.yandex.ru/profile">Yandex Profile</a>. ' "By the way, i'm " - f'<a href="{environ["PROJECT_URL_FOR_CODE"]}">open-source</a> ' + f'<a href="{current_app.config["PROJECT_URL_FOR_CODE"]}">open-source</a> ' # noqa "and you can make sure that your data will be safe. " "You can even create your own bot with my functionality if using " "me makes you feel uncomfortable (:" From 52ee6f96d660a6f74a258dc8645528ecd6ae8476 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 11:27:33 +0300 Subject: [PATCH 41/75] Fix incorrect usage of reply_markup Telegram parameter --- src/blueprints/telegram_bot/webhook/commands/about.py | 4 ++-- src/blueprints/telegram_bot/webhook/commands/yd_auth.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index 452c817..d733c13 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -18,7 +18,7 @@ def handle(): "\n" f"{current_app.config['PROJECT_URL_FOR_CODE']}" ), - reply_markup=[ + reply_markup={"inline_keyboard": [ [ { "text": "Post an issue", @@ -43,5 +43,5 @@ def handle(): "url": url_for("legal.terms_and_conditions") } ] - ] + ]} ) diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index 087e202..f8fde4a 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -168,14 +168,14 @@ def handle(): f'<a href="{url_for("legal.privacy_policy")}">Privacy Policy</a> and ' # noqa f'<a href="{url_for("legal.terms_and_conditions")}">Terms And Conditions</a>. ' # noqa ), - reply_markup=[ + reply_markup={"inline_keyboard": [ [ { "text": "Grant access", "url": yandex_oauth_url } ] - ] + ]} ) From f7a17d56c8a625121b9bbe1861b414c3b9de33ab Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 12:06:44 +0300 Subject: [PATCH 42/75] Add SERVER_NAME env variable --- CHANGELOG.md | 4 ++++ README.md | 25 ++++++++++++++++++++----- src/configs/flask.py | 1 + 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de4ff78..d342c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ - Big refactoring of everything. - File structure. +### Added + +- `SERVER_NAME` environment variable. + ### Changed - Decreased size of slug on Heroku by removing unused files and folders. diff --git a/README.md b/README.md index eeeaca7..229e7c5 100644 --- a/README.md +++ b/README.md @@ -200,15 +200,30 @@ heroku addons:create heroku-postgresql:hobby-dev Later you can view the DB content by using `heroku pg:psql`. -5. Switch to new branch special for Heroku (don't ever push it!): +5. Set required environment variables: + +``` +heroku config:set SERVER_NAME=<your host without scheme> +``` + +You may also want to set recommended environment variables: + +``` +heroku config:set NGINX_WORKERS=<value> +heroku config:set NGINX_WORKER_CONNECTIONS=<value> +heroku config:set GUNICORN_WORKERS=<value> +heroku config:set GUNICORN_WORKER_CONNECTIONS=<value> +``` + +6. Switch to new branch special for Heroku (don't ever push it!): ```git git checkout -b heroku ``` -6. Make sure `.env` file is created and filled. Remove it from `.gitignore`. Don't forget: don't ever push it anywhere but Heroku. +7. Make sure `.env` file is created and filled. Remove it from `.gitignore`. Don't forget: don't ever push it anywhere but Heroku. -7. Add changes for pushing to Heroku: +8. Add changes for pushing to Heroku: - if you edited files on heroku branch: ```git @@ -221,13 +236,13 @@ git commit -m <message> git merge <another branch> -m <message> ``` -8. Upload files to Heroku: +9. Upload files to Heroku: ```git git push heroku heroku:master ``` -You should do № 7 and № 8 every time you want to push changes. +You should do № 8 and № 9 every time you want to push changes. ## Contribution diff --git a/src/configs/flask.py b/src/configs/flask.py index 6f22b77..5605c16 100644 --- a/src/configs/flask.py +++ b/src/configs/flask.py @@ -75,6 +75,7 @@ class ProductionConfig(Config): DEBUG = False TESTING = False USE_X_SENDFILE = True + SERVER_NAME = os.getenv("SERVER_NAME") PREFERRED_URL_SCHEME = "https" From 3e937978263834464c51eecd7ec6c3a251acec8c Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 12:10:13 +0300 Subject: [PATCH 43/75] Fix incorrect usage of url_for in bot commands --- src/blueprints/telegram_bot/webhook/commands/about.py | 4 ++-- src/blueprints/telegram_bot/webhook/commands/yd_auth.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index d733c13..2739203 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -36,11 +36,11 @@ def handle(): [ { "text": "Privacy Policy", - "url": url_for("legal.privacy_policy") + "url": url_for("legal.privacy_policy", _external=True) }, { "text": "Terms And Conditions", - "url": url_for("legal.terms_and_conditions") + "url": url_for("legal.terms_and_conditions", _external=True) # noqa } ] ]} diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index f8fde4a..409599c 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -165,8 +165,8 @@ def handle(): "me makes you feel uncomfortable (:" "\n\n" "By using me, you accept " - f'<a href="{url_for("legal.privacy_policy")}">Privacy Policy</a> and ' # noqa - f'<a href="{url_for("legal.terms_and_conditions")}">Terms And Conditions</a>. ' # noqa + f'<a href="{url_for("legal.privacy_policy", _external=True)}">Privacy Policy</a> and ' # noqa + f'<a href="{url_for("legal.terms_and_conditions", _external=True)}">Terms And Conditions</a>. ' # noqa ), reply_markup={"inline_keyboard": [ [ From ebca0c107c58c52de494e6b7c0437706987537fb Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 12:42:32 +0300 Subject: [PATCH 44/75] Add absolute_url_for method and replace wit it Flask url_for in bot commands --- .../telegram_bot/webhook/commands/about.py | 7 +++--- .../telegram_bot/webhook/commands/yd_auth.py | 10 ++++---- src/blueprints/utils.py | 23 +++++++++++++++++++ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 src/blueprints/utils.py diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index 2739203..aefc55d 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -1,6 +1,7 @@ -from flask import g, url_for, current_app +from flask import g, current_app from src.api import telegram +from src.blueprints.utils import absolute_url_for def handle(): @@ -36,11 +37,11 @@ def handle(): [ { "text": "Privacy Policy", - "url": url_for("legal.privacy_policy", _external=True) + "url": absolute_url_for("legal.privacy_policy") }, { "text": "Terms And Conditions", - "url": url_for("legal.terms_and_conditions", _external=True) # noqa + "url": absolute_url_for("legal.terms_and_conditions") } ] ]} diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index 409599c..a50001d 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -2,8 +2,7 @@ from flask import ( g, - current_app, - url_for + current_app ) import jwt @@ -12,6 +11,9 @@ YandexDiskToken ) from src.api import telegram, yandex +from src.blueprints.utils import ( + absolute_url_for +) from src.blueprints.telegram_bot.utils import ( get_current_datetime ) @@ -165,8 +167,8 @@ def handle(): "me makes you feel uncomfortable (:" "\n\n" "By using me, you accept " - f'<a href="{url_for("legal.privacy_policy", _external=True)}">Privacy Policy</a> and ' # noqa - f'<a href="{url_for("legal.terms_and_conditions", _external=True)}">Terms And Conditions</a>. ' # noqa + f'<a href="{absolute_url_for("legal.privacy_policy")}">Privacy Policy</a> and ' # noqa + f'<a href="{absolute_url_for("legal.terms_and_conditions")}">Terms And Conditions</a>. ' # noqa ), reply_markup={"inline_keyboard": [ [ diff --git a/src/blueprints/utils.py b/src/blueprints/utils.py new file mode 100644 index 0000000..facab00 --- /dev/null +++ b/src/blueprints/utils.py @@ -0,0 +1,23 @@ +from flask import ( + current_app, + url_for +) + + +def absolute_url_for(endpoint: str, **kwargs) -> str: + """ + Implements Flask `url_for`, but by default + creates absolute URL (`_external` and `_scheme`) with + `PREFERRED_URL_SCHEME` scheme. + + - you can specify these parameters to change behavior. + + https://flask.palletsprojects.com/en/1.1.x/api/#flask.url_for + """ + if ("_external" not in kwargs): + kwargs["_external"] = True + + if ("_scheme" not in kwargs): + kwargs["_scheme"] = current_app.config["PREFERRED_URL_SCHEME"] + + return url_for(endpoint, **kwargs) From 9cf172f40664fac3f8a18351fcafafc5f465f997 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 12:44:25 +0300 Subject: [PATCH 45/75] Refactoring of utils in blueprints --- src/blueprints/telegram_bot/utils.py | 17 ----------------- .../telegram_bot/webhook/commands/yd_auth.py | 4 +--- .../telegram_bot/webhook/commands/yd_revoke.py | 2 +- src/blueprints/utils.py | 18 ++++++++++++++++++ 4 files changed, 20 insertions(+), 21 deletions(-) delete mode 100644 src/blueprints/telegram_bot/utils.py diff --git a/src/blueprints/telegram_bot/utils.py b/src/blueprints/telegram_bot/utils.py deleted file mode 100644 index 770bbdd..0000000 --- a/src/blueprints/telegram_bot/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -from datetime import datetime, timezone - - -def get_current_datetime() -> dict: - """ - :returns: Information about current date and time. - """ - current_datetime = datetime.now(timezone.utc) - current_date = current_datetime.strftime("%d.%m.%Y") - current_time = current_datetime.strftime("%H:%M:%S") - current_timezone = current_datetime.strftime("%Z") - - return { - "date": current_date, - "time": current_time, - "timezone": current_timezone - } diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index a50001d..c1f36f3 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -12,9 +12,7 @@ ) from src.api import telegram, yandex from src.blueprints.utils import ( - absolute_url_for -) -from src.blueprints.telegram_bot.utils import ( + absolute_url_for, get_current_datetime ) from .common.decorators import ( diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py index 1e8736a..08dafea 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py @@ -2,7 +2,7 @@ from src.database import db from src.api import telegram -from src.blueprints.telegram_bot.utils import get_current_datetime +from src.blueprints.utils import get_current_datetime from .common.decorators import get_db_data from .common.responses import request_private_chat from . import CommandsNames diff --git a/src/blueprints/utils.py b/src/blueprints/utils.py index facab00..60d4ca8 100644 --- a/src/blueprints/utils.py +++ b/src/blueprints/utils.py @@ -1,3 +1,5 @@ +from datetime import datetime, timezone + from flask import ( current_app, url_for @@ -21,3 +23,19 @@ def absolute_url_for(endpoint: str, **kwargs) -> str: kwargs["_scheme"] = current_app.config["PREFERRED_URL_SCHEME"] return url_for(endpoint, **kwargs) + + +def get_current_datetime() -> dict: + """ + :returns: Information about current date and time. + """ + current_datetime = datetime.now(timezone.utc) + current_date = current_datetime.strftime("%d.%m.%Y") + current_time = current_datetime.strftime("%H:%M:%S") + current_timezone = current_datetime.strftime("%Z") + + return { + "date": current_date, + "time": current_time, + "timezone": current_timezone + } From 4b36c353db91982ef2628ee88f9e89972ec9ca2a Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 12:50:38 +0300 Subject: [PATCH 46/75] Add absolute_url_for method and replace it with Flask url_for in all python files --- src/app.py | 5 ++++- src/blueprints/legal/views.py | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app.py b/src/app.py index 3a47dbe..c18d839 100644 --- a/src/app.py +++ b/src/app.py @@ -16,6 +16,9 @@ telegram_bot_blueprint, legal_blueprint ) +from .blueprints.utils import ( + absolute_url_for +) def create_app(config_name: str = None) -> Flask: @@ -73,7 +76,7 @@ def configure_redirects(app: Flask) -> None: @app.route("/favicon.ico") def favicon(): return redirect( - url_for( + absolute_url_for( "static", filename="favicons/favicon.ico" ) diff --git a/src/blueprints/legal/views.py b/src/blueprints/legal/views.py index 4f0d171..3e04e0d 100644 --- a/src/blueprints/legal/views.py +++ b/src/blueprints/legal/views.py @@ -1,12 +1,13 @@ from flask import redirect, url_for +from src.blueprints.utils import absolute_url_for from src.blueprints.legal import legal_blueprint as bp @bp.route("/privacy-policy") def privacy_policy(): return redirect( - url_for( + absolute_url_for( "static", filename="legal/privacy_policy.txt" ) @@ -16,7 +17,7 @@ def privacy_policy(): @bp.route("/terms-and-conditions") def terms_and_conditions(): return redirect( - url_for( + absolute_url_for( "static", filename="legal/terms_and_conditions.txt" ) From f0a4c8639e54bdee9f9aa4e7d03ec1eb1c14781d Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 12:53:08 +0300 Subject: [PATCH 47/75] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d342c62..19552c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Decreased size of slug on Heroku by removing unused files and folders. - Decreased number of seconds for Yandex.OAuth request timeout. +- Redirects and generated URL's always will be absolute URL's with `PREFERRED_URL_SCHEME + SERVER_NAME`. # 1.0.0 (April 15, 2020) From 8343718e318eeca0102a5b06f052fb67a27c312a Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 12:58:31 +0300 Subject: [PATCH 48/75] Improve text of /help command --- src/blueprints/telegram_bot/webhook/commands/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/help.py b/src/blueprints/telegram_bot/webhook/commands/help.py index 6da6734..e017f98 100644 --- a/src/blueprints/telegram_bot/webhook/commands/help.py +++ b/src/blueprints/telegram_bot/webhook/commands/help.py @@ -48,7 +48,7 @@ def handle(): "\n\n" "<b>Yandex.Disk Access</b>" "\n" - f"{CommandsNames.YD_AUTH.value} — give me an access to your Yandex.Disk" + f"{CommandsNames.YD_AUTH.value} — grant me access to your Yandex.Disk" "\n" f"{CommandsNames.YD_REVOKE.value} — revoke my access to your Yandex.Disk" "\n\n" From 40a18284dd54aac962a3c58d74a15d0204e75d55 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 13:06:10 +0300 Subject: [PATCH 49/75] Improve /about message --- src/blueprints/telegram_bot/webhook/commands/about.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index aefc55d..afc5c2c 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -16,8 +16,6 @@ def handle(): "you to interact with Yandex.Disk through Telegram." "\n\n" f"Written by {current_app.config['PROJECT_AUTHOR']}" - "\n" - f"{current_app.config['PROJECT_URL_FOR_CODE']}" ), reply_markup={"inline_keyboard": [ [ @@ -35,6 +33,10 @@ def handle(): } ], [ + { + "text": "Source code", + "url": current_app.config['PROJECT_URL_FOR_CODE'] + }, { "text": "Privacy Policy", "url": absolute_url_for("legal.privacy_policy") From 4811b70721a5ffff35b3fff59957d04ff001ff01 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 13:09:32 +0300 Subject: [PATCH 50/75] Improve /revoke_access message --- src/blueprints/telegram_bot/webhook/commands/yd_revoke.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py index 08dafea..ff113b0 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_revoke.py @@ -56,6 +56,7 @@ def handle(): "You successfully revoked my access to your Yandex.Disk " f"on {date} at {time} {timezone}." "\n\n" - "Don't forget to do that at https://passport.yandex.ru/profile" + "Don't forget to do that in your " + '<a href="https://passport.yandex.ru/profile">Yandex Profile</a>.' ) ) From f25087fb8dfa2268a534f2f9d63f3500308e07b0 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 13:18:19 +0300 Subject: [PATCH 51/75] Fix typo --- src/blueprints/telegram_bot/webhook/commands/upload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index df721e7..8bfdaf4 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -145,7 +145,7 @@ def upload(self) -> None: def long_task(): try: for status in upload_file_with_url( - access_token=user_access_token, + user_access_token=user_access_token, folder_path=folder_path, file_name=file_name, download_url=download_url From f62d4e8deba552969986875a01b470f6b29fbfd4 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 13:20:03 +0300 Subject: [PATCH 52/75] Improve message of /about command --- src/blueprints/telegram_bot/webhook/commands/about.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index afc5c2c..87f5167 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -18,6 +18,12 @@ def handle(): f"Written by {current_app.config['PROJECT_AUTHOR']}" ), reply_markup={"inline_keyboard": [ + [ + { + "text": "Source code", + "url": current_app.config['PROJECT_URL_FOR_CODE'] + } + ], [ { "text": "Post an issue", @@ -33,10 +39,6 @@ def handle(): } ], [ - { - "text": "Source code", - "url": current_app.config['PROJECT_URL_FOR_CODE'] - }, { "text": "Privacy Policy", "url": absolute_url_for("legal.privacy_policy") From 3487661df274c9965d85b01654b20e2a759c7082 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 14:48:36 +0300 Subject: [PATCH 53/75] Refactoring of commands/upload.py --- .../telegram_bot/webhook/commands/upload.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index 8bfdaf4..d05282c 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -206,7 +206,9 @@ def send_message(self, chat_id: int, text: str) -> None: will be updated with new text. """ if (self.sended_message is None): + incoming_message = g.telegram_message result = telegram.send_message( + reply_to_message_id=incoming_message.id, chat_id=chat_id, text=text ) @@ -214,11 +216,11 @@ def send_message(self, chat_id: int, text: str) -> None: result["content"] ) else: - telegram.edit_message_text( - chat_id=chat_id, - message_id=self.sended_message.message_id, - text=text - ) + if (text != self.sended_message.get_text()): + telegram.edit_message_text( + message_id=self.sended_message.message_id, + text=text + ) class PhotoHandler(AttachmentHandler): From a099995da4ac24214a613bc6f1be466ba45460df Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 15:21:42 +0300 Subject: [PATCH 54/75] Refactoring of commands/upload.py --- .../telegram_bot/webhook/commands/upload.py | 41 +++++++------------ 1 file changed, 15 insertions(+), 26 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index d05282c..6bf8dd7 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -150,40 +150,28 @@ def long_task(): file_name=file_name, download_url=download_url ): - self.send_message( - chat.telegram_id, - f"Status: {status}" - ) + self.send_message(f"Status: {status}") except YandexAPICreateFolderError as error: error_text = str(error) or ( "I can't create default upload folder " "due to an unknown Yandex error." ) - return self.send_message( - chat.telegram_id, - error_text - ) + return self.send_message(error_text) except YandexAPIUploadFileError as error: error_text = str(error) or ( "I can't upload this due " "to an unknown Yandex error." ) - return self.send_message( - chat.telegram_id, - error_text - ) + return self.send_message(error_text) except YandexAPIExceededNumberOfStatusChecksError: error_text = ( "I can't track operation status of " "this anymore. Perform manual checking." ) - return self.send_message( - chat.telegram_id, - error_text - ) + return self.send_message(error_text) except (YandexAPIRequestError, Exception) as error: print(error) sended_message_id = None @@ -198,29 +186,30 @@ def long_task(): long_task() - def send_message(self, chat_id: int, text: str) -> None: + def send_message(self, text: str) -> None: """ Sends message to Telegram user. - if message already was sent, then sent message will be updated with new text. """ + incoming_message = g.telegram_message + chat = g.db_chat + if (self.sended_message is None): - incoming_message = g.telegram_message result = telegram.send_message( - reply_to_message_id=incoming_message.id, - chat_id=chat_id, + reply_to_message_id=incoming_message.message_id, + chat_id=chat.telegram_id, text=text ) self.sended_message = telegram_interface.Message( result["content"] ) - else: - if (text != self.sended_message.get_text()): - telegram.edit_message_text( - message_id=self.sended_message.message_id, - text=text - ) + elif (text != self.sended_message.get_text()): + telegram.edit_message_text( + message_id=self.sended_message.message_id, + text=text + ) class PhotoHandler(AttachmentHandler): From 8b69ff3cac5dcf3c250b774a292badcb9cbac038 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 15:53:04 +0300 Subject: [PATCH 55/75] Add chat_id param in Telegram editMessageText call in upload handler --- src/blueprints/telegram_bot/webhook/commands/upload.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index 6bf8dd7..202767e 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -208,6 +208,7 @@ def send_message(self, text: str) -> None: elif (text != self.sended_message.get_text()): telegram.edit_message_text( message_id=self.sended_message.message_id, + chat_id=chat.telegram_id, text=text ) From 44dcfb9bd5663704c0174a0be2ec5110cc9bfd4b Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 16:08:22 +0300 Subject: [PATCH 56/75] Upgrade gevent package to 1.5.0 --- CHANGELOG.md | 1 + requirements.txt | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19552c7..ee0d5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Big refactoring of everything. - File structure. +- Upgrade `gevent` to 1.5.0. ### Added diff --git a/requirements.txt b/requirements.txt index 82cde38..f225ee4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ flake8==3.7.9 Flask==1.1.1 Flask-Migrate==2.5.3 Flask-SQLAlchemy==2.4.1 -gevent==1.4.0 +gevent==1.5.0 greenlet==0.4.15 gunicorn==20.0.4 idna==2.9 From e68dd83beffb411765345ed9226e694870171b55 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 16:28:32 +0300 Subject: [PATCH 57/75] Refactoring of abort and cancel responses --- .../webhook/commands/common/responses.py | 42 +++++++++++++------ .../telegram_bot/webhook/commands/upload.py | 18 ++++---- 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/common/responses.py b/src/blueprints/telegram_bot/webhook/commands/common/responses.py index 2dfb7c2..7dffb69 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/responses.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/responses.py @@ -3,14 +3,17 @@ def abort_command( chat_telegram_id: int, - message_telegram_id: int = None + edit_message: int = None, + reply_to_message: int = None ) -> None: """ Aborts command execution due to invalid message data. - don't confuse with `cancel_command()`. - - if `message_telegram_id` specified, then - that message will be updated. + - if `edit_message` Telegram ID specified, then + that message will be edited. + - if `reply_to_message` Telegram ID specified, then + that message will be used for reply message. """ text = ( "I can't handle this because " @@ -18,29 +21,38 @@ def abort_command( "for that command." ) - if (message_telegram_id is None): + if (edit_message is not None): + telegram.edit_message_text( + chat_id=chat_telegram_id, + message_id=edit_message, + text=text + ) + elif (reply_to_message is not None): telegram.send_message( chat_id=chat_telegram_id, + reply_to_message_id=reply_to_message, text=text ) else: - telegram.edit_message_text( + telegram.send_message( chat_id=chat_telegram_id, - message_id=message_telegram_id, text=text ) def cancel_command( chat_telegram_id: int, - message_telegram_id: int = None + edit_message: int = None, + reply_to_message: int = None ) -> None: """ Cancels command execution due to internal server error. - don't confuse with `abort_command()`. - - if `message_telegram_id` specified, then - that message will be updated. + - if `edit_message` Telegram ID specified, then + that message will be edited. + - if `reply_to_message` Telegram ID specified, then + that message will be used for reply message. """ text = ( "At the moment i can't process this " @@ -48,15 +60,21 @@ def cancel_command( "Try later please." ) - if (message_telegram_id is None): + if (edit_message is not None): + telegram.edit_message_text( + chat_id=chat_telegram_id, + message_id=edit_message, + text=text + ) + elif (reply_to_message is not None): telegram.send_message( chat_id=chat_telegram_id, + reply_to_message_id=reply_to_message, text=text ) else: - telegram.edit_message_text( + telegram.send_message( chat_id=chat_telegram_id, - message_id=message_telegram_id, text=text ) diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index 202767e..3b192d0 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -174,15 +174,17 @@ def long_task(): return self.send_message(error_text) except (YandexAPIRequestError, Exception) as error: print(error) - sended_message_id = None - if (self.sended_message is not None): - sended_message_id = self.sended_message.message_id - - return cancel_command( - chat.telegram_id, - sended_message_id - ) + if (self.sended_message is None): + return cancel_command( + chat.telegram_id, + reply_to_message=message.message_id + ) + else: + return cancel_command( + chat.telegram_id, + edit_message=self.sended_message.message_id + ) long_task() From 5d6b01e569006b02bca6538c4c9bf4526ea8e2e4 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 16:42:02 +0300 Subject: [PATCH 58/75] Improve text of /about command --- src/blueprints/telegram_bot/webhook/commands/about.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index 87f5167..f2d63ec 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -26,7 +26,7 @@ def handle(): ], [ { - "text": "Post an issue", + "text": "Report a problem", "url": current_app.config["PROJECT_URL_FOR_ISSUE"] }, { From f44e853db3fcfbc041bb7c8104c71a3240ab30f9 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 16:46:15 +0300 Subject: [PATCH 59/75] Refactoring of yd_auth.py --- src/blueprints/telegram_bot/webhook/commands/yd_auth.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index c1f36f3..23ff28e 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -136,13 +136,14 @@ def handle(): insert_token_lifetime = int( yd_token.insert_token_expires_in / 60 ) + open_link_button_text = "Grant access" telegram.send_message( chat_id=private_chat.telegram_id, parse_mode="HTML", disable_web_page_preview=True, text=( - 'Open special link by pressing on "Grant access" ' + f'Open special link by pressing on "{open_link_button_text}" ' "button and grant me access to your Yandex.Disk." "\n\n" "<b>IMPORTANT: don't give this link to anyone, " @@ -171,7 +172,7 @@ def handle(): reply_markup={"inline_keyboard": [ [ { - "text": "Grant access", + "text": open_link_button_text, "url": yandex_oauth_url } ] From 8bd7ffec09a65b2d317699450a33d241ec277239 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 17:07:02 +0300 Subject: [PATCH 60/75] Fix a buf when YandexAPIExceededNumberOfStatusChecksError error could be thrown even if the operation was completed --- CHANGELOG.md | 4 ++++ .../telegram_bot/webhook/commands/common/yandex_api.py | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee0d5e7..5cd2ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ - Favicon. - Button with URL instead of plain text with URL in `/grant_access` command. +### Fixed + +- A bug when sometimes "I can't track operation status of this anymore. Perform manual checking." message could appear even if operation was completed. + ## Project ### Improved diff --git a/src/blueprints/telegram_bot/webhook/commands/common/yandex_api.py b/src/blueprints/telegram_bot/webhook/commands/common/yandex_api.py index a5a61dc..edeba79 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/yandex_api.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/yandex_api.py @@ -189,6 +189,7 @@ def upload_file_with_url( attempt += 1 is_error = is_error_yandex_response(operation_status) + is_completed = yandex_operation_is_completed(operation_status) if (is_error): raise YandexAPIUploadFileError( @@ -196,7 +197,10 @@ def upload_file_with_url( operation_status ) ) - elif (attempt >= max_attempts): + elif ( + (attempt >= max_attempts) and + not is_completed + ): raise YandexAPIExceededNumberOfStatusChecksError() From 133c9250c47f1f04b11dee33fdfc2eef5629a462 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Wed, 29 Apr 2020 23:00:19 +0300 Subject: [PATCH 61/75] Rename templates/yandex_disk_auth to yd_auth --- src/blueprints/telegram_bot/yd_auth/views.py | 4 ++-- .../telegram_bot/{yandex_disk_auth => yd_auth}/error.html | 0 .../telegram_bot/{yandex_disk_auth => yd_auth}/success.html | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/templates/telegram_bot/{yandex_disk_auth => yd_auth}/error.html (100%) rename src/templates/telegram_bot/{yandex_disk_auth => yd_auth}/success.html (100%) diff --git a/src/blueprints/telegram_bot/yd_auth/views.py b/src/blueprints/telegram_bot/yd_auth/views.py index e9a71b0..9e97c6e 100644 --- a/src/blueprints/telegram_bot/yd_auth/views.py +++ b/src/blueprints/telegram_bot/yd_auth/views.py @@ -15,8 +15,8 @@ TEMPLATES = { - "error": "telegram_bot/yandex_disk_auth/error.html", - "success": "telegram_bot/yandex_disk_auth/success.html", + "error": "telegram_bot/yd_auth/error.html", + "success": "telegram_bot/yd_auth/success.html", } diff --git a/src/templates/telegram_bot/yandex_disk_auth/error.html b/src/templates/telegram_bot/yd_auth/error.html similarity index 100% rename from src/templates/telegram_bot/yandex_disk_auth/error.html rename to src/templates/telegram_bot/yd_auth/error.html diff --git a/src/templates/telegram_bot/yandex_disk_auth/success.html b/src/templates/telegram_bot/yd_auth/success.html similarity index 100% rename from src/templates/telegram_bot/yandex_disk_auth/success.html rename to src/templates/telegram_bot/yd_auth/success.html From 1c0cfa1cd73b0b20085029312203c5f1aa3e170f Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 30 Apr 2020 23:11:16 +0300 Subject: [PATCH 62/75] Refactoring of yd_auth blueprint --- CHANGELOG.md | 4 + .../telegram_bot/yd_auth/exceptions.py | 20 + src/blueprints/telegram_bot/yd_auth/views.py | 344 +++++++++++------- .../css/telegram_bot/yd_auth/common.css | 14 + src/templates/macros.html | 18 +- src/templates/telegram_bot/yd_auth/error.html | 36 +- .../telegram_bot/yd_auth/success.html | 9 +- 7 files changed, 291 insertions(+), 154 deletions(-) create mode 100644 src/blueprints/telegram_bot/yd_auth/exceptions.py create mode 100644 src/static/css/telegram_bot/yd_auth/common.css diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd2ef1..2ab49a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Improved - Text of most bot responses. +- Text of authorization HTML pages. +- Style of authorization HTML pages. - When uploading, bot will update single message instead of sending new one every time with operation status. ### Added @@ -12,6 +14,7 @@ - Privacy Policy and Terms & Conditions. - Favicons for different platforms. - Buttons with useful links in `/help` command. +- "Report a problem" link in error authorization HTML page. ### Changed @@ -42,6 +45,7 @@ - Decreased size of slug on Heroku by removing unused files and folders. - Decreased number of seconds for Yandex.OAuth request timeout. - Redirects and generated URL's always will be absolute URL's with `PREFERRED_URL_SCHEME + SERVER_NAME`. +- If user refused to grant the access to Yandex.Disk, then user `insert_token` will be cleared after redirect. # 1.0.0 (April 15, 2020) diff --git a/src/blueprints/telegram_bot/yd_auth/exceptions.py b/src/blueprints/telegram_bot/yd_auth/exceptions.py new file mode 100644 index 0000000..a9eda46 --- /dev/null +++ b/src/blueprints/telegram_bot/yd_auth/exceptions.py @@ -0,0 +1,20 @@ +class InvalidCredentials(Exception): + """ + Provided credentials is not valid. + """ + pass + + +class LinkExpired(Exception): + """ + Link is expired and not valid anymore. + """ + pass + + +class InvalidInsertToken(Exception): + """ + Provided `insert_token` is not valid with + `insert_token` from DB. + """ + pass diff --git a/src/blueprints/telegram_bot/yd_auth/views.py b/src/blueprints/telegram_bot/yd_auth/views.py index 9e97c6e..ee4e735 100644 --- a/src/blueprints/telegram_bot/yd_auth/views.py +++ b/src/blueprints/telegram_bot/yd_auth/views.py @@ -1,5 +1,4 @@ import base64 -from datetime import datetime, timezone from flask import ( request, @@ -9,19 +8,29 @@ ) import jwt -from src.database import db, UserQuery, ChatQuery +from src.database import ( + db, + UserQuery, + ChatQuery +) from src.api import yandex, telegram from src.blueprints.telegram_bot import telegram_bot_blueprint as bp +from src.blueprints.utils import ( + get_current_datetime +) +from src.blueprints.telegram_bot.webhook.commands import CommandsNames +from .exceptions import ( + InvalidCredentials, + LinkExpired, + InvalidInsertToken +) -TEMPLATES = { - "error": "telegram_bot/yd_auth/error.html", - "success": "telegram_bot/yd_auth/success.html", -} - - -@bp.route("/yandex_disk_authorization", methods=["GET"]) -def yandex_disk_auth(): +@bp.route("/yandex_disk_authorization") +def yd_auth(): + """ + Handles user redirect from Yandex OAuth page. + """ if (is_error_request()): return handle_error() elif (is_success_request()): @@ -30,61 +39,159 @@ def yandex_disk_auth(): abort(400) -def is_error_request(): - state = request.args.get("state") - error = request.args.get("error") +def is_error_request() -> bool: + """ + :returns: Incoming request is a failed user authorization. + """ + state = request.args.get("state", "") + error = request.args.get("error", "") return ( - isinstance(state, str) and len(state) > 0 and - isinstance(error, str) and len(error) > 0 ) -def is_success_request(): - state = request.args.get("state") - code = request.args.get("code") +def is_success_request() -> bool: + """ + :returns: Incoming request is a successful user authorization. + """ + state = request.args.get("state", "") + code = request.args.get("code", "") return ( - isinstance(state, str) and len(state) > 0 and - isinstance(code, str) and len(code) > 0 ) def handle_error(): - # TODO: remove user insert token. + """ + Handles failed user authorization. + """ + try: + db_user = get_db_user() + db_user.yandex_disk_token.clear_insert_token() + db.session.commit() + except Exception: + pass - error = request.args.get("error") - error_description = request.args.get("error_description") - errors = { + return create_error_response() + + +def handle_success(): + """ + Handles success user authorization. + """ + db_user = None + + try: + db_user = get_db_user() + except InvalidCredentials: + return create_error_response("invalid_credentials") + except LinkExpired: + return create_error_response("link_expired") + except InvalidInsertToken: + return create_error_response("invalid_insert_token") + except Exception as error: + print(error) + return create_error_response("internal_server_error") + + code = request.args["code"] + yandex_response = None + + try: + yandex_response = yandex.get_access_token( + grant_type="authorization_code", + code=code + )["content"] + except Exception as error: + print(error) + return create_error_response("internal_server_error") + + if ("error" in yandex_response): + db_user.yandex_disk_token.clear_all_tokens() + db.session.commit() + + return create_error_response( + error_code="internal_server_error", + raw_error_title=yandex_response["error"], + raw_error_description=yandex_response.get("error_description") + ) + + db_user.yandex_disk_token.clear_insert_token() + db_user.yandex_disk_token.set_access_token( + yandex_response["access_token"] + ) + db_user.yandex_disk_token.access_token_type = ( + yandex_response["token_type"] + ) + db_user.yandex_disk_token.access_token_expires_in = ( + yandex_response["expires_in"] + ) + db_user.yandex_disk_token.set_refresh_token( + yandex_response["refresh_token"] + ) + db.session.commit() + + private_chat = ChatQuery.get_private_chat(db_user.id) + + if (private_chat): + current_datetime = get_current_datetime() + date = current_datetime["date"] + time = current_datetime["time"] + timezone = current_datetime["timezone"] + + telegram.send_message( + chat_id=private_chat.telegram_id, + parse_mode="HTML", + text=( + "<b>Access to Yandex.Disk Granted</b>" + "\n\n" + "My access was attached to your Telegram account " + f"on {date} at {time} {timezone}." + "\n\n" + "If it wasn't you, then detach this access with " + f"{CommandsNames.YD_REVOKE.value}" + ) + ) + + return create_success_response() + + +def create_error_response( + error_code: str = None, + raw_error_title: str = None, + raw_error_description: str = None +): + """ + :param error_code: Name of error for user friendly + information. If not specified, then defaults to + `error` argument from request. + :param raw_error_title: Raw error title for + debugging purposes. If not specified, then defaults to + `error_code` argument. + :param raw_error_description: Raw error description + for debugging purposes. If not specified, then defaults to + `error_description` argument from request. + + :returns: Rendered template for error page. + """ + possible_errors = { "access_denied": { "title": "Access Denied", - "description": "You denied the access to Yandex.Disk." + "description": ( + "You refused to grant me " + "access to your Yandex.Disk." + ) }, "unauthorized_client": { "title": "Application is unavailable", "description": ( - "There is a problems with the application. " + "There is a problems with the me. " "Try later please." ) - } - } - error_info = errors.get(error, {}) - - return render_template( - TEMPLATES["error"], - error_title=error_info.get("title"), - error_description=error_info.get("description"), - raw_error_title=error, - raw_error_description=error_description - ) - - -def handle_success(): - errors = { + }, "invalid_credentials": { "title": "Invalid credentials", "description": "Your credentials is not valid." @@ -106,11 +213,60 @@ def handle_success(): "internal_server_error": { "title": "Internal server error", "description": ( - "Some error occured on server side. " + "At the moment i can't handle you " + "because of my internal error. " "Try later please." ) } } + state = request.args.get("state") + error = request.args.get("error") + error_description = request.args.get("error_description") + + if (error_code is None): + error_code = error + + error_info = possible_errors.get(error_code, {}) + + return render_template( + "telegram_bot/yd_auth/error.html", + error_title=error_info.get("title"), + error_description=error_info.get("description"), + raw_error_title=(raw_error_title or error_code), + raw_error_description=(raw_error_description or error_description), + raw_state=state + ) + + +def create_success_response(): + """ + :returns: Rendered template for success page. + """ + return render_template( + "telegram_bot/yd_auth/success.html" + ) + + +def get_db_user(): + """ + - `insert_token` will be checked. If it is not valid, + an error will be thrown. You shouldn't clear any tokens + in case of error, because provided tokens is not known + to attacker (potential). + - you shouldn't try to avoid checking logic! It is really + unsafe to access DB user without `insert_token`. + + :returns: User from DB based on incoming `state` from request. + This user have `yandex_disk_token` property which is + not `None`. + + :raises InvalidCredentials: If `state` have invalid + data or user not found in DB. + :raises LinkExpired: Requested link is expired and + not valid anymore. + :raises InvalidInsertToken: Provided `insert_token` + is not valid. + """ base64_state = request.args["state"] encoded_state = None decoded_state = None @@ -120,11 +276,7 @@ def handle_success(): base64_state.encode() ).decode() except Exception: - return render_template( - TEMPLATES["error"], - error_title=errors["invalid_credentials"]["title"], - error_description=errors["invalid_credentials"]["description"] - ) + raise InvalidCredentials() try: decoded_state = jwt.decode( @@ -133,11 +285,7 @@ def handle_success(): algorithm="HS256" ) except Exception: - return render_template( - TEMPLATES["error"], - error_title=errors["invalid_credentials"]["title"], - error_description=errors["invalid_credentials"]["description"] - ) + raise InvalidCredentials() incoming_user_id = decoded_state.get("user_id") incoming_insert_token = decoded_state.get("insert_token") @@ -146,104 +294,26 @@ def handle_success(): incoming_user_id is None or incoming_insert_token is None ): - return render_template( - TEMPLATES["error"], - error_title=errors["invalid_credentials"]["title"], - error_description=errors["invalid_credentials"]["description"] - ) + raise InvalidCredentials() db_user = UserQuery.get_user_by_id(int(incoming_user_id)) if ( db_user is None or + # for some reason `yandex_disk_token` not created, + # it is not intended behavior. db_user.yandex_disk_token is None ): - return render_template( - TEMPLATES["error"], - error_title=errors["invalid_credentials"]["title"], - error_description=errors["invalid_credentials"]["description"] - ) + raise InvalidCredentials() db_insert_token = None try: db_insert_token = db_user.yandex_disk_token.get_insert_token() except Exception: - return render_template( - TEMPLATES["error"], - error_title=errors["link_expired"]["title"], - error_description=errors["link_expired"]["description"] - ) + raise LinkExpired() if (incoming_insert_token != db_insert_token): - return render_template( - TEMPLATES["error"], - error_title=errors["invalid_insert_token"]["title"], - error_description=errors["invalid_insert_token"]["description"] - ) + raise InvalidInsertToken() - code = request.args["code"] - yandex_response = None - - try: - yandex_response = yandex.get_access_token( - grant_type="authorization_code", - code=code - )["content"] - except Exception: - return render_template( - TEMPLATES["error"], - error_title=errors["internal_server_error"]["title"], - error_description=errors["internal_server_error"]["description"] - ) - - if ("error" in yandex_response): - db_user.yandex_disk_token.clear_all_tokens() - db.session.commit() - - return render_template( - TEMPLATES["error"], - raw_error_title=yandex_response["error"], - raw_error_description=yandex_response.get("error_description") - ) - - db_user.yandex_disk_token.clear_insert_token() - db_user.yandex_disk_token.set_access_token( - yandex_response["access_token"] - ) - db_user.yandex_disk_token.access_token_type = ( - yandex_response["token_type"] - ) - db_user.yandex_disk_token.access_token_expires_in = ( - yandex_response["expires_in"] - ) - db_user.yandex_disk_token.set_refresh_token( - yandex_response["refresh_token"] - ) - db.session.commit() - - private_chat = ChatQuery.get_private_chat(db_user.id) - - if (private_chat): - current_datetime = datetime.now(timezone.utc) - current_date = current_datetime.strftime("%d.%m.%Y") - current_time = current_datetime.strftime("%H:%M:%S") - current_timezone = current_datetime.strftime("%Z") - - telegram.send_message( - chat_id=private_chat.telegram_id, - parse_mode="HTML", - text=( - "<b>Access to Yandex.Disk Granted</b>" - "\n\n" - "Access was attached to your account " - f"on {current_date} at {current_time} {current_timezone}." - "\n\n" - "If it wasn't you, you can detach this access with " - "/revoke_access" - ) - ) - - return render_template( - TEMPLATES["success"] - ) + return db_user diff --git a/src/static/css/telegram_bot/yd_auth/common.css b/src/static/css/telegram_bot/yd_auth/common.css new file mode 100644 index 0000000..98d1c21 --- /dev/null +++ b/src/static/css/telegram_bot/yd_auth/common.css @@ -0,0 +1,14 @@ +/* Global. */ + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; +} + + +/* Main. */ + +.report-problem-container { + margin-top: 1.5rem; + font-size: .85rem; + font-style: italic; +} diff --git a/src/templates/macros.html b/src/templates/macros.html index cd08e87..d4fc002 100644 --- a/src/templates/macros.html +++ b/src/templates/macros.html @@ -1,7 +1,7 @@ {% macro css_file(file) %} <link rel="stylesheet" - href="{{ url_for('static', filename=file) }}" + href="{{ url_for('static', filename=('css/' + file)) }}" > {% endmacro %} @@ -14,6 +14,20 @@ async {% endif %} - src="{{ url_for('static', filename=file) }}" + src="{{ url_for('static', filename=('js/' + file)) }}" ></script> {% endmacro %} + + +{% macro href(url, text, target="_self") %} + <a + href="{{ url }}" + target="{{ target }}" + + {% if target == "_blank" %} + rel="noopener" + {% endif %} + >{{ + text + }}</a> +{% endmacro %} diff --git a/src/templates/telegram_bot/yd_auth/error.html b/src/templates/telegram_bot/yd_auth/error.html index e9d7d10..b27a81d 100644 --- a/src/templates/telegram_bot/yd_auth/error.html +++ b/src/templates/telegram_bot/yd_auth/error.html @@ -1,5 +1,12 @@ {% extends "html5.html" %} +{% import "macros.html" as macros %} + + +{% block head %} + {{ macros.css_file("telegram_bot/yd_auth/common.css") }} +{% endblock %} + {% block title %} Authorization Failed @@ -20,25 +27,26 @@ <h1> {% if error_description %} {{ error_description }} {% else %} - Authorization in {{ config["PROJECT_APP_NAME"] }} failed due to an unknown error. + Granting of access to {{ config["PROJECT_APP_NAME"] }} failed due to an unknown error. {% endif %} </p> {% if not error_title %} - <div> - Raw Error: - - <hr /> - - {% if raw_error_title %} - Title: {{ raw_error_title }} - {% endif %} - + <div class="report-problem-container"> + {% set issue_href = macros.href( + config['PROJECT_URL_FOR_ISSUE'], + "report a problem", + "_blank" + ) + %} + + You may {{ issue_href | trim }}. Here is a data for debugging purposes: <br /> - - {% if raw_error_description %} - Description: {{ raw_error_description }} - {% endif %} + Title: {{ raw_error_title or "?" }} + <br /> + Description: {{ raw_error_description or "?" }} + <br /> + State: {{ raw_state or "?" }} </div> {% endif %} </main> diff --git a/src/templates/telegram_bot/yd_auth/success.html b/src/templates/telegram_bot/yd_auth/success.html index 1145753..ef7e66d 100644 --- a/src/templates/telegram_bot/yd_auth/success.html +++ b/src/templates/telegram_bot/yd_auth/success.html @@ -1,5 +1,12 @@ {% extends "html5.html" %} +{% import "macros.html" as macros %} + + +{% block head %} + {{ macros.css_file("telegram_bot/yd_auth/common.css") }} +{% endblock %} + {% block title %} Successfully Authorized @@ -13,7 +20,7 @@ <h1> </h1> <p> - You've successfully authorized in {{ config["PROJECT_APP_NAME"] }}. + You've successfully granted an access to {{ config["PROJECT_APP_NAME"] }}. </p> </main> {% endblock %} From da1bd5e9f790fe903983e88897178b122a972aed Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 30 Apr 2020 23:14:51 +0300 Subject: [PATCH 63/75] Improve /about message --- src/blueprints/telegram_bot/webhook/commands/about.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/about.py b/src/blueprints/telegram_bot/webhook/commands/about.py index f2d63ec..ab7e8b4 100644 --- a/src/blueprints/telegram_bot/webhook/commands/about.py +++ b/src/blueprints/telegram_bot/webhook/commands/about.py @@ -44,7 +44,7 @@ def handle(): "url": absolute_url_for("legal.privacy_policy") }, { - "text": "Terms And Conditions", + "text": "Terms of service", "url": absolute_url_for("legal.terms_and_conditions") } ] From 6846a85a5ec59d8f17ee0b18c39dadd9e0bcb6c1 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 30 Apr 2020 23:17:09 +0300 Subject: [PATCH 64/75] Improve /grant_access message --- src/blueprints/telegram_bot/webhook/commands/yd_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py index 23ff28e..bcefc4d 100644 --- a/src/blueprints/telegram_bot/webhook/commands/yd_auth.py +++ b/src/blueprints/telegram_bot/webhook/commands/yd_auth.py @@ -167,7 +167,7 @@ def handle(): "\n\n" "By using me, you accept " f'<a href="{absolute_url_for("legal.privacy_policy")}">Privacy Policy</a> and ' # noqa - f'<a href="{absolute_url_for("legal.terms_and_conditions")}">Terms And Conditions</a>. ' # noqa + f'<a href="{absolute_url_for("legal.terms_and_conditions")}">Terms of service</a>. ' # noqa ), reply_markup={"inline_keyboard": [ [ From 7b1331466145d91922c4ffda492829c9539912b3 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 30 Apr 2020 23:38:32 +0300 Subject: [PATCH 65/75] Refactoring of Flask config --- src/configs/flask.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/configs/flask.py b/src/configs/flask.py index 5605c16..99df13a 100644 --- a/src/configs/flask.py +++ b/src/configs/flask.py @@ -17,6 +17,15 @@ class Config: So, if your configuration value can exceed 30 seconds, then request will be terminated by Heroku. """ + # Project + # name of app that will be used in HTML and so on + PROJECT_APP_NAME = "Yandex.Disk Telegram Bot" + PROJECT_AUTHOR = "Sergey Kuznetsov" + PROJECT_URL_FOR_CODE = "https://github.com/Amaimersion/yandex-disk-telegram-bot" # noqa + PROJECT_URL_FOR_ISSUE = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=bug-report.md" # noqa + PROJECT_URL_FOR_REQUEST = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=feature-request.md" # noqa + PROJECT_URL_FOR_QUESTION = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=question.md" # noqa + # Flask DEBUG = False TESTING = False @@ -61,15 +70,6 @@ class Config: # if user not specified custom folder. YANDEX_DISK_API_DEFAULT_UPLOAD_FOLDER = "Telegram Bot" - # Project - # name of app that will be used in HTML and so on - PROJECT_APP_NAME = "Yandex.Disk Telegram Bot" - PROJECT_AUTHOR = "Sergey Kuznetsov" - PROJECT_URL_FOR_CODE = "https://github.com/Amaimersion/yandex-disk-telegram-bot" # noqa - PROJECT_URL_FOR_ISSUE = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=bug-report.md" # noqa - PROJECT_URL_FOR_REQUEST = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=feature-request.md" # noqa - PROJECT_URL_FOR_QUESTION = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=question.md" # noqa - class ProductionConfig(Config): DEBUG = False From a1143c1661a329c0e2d7cbe6283288134d83d204 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 30 Apr 2020 23:48:24 +0300 Subject: [PATCH 66/75] Add Google Analytics --- .env.example | 3 +++ CHANGELOG.md | 1 + src/configs/flask.py | 3 +++ src/templates/macros.html | 16 ++++++++++++++++ src/templates/telegram_bot/yd_auth/error.html | 1 + src/templates/telegram_bot/yd_auth/success.html | 1 + 6 files changed, 25 insertions(+) diff --git a/.env.example b/.env.example index 7eb76c6..c08b934 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,6 @@ YANDEX_OAUTH_API_APP_ID= # Password of app registerd in Yandex to access Yandex OAuth API YANDEX_OAUTH_API_APP_PASSWORD= + +# Your UA for Google Analytics. Optional +GOOGLE_ANALYTICS_UA=UA-XXXX-X diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ab49a0..6fb816d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ ### Added - `SERVER_NAME` environment variable. +- Google Analytics in authorization pages. ### Changed diff --git a/src/configs/flask.py b/src/configs/flask.py index 99df13a..84987e3 100644 --- a/src/configs/flask.py +++ b/src/configs/flask.py @@ -70,6 +70,9 @@ class Config: # if user not specified custom folder. YANDEX_DISK_API_DEFAULT_UPLOAD_FOLDER = "Telegram Bot" + # Google Analytics + GOOGLE_ANALYTICS_UA = os.getenv("GOOGLE_ANALYTICS_UA") + class ProductionConfig(Config): DEBUG = False diff --git a/src/templates/macros.html b/src/templates/macros.html index d4fc002..d1cad9a 100644 --- a/src/templates/macros.html +++ b/src/templates/macros.html @@ -19,6 +19,22 @@ {% endmacro %} +{% macro google_analytics() %} + {% set ua = config.get("GOOGLE_ANALYTICS_UA") %} + + {% if ua %} + <script async src="https://www.googletagmanager.com/gtag/js?id={{ ua }}"></script> + <script> + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag("js", new Date()); + + gtag("config", "{{ ua }}"); + </script> + {% endif %} +{% endmacro %} + + {% macro href(url, text, target="_self") %} <a href="{{ url }}" diff --git a/src/templates/telegram_bot/yd_auth/error.html b/src/templates/telegram_bot/yd_auth/error.html index b27a81d..ed55170 100644 --- a/src/templates/telegram_bot/yd_auth/error.html +++ b/src/templates/telegram_bot/yd_auth/error.html @@ -5,6 +5,7 @@ {% block head %} {{ macros.css_file("telegram_bot/yd_auth/common.css") }} + {{ macros.google_analytics() }} {% endblock %} diff --git a/src/templates/telegram_bot/yd_auth/success.html b/src/templates/telegram_bot/yd_auth/success.html index ef7e66d..7330eeb 100644 --- a/src/templates/telegram_bot/yd_auth/success.html +++ b/src/templates/telegram_bot/yd_auth/success.html @@ -5,6 +5,7 @@ {% block head %} {{ macros.css_file("telegram_bot/yd_auth/common.css") }} + {{ macros.google_analytics() }} {% endblock %} From 1241a8fbb94b84395962d0c1728e003b9af81fc3 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 2 May 2020 00:19:50 +0300 Subject: [PATCH 67/75] Add authorization event in GA and remove URL parameters from being sended --- src/blueprints/telegram_bot/yd_auth/views.py | 1 + .../js/telegram_bot/yd_auth/analytics.js | 20 +++++++++++++++++++ src/templates/macros.html | 12 +++++++---- src/templates/telegram_bot/yd_auth/error.html | 3 ++- .../telegram_bot/yd_auth/success.html | 3 ++- 5 files changed, 33 insertions(+), 6 deletions(-) create mode 100644 src/static/js/telegram_bot/yd_auth/analytics.js diff --git a/src/blueprints/telegram_bot/yd_auth/views.py b/src/blueprints/telegram_bot/yd_auth/views.py index ee4e735..a5803c5 100644 --- a/src/blueprints/telegram_bot/yd_auth/views.py +++ b/src/blueprints/telegram_bot/yd_auth/views.py @@ -230,6 +230,7 @@ def create_error_response( return render_template( "telegram_bot/yd_auth/error.html", + error_code=error_code, error_title=error_info.get("title"), error_description=error_info.get("description"), raw_error_title=(raw_error_title or error_code), diff --git a/src/static/js/telegram_bot/yd_auth/analytics.js b/src/static/js/telegram_bot/yd_auth/analytics.js new file mode 100644 index 0000000..77c3924 --- /dev/null +++ b/src/static/js/telegram_bot/yd_auth/analytics.js @@ -0,0 +1,20 @@ +function main() { + const main = document.getElementsByTagName("main")[0]; + const status = main.dataset.status; + + gtag( + "event", + "yandex_disk_authorization", + { + "event_category": "authorization", + "event_label": status + } + ); +} + + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", main); +} else { + main(); +} diff --git a/src/templates/macros.html b/src/templates/macros.html index d1cad9a..ef5eaef 100644 --- a/src/templates/macros.html +++ b/src/templates/macros.html @@ -23,13 +23,17 @@ {% set ua = config.get("GOOGLE_ANALYTICS_UA") %} {% if ua %} - <script async src="https://www.googletagmanager.com/gtag/js?id={{ ua }}"></script> - <script> + <script defer src="https://www.googletagmanager.com/gtag/js?id={{ ua }}"></script> + <script defer> window.dataLayer = window.dataLayer || []; function gtag(){dataLayer.push(arguments);} - gtag("js", new Date()); - gtag("config", "{{ ua }}"); + gtag("js", new Date()); + gtag("config", "{{ ua }}", { + page_title: document.title, + page_location: window.location.origin, + page_path: window.location.pathname + }); </script> {% endif %} {% endmacro %} diff --git a/src/templates/telegram_bot/yd_auth/error.html b/src/templates/telegram_bot/yd_auth/error.html index ed55170..a6d6f15 100644 --- a/src/templates/telegram_bot/yd_auth/error.html +++ b/src/templates/telegram_bot/yd_auth/error.html @@ -6,6 +6,7 @@ {% block head %} {{ macros.css_file("telegram_bot/yd_auth/common.css") }} {{ macros.google_analytics() }} + {{ macros.js_file("telegram_bot/yd_auth/analytics.js") }} {% endblock %} @@ -15,7 +16,7 @@ {% block main %} - <main> + <main data-status="{{ error_code }}"> <h1> {% if error_title %} {{ error_title | title }} diff --git a/src/templates/telegram_bot/yd_auth/success.html b/src/templates/telegram_bot/yd_auth/success.html index 7330eeb..911076f 100644 --- a/src/templates/telegram_bot/yd_auth/success.html +++ b/src/templates/telegram_bot/yd_auth/success.html @@ -6,6 +6,7 @@ {% block head %} {{ macros.css_file("telegram_bot/yd_auth/common.css") }} {{ macros.google_analytics() }} + {{ macros.js_file("telegram_bot/yd_auth/analytics.js") }} {% endblock %} @@ -15,7 +16,7 @@ {% block main %} - <main> + <main data-status="success"> <h1> Success </h1> From 20a967c54e8826e2b18d04bf38f93dac9d973964 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 2 May 2020 11:43:01 +0300 Subject: [PATCH 68/75] Fix a bug when empty /ccreate_folder was successfully handled --- CHANGELOG.md | 1 + .../telegram_bot/webhook/commands/create_folder.py | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fb816d..18e964b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ ### Fixed - A bug when sometimes "I can't track operation status of this anymore. Perform manual checking." message could appear even if operation was completed. +- A bug when empty `/create_folder` was successfully handled. ## Project diff --git a/src/blueprints/telegram_bot/webhook/commands/create_folder.py b/src/blueprints/telegram_bot/webhook/commands/create_folder.py index 8d7cbe9..69bf7cd 100644 --- a/src/blueprints/telegram_bot/webhook/commands/create_folder.py +++ b/src/blueprints/telegram_bot/webhook/commands/create_folder.py @@ -2,7 +2,8 @@ from src.api import telegram from .common.responses import ( - cancel_command + cancel_command, + abort_command ) from .common.decorators import ( yd_access_token_required, @@ -25,12 +26,16 @@ def handle(): message = g.telegram_message user = g.db_user chat = g.db_chat - access_token = user.yandex_disk_token.get_access_token() message_text = message.get_text() folder_name = message_text.replace( CommandsNames.CREATE_FOLDER.value, "" ).strip() + + if not (folder_name): + return abort_command(chat.telegram_id) + + access_token = user.yandex_disk_token.get_access_token() last_status_code = None try: From ba9d4d4c8bbddc8584289eabc287f2bad283f63e Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 2 May 2020 12:20:30 +0300 Subject: [PATCH 69/75] Refactoring of telegram_interface.py --- .../webhook/telegram_interface.py | 50 ++++++++++++++----- src/blueprints/telegram_bot/webhook/views.py | 4 +- 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/src/blueprints/telegram_bot/webhook/telegram_interface.py b/src/blueprints/telegram_bot/webhook/telegram_interface.py index 584b3f7..274909a 100644 --- a/src/blueprints/telegram_bot/webhook/telegram_interface.py +++ b/src/blueprints/telegram_bot/webhook/telegram_interface.py @@ -1,6 +1,7 @@ from typing import ( List, - Union + Union, + Any ) from .commands import CommandsNames @@ -73,6 +74,12 @@ def is_bot_command(self) -> bool: """ return (self.type == "bot_command") + def is_url(self) -> bool: + """ + :returns: Entity is an URL. + """ + return (self.type == "url") + class Message: """ @@ -144,34 +151,51 @@ def get_entities(self) -> List[Entity]: return self.entities - def get_bot_command(self, default=CommandsNames.HELP) -> str: + def get_entity_value( + self, + entity_type: str, + default: Any = None + ) -> Any: """ - Extracts bot command from a message. + Extracts value of single entity from a message. - - first command will be returned even if message - contains more than one command. + - first value will be returned, all others + will be ignored. - :param default: Default command which will be - returned if message don't contains any bot commands. + :param entity_type: Type of entity whose value + you want to extract. See + https://core.telegram.org/bots/api/#messageentity + :param default: Default value which will be + returned if no such entities in a message. - :returns: Bot command from a message. + :returns: First value or `default`. + + :raises ValueError: If `entity_type` not supported. """ text = self.get_text() entities = self.get_entities() - command = default + value = default + checkers = { + "bot_command": lambda entity: entity.is_bot_command(), + "url": lambda entity: entity.is_url() + } + is_valid = checkers.get(entity_type) + + if (is_valid is None): + raise ValueError("Entity type not supported") for entity in entities: - if not (entity.is_bot_command()): + if not (is_valid(entity)): continue offset = entity.offset length = entity.length - command = text[offset:offset + length] + value = text[offset:offset + length] - # ignore next commands + # next values will be ignores break - return command + return value def guess_bot_command(self, default=CommandsNames.HELP) -> str: """ diff --git a/src/blueprints/telegram_bot/webhook/views.py b/src/blueprints/telegram_bot/webhook/views.py index 9bf07e3..27327b6 100644 --- a/src/blueprints/telegram_bot/webhook/views.py +++ b/src/blueprints/telegram_bot/webhook/views.py @@ -41,9 +41,9 @@ def webhook(): g.telegram_chat = message.get_chat() g.route_to = route_command - command = message.get_bot_command("") + command = message.get_entity_value("bot_command") - if not (command): + if (command is None): command = message.guess_bot_command() route_command(command) From 38ac371bf992cd272e0d5667e907ff82e8686bdd Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sun, 3 May 2020 00:12:18 +0300 Subject: [PATCH 70/75] Add new feature: uploading of file using direct URL --- CHANGELOG.md | 1 + README.md | 1 + info/info.json | 4 + .../telegram_bot/webhook/commands/__init__.py | 1 + .../webhook/commands/common/names.py | 1 + .../telegram_bot/webhook/commands/help.py | 4 + .../telegram_bot/webhook/commands/upload.py | 74 ++++++++++++++----- .../webhook/telegram_interface.py | 2 + src/blueprints/telegram_bot/webhook/views.py | 1 + 9 files changed, 71 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e964b..c269b99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ ### Added +- `/upload_url`: uploading of file using direct URL. - Privacy Policy and Terms & Conditions. - Favicons for different platforms. - Buttons with useful links in `/help` command. diff --git a/README.md b/README.md index 229e7c5..d89f9b1 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - uploading of photos. - uploading of files. +- uploading of files using direct URL. - uploading of audio. - uploading of video. - uploading of voice. diff --git a/info/info.json b/info/info.json index fae9d18..4823fa9 100644 --- a/info/info.json +++ b/info/info.json @@ -41,6 +41,10 @@ "command": "upload_voice", "description": "Upload a voice message" }, + { + "command": "upload_url", + "description": "Upload a file using direct URL" + }, { "command": "create_folder", "description": "Create a folder using OS path" diff --git a/src/blueprints/telegram_bot/webhook/commands/__init__.py b/src/blueprints/telegram_bot/webhook/commands/__init__.py index b549be6..6eb71fe 100644 --- a/src/blueprints/telegram_bot/webhook/commands/__init__.py +++ b/src/blueprints/telegram_bot/webhook/commands/__init__.py @@ -10,4 +10,5 @@ from .upload import handle_audio as upload_audio_handler from .upload import handle_video as upload_video_handler from .upload import handle_voice as upload_voice_handler +from .upload import handle_url as upload_url_handler from .create_folder import handle as create_folder_handler diff --git a/src/blueprints/telegram_bot/webhook/commands/common/names.py b/src/blueprints/telegram_bot/webhook/commands/common/names.py index 0ee65e9..ecf0500 100644 --- a/src/blueprints/telegram_bot/webhook/commands/common/names.py +++ b/src/blueprints/telegram_bot/webhook/commands/common/names.py @@ -17,4 +17,5 @@ class CommandsNames(Enum): UPLOAD_AUDIO = "/upload_audio" UPLOAD_VIDEO = "/upload_video" UPLOAD_VOICE = "/upload_voice" + UPLOAD_URL = "/upload_url" CREATE_FOLDER = "/create_folder" diff --git a/src/blueprints/telegram_bot/webhook/commands/help.py b/src/blueprints/telegram_bot/webhook/commands/help.py index e017f98..79548db 100644 --- a/src/blueprints/telegram_bot/webhook/commands/help.py +++ b/src/blueprints/telegram_bot/webhook/commands/help.py @@ -41,6 +41,10 @@ def handle(): f"{CommandsNames.UPLOAD_VOICE.value} — upload a voice. " "You can send voice file without this command." "\n" + f"{CommandsNames.UPLOAD_URL.value} — upload a file using direct URL. " + "Original name will be saved. " + "You can send direct URL to a file without this command." + "\n" f"{CommandsNames.CREATE_FOLDER.value}— create a folder. " "Send folder name to create with this command. " "Folder name should starts from root, " diff --git a/src/blueprints/telegram_bot/webhook/commands/upload.py b/src/blueprints/telegram_bot/webhook/commands/upload.py index 3b192d0..bc151c0 100644 --- a/src/blueprints/telegram_bot/webhook/commands/upload.py +++ b/src/blueprints/telegram_bot/webhook/commands/upload.py @@ -68,14 +68,15 @@ def message_is_valid( def get_attachment( self, message: telegram_interface.Message - ) -> Union[dict, None]: + ) -> Union[dict, str, None]: """ :param message: Incoming Telegram message. :returns: Attachment of message (photo object, file object, audio object, etc.). If `None`, - uploading will be aborted. It must have `file_id` and - `file_unique_id` properties. See + uploading will be aborted. If `dict`, it must have `file_id` + and `file_unique_id` properties. If `str`, it is assumed + as direct file URL. See https://core.telegram.org/bots/api/#available-types """ pass @@ -83,13 +84,14 @@ def get_attachment( @abstractmethod def create_file_name( self, - attachment: dict, - file: dict + attachment: Union[dict, str], + file: Union[dict, None] ) -> str: """ :param attachment: Not `None` value from `self.get_attachment()`. :param file: Representation of this attachment as a file on - Telegram servers. See https://core.telegram.org/bots/api/#file + Telegram servers. If `attachment` is `str`, then this will + be equal `None`. See https://core.telegram.org/bots/api/#file :returns: Name of file which will be uploaded. """ @@ -122,25 +124,32 @@ def upload(self) -> None: print(error) return cancel_command(chat.telegram_id) - result = None + download_url = None + file = None - try: - result = telegram.get_file( - file_id=attachment["file_id"] + if (isinstance(attachment, str)): + download_url = attachment + else: + result = None + + try: + result = telegram.get_file( + file_id=attachment["file_id"] + ) + except Exception as error: + print(error) + return cancel_command(chat.telegram_id) + + file = result["content"] + download_url = telegram.create_file_download_url( + file["file_path"] ) - except Exception as error: - print(error) - return cancel_command(chat.telegram_id) - file = result["content"] + file_name = self.create_file_name(attachment, file) user_access_token = user.yandex_disk_token.get_access_token() folder_path = current_app.config[ "YANDEX_DISK_API_DEFAULT_UPLOAD_FOLDER" ] - file_name = self.create_file_name(attachment, file) - download_url = telegram.create_file_download_url( - file["file_path"] - ) def long_task(): try: @@ -396,8 +405,37 @@ def create_file_name(self, attachment, file): return name +class URLHandler(AttachmentHandler): + """ + Handles uploading of direct URL to file. + """ + @staticmethod + def handle(): + handler = URLHandler() + handler.upload() + + @property + def telegram_action(self): + return "upload_document" + + def message_is_valid(self, message: telegram_interface.Message): + value = self.get_attachment(message) + + return ( + isinstance(value, str) and + len(value) > 0 + ) + + def get_attachment(self, message: telegram_interface.Message): + return message.get_entity_value("url") + + def create_file_name(self, attachment, file): + return attachment.split("/")[-1] + + handle_photo = PhotoHandler.handle handle_file = FileHandler.handle handle_audio = AudioHandler.handle handle_video = VideoHandler.handle handle_voice = VoiceHandler.handle +handle_url = URLHandler.handle diff --git a/src/blueprints/telegram_bot/webhook/telegram_interface.py b/src/blueprints/telegram_bot/webhook/telegram_interface.py index 274909a..3495267 100644 --- a/src/blueprints/telegram_bot/webhook/telegram_interface.py +++ b/src/blueprints/telegram_bot/webhook/telegram_interface.py @@ -219,6 +219,8 @@ def guess_bot_command(self, default=CommandsNames.HELP) -> str: command = CommandsNames.UPLOAD_VIDEO elif ("voice" in self.raw_data): command = CommandsNames.UPLOAD_VOICE + elif (self.get_entity_value("url") is not None): + command = CommandsNames.UPLOAD_URL return command diff --git a/src/blueprints/telegram_bot/webhook/views.py b/src/blueprints/telegram_bot/webhook/views.py index 27327b6..004a89f 100644 --- a/src/blueprints/telegram_bot/webhook/views.py +++ b/src/blueprints/telegram_bot/webhook/views.py @@ -72,6 +72,7 @@ def route_command(command: str) -> None: CommandNames.UPLOAD_AUDIO.value: commands.upload_audio_handler, CommandNames.UPLOAD_VIDEO.value: commands.upload_video_handler, CommandNames.UPLOAD_VOICE.value: commands.upload_voice_handler, + CommandNames.UPLOAD_URL.value: commands.upload_url_handler, CommandNames.CREATE_FOLDER.value: commands.create_folder_handler } method = routes.get(command, commands.unknown_handler) From 4fc7096f6402c92a2c53e0c0c3330046a6666f05 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 7 May 2020 22:42:32 +0300 Subject: [PATCH 71/75] Add templates for issues and PR --- .github/ISSUE_TEMPLATE/bug_report.md | 30 +++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 40 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/question.md | 20 ++++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 35 ++++++++++++++++++++ .slugignore | 1 + CHANGELOG.md | 1 + 6 files changed, 127 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/question.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..c535385 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,30 @@ +--- +name: Bug Report +about: Did something not work as expected? +--- + + +<!-- +Thank you very much for contributing by creating an issue! +As an open source project with busy owner, it can sometimes take a long time to response. Please be patient if needed :). Thanks! +--> + + +# Bug Report + +<!-- Please provide a general summary of the issue in the title field. --> + + +## Expected Behavior + +<!-- What you expected to happen. --> + + +## Actual Behavior + +<!-- What actually happened. If you seeing an error, please include the full error message and description of your actions. --> + + +## Additional Information + +<!-- Any other additional information. --> diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..ce4b312 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,40 @@ +--- +name: Feature Request +about: Want to add or change something in the project? +--- + + +<!-- +Thank you very much for contributing by creating an issue! +As an open source project with busy owner, it can sometimes take a long time to response. Please be patient if needed :). Thanks! +--> + + +# Feature Request + +<!-- Please provide a general summary of the issue in the title field. --> + + +## Summary + +<!-- Clear and concise description of your idea. --> + + +## Implementation + +<!-- How should your idea be implemented in your opinion? --> + + +## Description + +<!-- Optional, but preferable. Detailed description of your idea. --> + + +## Examples + +<!-- Optional, but preferable. Examples of your idea. --> + + +## Additional Information + +<!-- Any other additional information. --> diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..ff1356b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,20 @@ +--- +name: Question +about: Have any questions regarding the project? +--- + + +<!-- +Thank you very much for contributing by creating an issue! +As an open source project with busy owner, it can sometimes take a long time to response. Please be patient if needed :). Thanks! +--> + + +# Question + +<!-- Please provide a general summary of the issue in the title field. --> + + +## Summary + +<!-- Write your question here. --> diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..aa043cd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,35 @@ +<!-- +Thank you very much for contributing by creating a pull request! Your contribution is appreciated! +As an open source project with busy owner, it can sometimes take a long time to review. Please be patient if needed :). Thanks! +--> + + +# Pull Request + +<!-- Provide a general summary of the pull request in the title field. --> + + +## Type + +<!-- What kind of change does this PR introduce? e.g. a bugfix, feature, refactoring, new version, etc. --> + + +## Description + +<!-- Clear description of your changes. Please look for any issues that this PR resolves and tag them in the PR. If this PR introduces a breaking changes, please describe the impact and a migration path for existing projects. --> + + +## Checklist + +<!-- Fill this checklist. Do not remove item if it not applied. For completed items, change [ ] to [x]. --> + +- [ ] this PR contains breaking changes +- [ ] `flake8` passes +- [ ] changelog is changed +- [ ] documentation is changed +- [ ] tests are changed or added + + +## Additional Information + +<!-- Any other additional information. --> diff --git a/.slugignore b/.slugignore index 2049c4b..6e26de9 100644 --- a/.slugignore +++ b/.slugignore @@ -9,3 +9,4 @@ README.md # Folders .vscode info +.github diff --git a/CHANGELOG.md b/CHANGELOG.md index c269b99..414a8d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ - `SERVER_NAME` environment variable. - Google Analytics in authorization pages. +- Templates for issues and pull requests. ### Changed From 9056c554294b8ebb208a07f91fa7644eb0f86ea3 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 7 May 2020 22:43:37 +0300 Subject: [PATCH 72/75] Change URL's for issue, request and question --- src/configs/flask.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/configs/flask.py b/src/configs/flask.py index 84987e3..26802ab 100644 --- a/src/configs/flask.py +++ b/src/configs/flask.py @@ -22,8 +22,8 @@ class Config: PROJECT_APP_NAME = "Yandex.Disk Telegram Bot" PROJECT_AUTHOR = "Sergey Kuznetsov" PROJECT_URL_FOR_CODE = "https://github.com/Amaimersion/yandex-disk-telegram-bot" # noqa - PROJECT_URL_FOR_ISSUE = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=bug-report.md" # noqa - PROJECT_URL_FOR_REQUEST = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=feature-request.md" # noqa + PROJECT_URL_FOR_ISSUE = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=bug_report.md" # noqa + PROJECT_URL_FOR_REQUEST = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=feature_request.md" # noqa PROJECT_URL_FOR_QUESTION = "https://github.com/Amaimersion/yandex-disk-telegram-bot/issues/new?template=question.md" # noqa # Flask From a504d16709594b4c150045f28a289ccaa259b417 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 7 May 2020 22:54:01 +0300 Subject: [PATCH 73/75] Add meta tags for search robots in auth pages --- src/templates/macros.html | 8 ++++++++ src/templates/telegram_bot/yd_auth/error.html | 1 + src/templates/telegram_bot/yd_auth/success.html | 1 + 3 files changed, 10 insertions(+) diff --git a/src/templates/macros.html b/src/templates/macros.html index ef5eaef..3fa0f67 100644 --- a/src/templates/macros.html +++ b/src/templates/macros.html @@ -51,3 +51,11 @@ text }}</a> {% endmacro %} + + +{% macro forbid_access_for_robots() %} + <meta + name="robots" + content="none, noarchive, noyaca" + /> +{% endmacro %} diff --git a/src/templates/telegram_bot/yd_auth/error.html b/src/templates/telegram_bot/yd_auth/error.html index a6d6f15..a876268 100644 --- a/src/templates/telegram_bot/yd_auth/error.html +++ b/src/templates/telegram_bot/yd_auth/error.html @@ -4,6 +4,7 @@ {% block head %} + {{ macros.forbid_access_for_robots() }} {{ macros.css_file("telegram_bot/yd_auth/common.css") }} {{ macros.google_analytics() }} {{ macros.js_file("telegram_bot/yd_auth/analytics.js") }} diff --git a/src/templates/telegram_bot/yd_auth/success.html b/src/templates/telegram_bot/yd_auth/success.html index 911076f..75ac4d0 100644 --- a/src/templates/telegram_bot/yd_auth/success.html +++ b/src/templates/telegram_bot/yd_auth/success.html @@ -4,6 +4,7 @@ {% block head %} + {{ macros.forbid_access_for_robots() }} {{ macros.css_file("telegram_bot/yd_auth/common.css") }} {{ macros.google_analytics() }} {{ macros.js_file("telegram_bot/yd_auth/analytics.js") }} From 9c28efea01ea2efc7103095242710c2ca2027afe Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Thu, 7 May 2020 23:26:09 +0300 Subject: [PATCH 74/75] Add robots.txt --- CHANGELOG.md | 1 + src/configs/nginx.conf | 4 ++++ src/static/robots/robots.txt | 2 ++ 3 files changed, 7 insertions(+) create mode 100644 src/static/robots/robots.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 414a8d5..7549970 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ - `SERVER_NAME` environment variable. - Google Analytics in authorization pages. - Templates for issues and pull requests. +- `robots.txt`. ### Changed diff --git a/src/configs/nginx.conf b/src/configs/nginx.conf index a4953cc..2e64c37 100644 --- a/src/configs/nginx.conf +++ b/src/configs/nginx.conf @@ -29,6 +29,10 @@ http { server_name localhost; keepalive_timeout 15s; + location /robots.txt { + alias src/static/robots/robots.txt; + } + location /static/ { root src; autoindex off; diff --git a/src/static/robots/robots.txt b/src/static/robots/robots.txt new file mode 100644 index 0000000..6dced88 --- /dev/null +++ b/src/static/robots/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: /telegram_bot/ From 6e6c5fef9224e8a1ddd2a31cb7d7a60891d48667 Mon Sep 17 00:00:00 2001 From: Sergey Kuznetsov <amatsukine@gmail.com> Date: Sat, 9 May 2020 22:41:50 +0300 Subject: [PATCH 75/75] Update changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7549970..de1eb89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# 1.1.0 +# 1.1.0 (May 9, 2020) ## Telegram Bot