init-data-py 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- init_data_py-0.1.0/.github/workflows/publish.yml +41 -0
- init_data_py-0.1.0/.gitignore +13 -0
- init_data_py-0.1.0/.python-version +1 -0
- init_data_py-0.1.0/LICENCE +21 -0
- init_data_py-0.1.0/PKG-INFO +65 -0
- init_data_py-0.1.0/README.md +55 -0
- init_data_py-0.1.0/pyproject.toml +77 -0
- init_data_py-0.1.0/requirements-dev.lock +42 -0
- init_data_py-0.1.0/requirements.lock +12 -0
- init_data_py-0.1.0/src/init_data_py/__init__.py +5 -0
- init_data_py-0.1.0/src/init_data_py/errors/__init__.py +15 -0
- init_data_py-0.1.0/src/init_data_py/errors/errors.py +23 -0
- init_data_py-0.1.0/src/init_data_py/init_data.py +261 -0
- init_data_py-0.1.0/src/init_data_py/types/__init__.py +4 -0
- init_data_py-0.1.0/src/init_data_py/types/chat.py +41 -0
- init_data_py-0.1.0/src/init_data_py/types/object.py +32 -0
- init_data_py-0.1.0/src/init_data_py/types/user.py +65 -0
- init_data_py-0.1.0/tests/__init__.py +0 -0
- init_data_py-0.1.0/tests/test_parse.py +29 -0
- init_data_py-0.1.0/tests/test_sign.py +32 -0
- init_data_py-0.1.0/tests/test_validate.py +46 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish Python Package to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types:
|
|
6
|
+
- published
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
name: Publish to PyPI
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
environment:
|
|
14
|
+
name: pypi
|
|
15
|
+
url: https://pypi.org/p/init-data-py
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
|
|
21
|
+
- name: Set up python
|
|
22
|
+
uses: actions/setup-python@v4
|
|
23
|
+
with:
|
|
24
|
+
python-version: ${{ matrix.python-version }}
|
|
25
|
+
|
|
26
|
+
- name: Search and replace version
|
|
27
|
+
run: |
|
|
28
|
+
VERSION="${TAG_NAME//v/}"
|
|
29
|
+
sed -i "s/1\.0+versionplaceholder\.1/$VERSION/" pyproject.toml
|
|
30
|
+
env:
|
|
31
|
+
TAG_NAME: ${{ github.event.release.tag_name }}
|
|
32
|
+
|
|
33
|
+
- uses: eifinger/setup-rye@v4
|
|
34
|
+
with:
|
|
35
|
+
enable-cache: true
|
|
36
|
+
|
|
37
|
+
- name: Build
|
|
38
|
+
run: |
|
|
39
|
+
rye build
|
|
40
|
+
- name: Publish package distributions to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11.9
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 nimaxin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: init-data-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A Python library that provides tools for using and validating Telegram web app init data.
|
|
5
|
+
Author-email: nimaxin <nimaxin@proton.me>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENCE
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# init-data-py
|
|
12
|
+
|
|
13
|
+
A Python library that provides tools for using and validating Telegram web app init data.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
You can install the library using pip.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install init-data-py
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
### Parsing.
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from init_data_py import InitData
|
|
29
|
+
|
|
30
|
+
query_string = "query_id=AAF03wc0Ag..."
|
|
31
|
+
|
|
32
|
+
InitData.from_query_string(query_string)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Signing
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from init_data_py import InitData
|
|
39
|
+
from init_data_py.types import User
|
|
40
|
+
|
|
41
|
+
BOT_TOKEN = "7244657541:AA..."
|
|
42
|
+
|
|
43
|
+
init_data = InitData(
|
|
44
|
+
user=User(
|
|
45
|
+
id=5167898484,
|
|
46
|
+
first_name="xin",
|
|
47
|
+
username="pvnimaxin",
|
|
48
|
+
)
|
|
49
|
+
).sign(bot_token=BOT_TOKEN)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Validation
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from init_data_py import InitData
|
|
56
|
+
|
|
57
|
+
BOT_TOKEN = "7244657541:AA..."
|
|
58
|
+
|
|
59
|
+
init_data = InitData(...)
|
|
60
|
+
is_valid = init_data.validate(bot_token=BOT_TOKEN, lifetime=3600)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## License
|
|
64
|
+
|
|
65
|
+
This library is licensed under the [MIT License](LICENCE).
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# init-data-py
|
|
2
|
+
|
|
3
|
+
A Python library that provides tools for using and validating Telegram web app init data.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
You can install the library using pip.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install init-data-py
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
### Parsing.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from init_data_py import InitData
|
|
19
|
+
|
|
20
|
+
query_string = "query_id=AAF03wc0Ag..."
|
|
21
|
+
|
|
22
|
+
InitData.from_query_string(query_string)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Signing
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from init_data_py import InitData
|
|
29
|
+
from init_data_py.types import User
|
|
30
|
+
|
|
31
|
+
BOT_TOKEN = "7244657541:AA..."
|
|
32
|
+
|
|
33
|
+
init_data = InitData(
|
|
34
|
+
user=User(
|
|
35
|
+
id=5167898484,
|
|
36
|
+
first_name="xin",
|
|
37
|
+
username="pvnimaxin",
|
|
38
|
+
)
|
|
39
|
+
).sign(bot_token=BOT_TOKEN)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Validation
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from init_data_py import InitData
|
|
46
|
+
|
|
47
|
+
BOT_TOKEN = "7244657541:AA..."
|
|
48
|
+
|
|
49
|
+
init_data = InitData(...)
|
|
50
|
+
is_valid = init_data.validate(bot_token=BOT_TOKEN, lifetime=3600)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
This library is licensed under the [MIT License](LICENCE).
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "init-data-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A Python library that provides tools for using and validating Telegram web app init data."
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "nimaxin", email = "nimaxin@proton.me" }
|
|
7
|
+
]
|
|
8
|
+
dependencies = []
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">= 3.8"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
16
|
+
|
|
17
|
+
[tool.rye]
|
|
18
|
+
managed = true
|
|
19
|
+
dev-dependencies = [
|
|
20
|
+
"mypy>=1.11.1",
|
|
21
|
+
"tox>=4.17.1",
|
|
22
|
+
"ruff>=0.5.7",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.metadata]
|
|
26
|
+
allow-direct-references = true
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/init_data_py"]
|
|
30
|
+
|
|
31
|
+
[tool.rye.scripts]
|
|
32
|
+
tests = "python -m unittest discover --verbose"
|
|
33
|
+
|
|
34
|
+
[tool.ruff]
|
|
35
|
+
exclude = [
|
|
36
|
+
".bzr",
|
|
37
|
+
".direnv",
|
|
38
|
+
".eggs",
|
|
39
|
+
".git",
|
|
40
|
+
".git-rewrite",
|
|
41
|
+
".hg",
|
|
42
|
+
".ipynb_checkpoints",
|
|
43
|
+
".mypy_cache",
|
|
44
|
+
".nox",
|
|
45
|
+
".pants.d",
|
|
46
|
+
".pyenv",
|
|
47
|
+
".pytest_cache",
|
|
48
|
+
".pytype",
|
|
49
|
+
".ruff_cache",
|
|
50
|
+
".svn",
|
|
51
|
+
".tox",
|
|
52
|
+
".venv",
|
|
53
|
+
".vscode",
|
|
54
|
+
"__pypackages__",
|
|
55
|
+
"_build",
|
|
56
|
+
"buck-out",
|
|
57
|
+
"build",
|
|
58
|
+
"dist",
|
|
59
|
+
"node_modules",
|
|
60
|
+
"site-packages",
|
|
61
|
+
"venv",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
# Same as Black.
|
|
65
|
+
line-length = 79
|
|
66
|
+
indent-width = 4
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E4", "E7", "E9", "F", "I001"]
|
|
71
|
+
fixable = ["ALL"]
|
|
72
|
+
|
|
73
|
+
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
|
74
|
+
|
|
75
|
+
[tool.ruff.format]
|
|
76
|
+
quote-style = "double"
|
|
77
|
+
indent-style = "space"
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# generated by rye
|
|
2
|
+
# use `rye lock` or `rye sync` to update this lockfile
|
|
3
|
+
#
|
|
4
|
+
# last locked with the following flags:
|
|
5
|
+
# pre: false
|
|
6
|
+
# features: []
|
|
7
|
+
# all-features: false
|
|
8
|
+
# with-sources: false
|
|
9
|
+
# generate-hashes: false
|
|
10
|
+
# universal: false
|
|
11
|
+
|
|
12
|
+
-e file:.
|
|
13
|
+
cachetools==5.4.0
|
|
14
|
+
# via tox
|
|
15
|
+
chardet==5.2.0
|
|
16
|
+
# via tox
|
|
17
|
+
colorama==0.4.6
|
|
18
|
+
# via tox
|
|
19
|
+
distlib==0.3.8
|
|
20
|
+
# via virtualenv
|
|
21
|
+
filelock==3.15.4
|
|
22
|
+
# via tox
|
|
23
|
+
# via virtualenv
|
|
24
|
+
mypy==1.11.1
|
|
25
|
+
mypy-extensions==1.0.0
|
|
26
|
+
# via mypy
|
|
27
|
+
packaging==24.1
|
|
28
|
+
# via pyproject-api
|
|
29
|
+
# via tox
|
|
30
|
+
platformdirs==4.2.2
|
|
31
|
+
# via tox
|
|
32
|
+
# via virtualenv
|
|
33
|
+
pluggy==1.5.0
|
|
34
|
+
# via tox
|
|
35
|
+
pyproject-api==1.7.1
|
|
36
|
+
# via tox
|
|
37
|
+
ruff==0.5.7
|
|
38
|
+
tox==4.17.1
|
|
39
|
+
typing-extensions==4.12.2
|
|
40
|
+
# via mypy
|
|
41
|
+
virtualenv==20.26.3
|
|
42
|
+
# via tox
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# generated by rye
|
|
2
|
+
# use `rye lock` or `rye sync` to update this lockfile
|
|
3
|
+
#
|
|
4
|
+
# last locked with the following flags:
|
|
5
|
+
# pre: false
|
|
6
|
+
# features: []
|
|
7
|
+
# all-features: false
|
|
8
|
+
# with-sources: false
|
|
9
|
+
# generate-hashes: false
|
|
10
|
+
# universal: false
|
|
11
|
+
|
|
12
|
+
-e file:.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from .errors import (
|
|
2
|
+
AuthDateMissingError,
|
|
3
|
+
ExpiredError,
|
|
4
|
+
SignInvalidError,
|
|
5
|
+
SignMissingError,
|
|
6
|
+
UnexpectedFormatError,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"AuthDateMissingError",
|
|
11
|
+
"ExpiredError",
|
|
12
|
+
"SignInvalidError",
|
|
13
|
+
"SignMissingError",
|
|
14
|
+
"UnexpectedFormatError",
|
|
15
|
+
]
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class AuthDateMissingError(Exception):
|
|
2
|
+
def __init__(self):
|
|
3
|
+
super().__init__("auth_date is missing.")
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ExpiredError(Exception):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
super().__init__("init data is expired.")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SignInvalidError(Exception):
|
|
12
|
+
def __init__(self):
|
|
13
|
+
super().__init__("signature (hash) is invalid.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SignMissingError(Exception):
|
|
17
|
+
def __init__(self):
|
|
18
|
+
super().__init__("signature (hash) is missing.")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnexpectedFormatError(Exception):
|
|
22
|
+
def __init__(self):
|
|
23
|
+
super().__init__("the init data query string has unexpected format.")
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import hmac
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from typing import Literal, Optional
|
|
8
|
+
|
|
9
|
+
from init_data_py import errors, types
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InitData:
|
|
13
|
+
"""Represent a [WebAppInitData](https://core.telegram.org/bots/webapps#webappinitdata).
|
|
14
|
+
|
|
15
|
+
Parameters:
|
|
16
|
+
query_id (`str`, optional):
|
|
17
|
+
A unique identifier for the Mini App session, required for sending messages via the answerWebAppQuery method.
|
|
18
|
+
|
|
19
|
+
user (`init_data_py.types.User`, optional):
|
|
20
|
+
An object containing data about the current user.
|
|
21
|
+
|
|
22
|
+
receiver (`init_data_py.types.User`, optional):
|
|
23
|
+
An object containing data about the chat partner of the current user in the chat where the bot was launched via the attachment menu. Returned only for private chats and only for Mini Apps launched via the attachment menu.
|
|
24
|
+
|
|
25
|
+
chat (`init_data_py.types.Chat`, optional):
|
|
26
|
+
An object containing data about the chat where the bot was launched via the attachment menu. Returned for supergroups, channels and group chats - only for Mini Apps launched via the attachment menu.
|
|
27
|
+
|
|
28
|
+
chat_type (`str`, optional):
|
|
29
|
+
Type of the chat from which the Mini App was opened. Can be either "sender" for a private chat with the user opening the link, "private", "group", "supergroup", or "channel". Returned only for Mini Apps launched from direct links.
|
|
30
|
+
|
|
31
|
+
chat_instance (`str`, optional):
|
|
32
|
+
Global identifier, uniquely corresponding to the chat from which the Mini App was opened. Returned only for Mini Apps launched from a direct link.
|
|
33
|
+
|
|
34
|
+
start_param (`str`, optional):
|
|
35
|
+
The value of the startattach parameter, passed via link. Only returned for Mini Apps when launched from the attachment menu via link.
|
|
36
|
+
|
|
37
|
+
can_send_after (`int`, optional):
|
|
38
|
+
Time in seconds, after which a message can be sent via the answerWebAppQuery method.
|
|
39
|
+
|
|
40
|
+
auth_date (`int`, optional):
|
|
41
|
+
Unix time when the form was opened.
|
|
42
|
+
|
|
43
|
+
hash (`str`, optional):
|
|
44
|
+
A hash of all passed parameters, which the bot server can use to check their validity.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
query_id: Optional[str] = None,
|
|
51
|
+
user: Optional["types.User"] = None,
|
|
52
|
+
receiver: Optional["types.User"] = None,
|
|
53
|
+
chat: Optional["types.Chat"] = None,
|
|
54
|
+
chat_type: Optional[
|
|
55
|
+
Literal["sender", "private", "group", "supergroup", "channel"]
|
|
56
|
+
] = None,
|
|
57
|
+
chat_instance: Optional[str] = None,
|
|
58
|
+
start_param: Optional[str] = None,
|
|
59
|
+
can_send_after: Optional[int] = None,
|
|
60
|
+
auth_date: Optional[int] = None,
|
|
61
|
+
hash: Optional[str] = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.query_id = query_id
|
|
64
|
+
self.user = user
|
|
65
|
+
self.receiver = receiver
|
|
66
|
+
self.chat = chat
|
|
67
|
+
self.chat_type = chat_type
|
|
68
|
+
self.chat_instance = chat_instance
|
|
69
|
+
self.start_param = start_param
|
|
70
|
+
self.can_send_after = can_send_after
|
|
71
|
+
self.auth_date = auth_date
|
|
72
|
+
self.hash = hash
|
|
73
|
+
|
|
74
|
+
def validate(self, bot_token: str, lifetime: Optional[int] = None):
|
|
75
|
+
"""Validates the init data authenticity.
|
|
76
|
+
|
|
77
|
+
Parameters:
|
|
78
|
+
bot_token (`str`):
|
|
79
|
+
The token associated with the bot, used to either launch the webapp or sign the init data.
|
|
80
|
+
|
|
81
|
+
lifetime (`int`, optional):
|
|
82
|
+
The maximum validity period of the init data in seconds.
|
|
83
|
+
Recommended for security. Default is `None`.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
`bool`:
|
|
87
|
+
`True` if the init data is valid; otherwise, raises an exception.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
`errors.SignMissingError`: In case the signature (hash) is missing.
|
|
91
|
+
`errors.AuthDateMissingError`: In case the auth_date is missing.
|
|
92
|
+
`errors.ExpiredError`: In case the init data has expired based on the provided lifetime.
|
|
93
|
+
`errors.SignInvalidError`: In case the signature (hash) is invalid.
|
|
94
|
+
"""
|
|
95
|
+
if self.hash is None:
|
|
96
|
+
raise errors.SignMissingError()
|
|
97
|
+
|
|
98
|
+
if self.auth_date is None:
|
|
99
|
+
raise errors.AuthDateMissingError()
|
|
100
|
+
|
|
101
|
+
if lifetime is not None:
|
|
102
|
+
auth_date = datetime.fromtimestamp(self.auth_date)
|
|
103
|
+
expire_date = auth_date - timedelta(seconds=lifetime)
|
|
104
|
+
if datetime.now() > expire_date:
|
|
105
|
+
raise errors.ExpiredError()
|
|
106
|
+
|
|
107
|
+
if self.hash != self.calculate_hash(bot_token):
|
|
108
|
+
raise errors.SignInvalidError()
|
|
109
|
+
|
|
110
|
+
return True
|
|
111
|
+
|
|
112
|
+
def sign(self, bot_token: str, auth_date: Optional[int] = None):
|
|
113
|
+
"""Sign the init data using the provided bot token.
|
|
114
|
+
|
|
115
|
+
Parameters:
|
|
116
|
+
bot_token (`str`):
|
|
117
|
+
The bot token used to generate the signature for init data.
|
|
118
|
+
|
|
119
|
+
auth_date (`init`):
|
|
120
|
+
The timestamp (without timezone) representing the authorization date. If not provided, the current timestamp will be used.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
`None`:
|
|
124
|
+
This method updates the current init data by setting the `auth_date` and generated signature (`hash`) attributes.
|
|
125
|
+
"""
|
|
126
|
+
if auth_date is None:
|
|
127
|
+
self.auth_date = int(time.time())
|
|
128
|
+
self.auth_date = auth_date
|
|
129
|
+
self.hash = self.calculate_hash(bot_token)
|
|
130
|
+
|
|
131
|
+
def calculate_hash(
|
|
132
|
+
self,
|
|
133
|
+
bot_token: str,
|
|
134
|
+
):
|
|
135
|
+
"""Calculates a hash based on the bot token and the current `InitData` object.
|
|
136
|
+
|
|
137
|
+
Parameters:
|
|
138
|
+
bot_token (`str`):
|
|
139
|
+
The bot token used to generate the secret key for hash computation.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
`str`:
|
|
143
|
+
The calculated hash, derived from the init data attributes and the generated secret key.
|
|
144
|
+
"""
|
|
145
|
+
init_data = self.to_dict(nested=False)
|
|
146
|
+
init_data.pop("hash", None)
|
|
147
|
+
sorted_attrs = sorted(init_data.items())
|
|
148
|
+
data_check_string = "\n".join(
|
|
149
|
+
f"{k}={v}" for k, v in sorted_attrs
|
|
150
|
+
).encode()
|
|
151
|
+
secret_key = hmac.new(
|
|
152
|
+
b"WebAppData", bot_token.encode(), hashlib.sha256
|
|
153
|
+
).digest()
|
|
154
|
+
return hmac.new(
|
|
155
|
+
secret_key, data_check_string, hashlib.sha256
|
|
156
|
+
).hexdigest()
|
|
157
|
+
|
|
158
|
+
@classmethod
|
|
159
|
+
def from_query_string(cls, query_string: str):
|
|
160
|
+
"""Create an InitData object from a query string.
|
|
161
|
+
|
|
162
|
+
Parameters:
|
|
163
|
+
query_string (`str`):
|
|
164
|
+
The query string from `window.WebApp.initData` to parse and covert into an WebAppInitData object.
|
|
165
|
+
|
|
166
|
+
Raises:
|
|
167
|
+
`errors.SignMissingError`: In case the signature (hash) is missing.
|
|
168
|
+
`errors.AuthDateMissingError`: In case the auth_date is missing.
|
|
169
|
+
`errors.UnexpectedFormatError`: In case the the format of the query string is unexpected.
|
|
170
|
+
"""
|
|
171
|
+
parsed_qs = dict(urllib.parse.parse_qsl(query_string))
|
|
172
|
+
|
|
173
|
+
if not parsed_qs:
|
|
174
|
+
raise errors.UnexpectedFormatError()
|
|
175
|
+
|
|
176
|
+
init_data = {}
|
|
177
|
+
|
|
178
|
+
for k, v in parsed_qs.items():
|
|
179
|
+
if k in {
|
|
180
|
+
"hash",
|
|
181
|
+
"query_id",
|
|
182
|
+
"chat_type",
|
|
183
|
+
"chat_instance",
|
|
184
|
+
"start_param",
|
|
185
|
+
}:
|
|
186
|
+
init_data[k] = v
|
|
187
|
+
|
|
188
|
+
elif k in {"auth_date", "can_send_after"}:
|
|
189
|
+
init_data[k] = int(v) # type: ignore
|
|
190
|
+
elif k in {"user", "receiver"}:
|
|
191
|
+
try:
|
|
192
|
+
init_data[k] = types.User.from_json(v)
|
|
193
|
+
except json.decoder.JSONDecodeError:
|
|
194
|
+
raise errors.UnexpectedFormatError()
|
|
195
|
+
elif k == "chat":
|
|
196
|
+
try:
|
|
197
|
+
init_data[k] = types.Chat.from_json(v)
|
|
198
|
+
except json.decoder.JSONDecodeError:
|
|
199
|
+
raise errors.UnexpectedFormatError()
|
|
200
|
+
else:
|
|
201
|
+
raise errors.UnexpectedFormatError()
|
|
202
|
+
|
|
203
|
+
hash = parsed_qs.get("hash")
|
|
204
|
+
if hash is None:
|
|
205
|
+
raise errors.UnexpectedFormatError()
|
|
206
|
+
|
|
207
|
+
auth_date = parsed_qs.get("auth_date")
|
|
208
|
+
if auth_date is None:
|
|
209
|
+
raise errors.UnexpectedFormatError()
|
|
210
|
+
|
|
211
|
+
return cls(**init_data) # type: ignore
|
|
212
|
+
|
|
213
|
+
def to_json(self):
|
|
214
|
+
"""Returns a JSON serialized representation of the object."""
|
|
215
|
+
return json.dumps(self.to_dict(nested=True))
|
|
216
|
+
|
|
217
|
+
def to_dict(self, nested=True):
|
|
218
|
+
"""Returns a dictionary representation of the object.
|
|
219
|
+
|
|
220
|
+
Parameters:
|
|
221
|
+
nested (`bool`):
|
|
222
|
+
If True, nested objects will be converted to dictionaries; if False, nested objects will be represented as serialized JSON.
|
|
223
|
+
"""
|
|
224
|
+
init_data_attrs = {
|
|
225
|
+
k: v for k, v in self.__dict__.items() if v is not None
|
|
226
|
+
}
|
|
227
|
+
init_data = {}
|
|
228
|
+
|
|
229
|
+
for k, v in init_data_attrs.items():
|
|
230
|
+
if isinstance(v, (types.User, types.Chat)):
|
|
231
|
+
if nested:
|
|
232
|
+
init_data[k] = v.to_dict()
|
|
233
|
+
continue
|
|
234
|
+
init_data[k] = v.to_json()
|
|
235
|
+
elif k in {"auth_date", "can_send_after"}:
|
|
236
|
+
init_data[k] = int(v)
|
|
237
|
+
else:
|
|
238
|
+
init_data[k] = str(v)
|
|
239
|
+
|
|
240
|
+
return init_data
|
|
241
|
+
|
|
242
|
+
def to_query_string(self):
|
|
243
|
+
"""Returns a query string representation of the object."""
|
|
244
|
+
init_data = self.to_dict(nested=False)
|
|
245
|
+
return urllib.parse.urlencode(init_data)
|
|
246
|
+
|
|
247
|
+
def __str__(self) -> str:
|
|
248
|
+
return json.dumps(self.to_dict(nested=True), indent=4)
|
|
249
|
+
|
|
250
|
+
def __eq__(self, other: object) -> bool:
|
|
251
|
+
if not isinstance(other, type(self)):
|
|
252
|
+
return False
|
|
253
|
+
|
|
254
|
+
for attr in self.__dict__:
|
|
255
|
+
try:
|
|
256
|
+
if getattr(self, attr) != getattr(other, attr):
|
|
257
|
+
return False
|
|
258
|
+
except AttributeError:
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
return True
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from typing import Literal, Optional
|
|
2
|
+
|
|
3
|
+
from .object import Object
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Chat(Object):
|
|
7
|
+
"""
|
|
8
|
+
Represent a [WebAppChat](https://core.telegram.org/bots/webapps#webappchat).
|
|
9
|
+
|
|
10
|
+
Parameters:
|
|
11
|
+
id (int):
|
|
12
|
+
Unique identifier for this chat. This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. But it has at most 52 significant bits, so a signed 64-bit integer or double-precision float type are safe for storing this identifier.
|
|
13
|
+
|
|
14
|
+
type (`str`):
|
|
15
|
+
Type of chat, can be either "group", "supergroup" or "channel".
|
|
16
|
+
|
|
17
|
+
title (`str`):
|
|
18
|
+
Title of the chat.
|
|
19
|
+
|
|
20
|
+
username (`str`, optional):
|
|
21
|
+
Username of the chat.
|
|
22
|
+
|
|
23
|
+
photo_url (`str`, optional):
|
|
24
|
+
URL of the chat's photo. The photo can be in .jpeg or .svg formats. Only returned for Mini Apps launched from the attachment menu.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
id: int,
|
|
31
|
+
type: Literal["group", "supergroup", "channel"],
|
|
32
|
+
title: str,
|
|
33
|
+
username: Optional[str] = None,
|
|
34
|
+
photo_url: Optional[str] = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self.id = id
|
|
37
|
+
self.type = type
|
|
38
|
+
self.title = title
|
|
39
|
+
self.username = username
|
|
40
|
+
self.photo_url = photo_url
|
|
41
|
+
# NOTE: The order of the attributes is important.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Object:
|
|
5
|
+
def to_json(self):
|
|
6
|
+
"""Returns a JSON serialized representation of the object."""
|
|
7
|
+
return json.dumps(self.to_dict(), separators=(",", ":"))
|
|
8
|
+
|
|
9
|
+
def to_dict(self):
|
|
10
|
+
"""Returns a dictionary representation of the object."""
|
|
11
|
+
return {k: v for k, v in self.__dict__.items() if v is not None}
|
|
12
|
+
|
|
13
|
+
@classmethod
|
|
14
|
+
def from_json(cls, json_string):
|
|
15
|
+
"""Create an object from JSON serialized string."""
|
|
16
|
+
return cls(**json.loads(json_string))
|
|
17
|
+
|
|
18
|
+
def __str__(self) -> str:
|
|
19
|
+
return json.dumps(self.to_dict(), indent=4)
|
|
20
|
+
|
|
21
|
+
def __eq__(self, other: object) -> bool:
|
|
22
|
+
if not isinstance(other, type(self)):
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
for attr in self.__dict__:
|
|
26
|
+
try:
|
|
27
|
+
if getattr(self, attr) != getattr(other, attr):
|
|
28
|
+
return False
|
|
29
|
+
except AttributeError:
|
|
30
|
+
return False
|
|
31
|
+
|
|
32
|
+
return True
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from typing import Literal, Optional
|
|
2
|
+
|
|
3
|
+
from .object import Object
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class User(Object):
|
|
7
|
+
"""Represent a [WebAppUser](https://core.telegram.org/bots/webapps#webappuser).
|
|
8
|
+
|
|
9
|
+
Parameters:
|
|
10
|
+
id (int):
|
|
11
|
+
A unique identifier for the user or bot. This number may have more than 32 significant bits and some programming languages may have difficulty/silent defects in interpreting it. It has at most 52 significant bits, so a 64-bit integer or a double-precision float type is safe for storing this identifier.
|
|
12
|
+
|
|
13
|
+
is_bot (boolean, optional):
|
|
14
|
+
True, if this user is a bot. Returns in the `receiver` field only.
|
|
15
|
+
|
|
16
|
+
first_name (`str`):
|
|
17
|
+
First name of the user or bot.
|
|
18
|
+
|
|
19
|
+
last_name (`str`, optional):
|
|
20
|
+
Last name of the user or bot.
|
|
21
|
+
|
|
22
|
+
username (`str`, optional):
|
|
23
|
+
Username of the user or bot.
|
|
24
|
+
|
|
25
|
+
language_code (`str`, optional):
|
|
26
|
+
IETF language tag of the user's language. Returns in user field only.
|
|
27
|
+
|
|
28
|
+
is_premium (True, optional):
|
|
29
|
+
True, if this user is a Telegram Premium user.
|
|
30
|
+
|
|
31
|
+
added_to_attachment_menu (True, optional):
|
|
32
|
+
True, if this user added the bot to the attachment menu.
|
|
33
|
+
|
|
34
|
+
allows_write_to_pm (True, optional):
|
|
35
|
+
True, if this user allowed the bot to message them.
|
|
36
|
+
|
|
37
|
+
photo_url (`str`, optional):
|
|
38
|
+
URL of the user's profile photo. The photo can be in .jpeg or .svg formats. Only returned for Mini Apps launched from the attachment menu.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
id: int,
|
|
45
|
+
first_name: str,
|
|
46
|
+
last_name: Optional[str] = None,
|
|
47
|
+
username: Optional[str] = None,
|
|
48
|
+
language_code: Optional[str] = None,
|
|
49
|
+
is_premium: Optional[Literal[True]] = None,
|
|
50
|
+
is_bot: Optional[bool] = None,
|
|
51
|
+
added_to_attachment_menu: Optional[Literal[True]] = None,
|
|
52
|
+
allows_write_to_pm: Optional[Literal[True]] = None,
|
|
53
|
+
photo_url: Optional[str] = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.id = id
|
|
56
|
+
self.is_bot = is_bot
|
|
57
|
+
self.first_name = first_name
|
|
58
|
+
self.last_name = last_name
|
|
59
|
+
self.username = username
|
|
60
|
+
self.language_code = language_code
|
|
61
|
+
self.is_premium = is_premium
|
|
62
|
+
self.added_to_attachment_menu = added_to_attachment_menu
|
|
63
|
+
self.allows_write_to_pm = allows_write_to_pm
|
|
64
|
+
self.photo_url = photo_url
|
|
65
|
+
# NOTE: The order of the attributes is important.
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from init_data_py import InitData, errors, types
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestParseInitData(unittest.TestCase):
|
|
7
|
+
def setUp(self) -> None:
|
|
8
|
+
self.query_string = "query_id=AAF03wc0AgAAAHTfBzROOCVW&user=%7B%22id%22%3A5167898484%2C%22first_name%22%3A%22xin%22%2C%22last_name%22%3A%22%22%2C%22username%22%3A%22pvnimaxin%22%2C%22language_code%22%3A%22en%22%2C%22allows_write_to_pm%22%3Atrue%7D&auth_date=1722938610&hash=8654c8c617c143abf656f4f159be2539880a56f58c2d9be622f90c0346aa162b"
|
|
9
|
+
self.expected_init_data = InitData(
|
|
10
|
+
query_id="AAF03wc0AgAAAHTfBzROOCVW",
|
|
11
|
+
user=types.User(
|
|
12
|
+
id=5167898484,
|
|
13
|
+
first_name="xin",
|
|
14
|
+
last_name="",
|
|
15
|
+
username="pvnimaxin",
|
|
16
|
+
language_code="en",
|
|
17
|
+
allows_write_to_pm=True,
|
|
18
|
+
),
|
|
19
|
+
auth_date=1722938610,
|
|
20
|
+
hash="8654c8c617c143abf656f4f159be2539880a56f58c2d9be622f90c0346aa162b",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
def test_valid_query_string(self):
|
|
24
|
+
init_data = InitData.from_query_string(self.query_string)
|
|
25
|
+
self.assertEqual(init_data, self.expected_init_data)
|
|
26
|
+
|
|
27
|
+
def test_invalid_query_string(self):
|
|
28
|
+
with self.assertRaises(errors.UnexpectedFormatError):
|
|
29
|
+
InitData.from_query_string(self.query_string.upper())
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from init_data_py import InitData, types
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestSignInitData(unittest.TestCase):
|
|
7
|
+
def setUp(self) -> None:
|
|
8
|
+
self.init_data = InitData(
|
|
9
|
+
query_id="AAF03wc0AgAAAHTfBzROOCVW",
|
|
10
|
+
user=types.User(
|
|
11
|
+
id=5167898484,
|
|
12
|
+
first_name="xin",
|
|
13
|
+
last_name="",
|
|
14
|
+
username="pvnimaxin",
|
|
15
|
+
language_code="en",
|
|
16
|
+
allows_write_to_pm=True,
|
|
17
|
+
),
|
|
18
|
+
)
|
|
19
|
+
self.bot_token = "7244657541:AAEgqk0HDC3WD5cdbnGMdd6L0TJ74FDp97Y"
|
|
20
|
+
|
|
21
|
+
self.expected_auth_date = 1722938610
|
|
22
|
+
self.expected_hash = (
|
|
23
|
+
"8654c8c617c143abf656f4f159be2539880a56f58c2d9be622f90c0346aa162b"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
def test_sign(self):
|
|
27
|
+
self.init_data.sign(
|
|
28
|
+
bot_token=self.bot_token,
|
|
29
|
+
auth_date=self.expected_auth_date,
|
|
30
|
+
)
|
|
31
|
+
self.assertEqual(self.init_data.hash, self.expected_hash)
|
|
32
|
+
self.assertEqual(self.init_data.auth_date, self.expected_auth_date)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from init_data_py import InitData, errors, types
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestValidateInitData(unittest.TestCase):
|
|
8
|
+
def setUp(self) -> None:
|
|
9
|
+
self.init_data = InitData(
|
|
10
|
+
query_id="AAF03wc0AgAAAHTfBzROOCVW",
|
|
11
|
+
user=types.User(
|
|
12
|
+
id=5167898484,
|
|
13
|
+
first_name="xin",
|
|
14
|
+
last_name="",
|
|
15
|
+
username="pvnimaxin",
|
|
16
|
+
language_code="en",
|
|
17
|
+
allows_write_to_pm=True,
|
|
18
|
+
),
|
|
19
|
+
auth_date=1722938610,
|
|
20
|
+
hash="8654c8c617c143abf656f4f159be2539880a56f58c2d9be622f90c0346aa162b",
|
|
21
|
+
)
|
|
22
|
+
self.bot_token = "7244657541:AAEgqk0HDC3WD5cdbnGMdd6L0TJ74FDp97Y"
|
|
23
|
+
|
|
24
|
+
def test_valid_init_data(self):
|
|
25
|
+
self.assertTrue(self.init_data.validate(self.bot_token))
|
|
26
|
+
|
|
27
|
+
def test_invalid_init_data(self):
|
|
28
|
+
self.init_data.hash = "invalid hash"
|
|
29
|
+
with self.assertRaises(errors.SignInvalidError):
|
|
30
|
+
self.init_data.validate(self.bot_token)
|
|
31
|
+
|
|
32
|
+
def test_sign_missing_init_data(self):
|
|
33
|
+
self.init_data.hash = None
|
|
34
|
+
with self.assertRaises(errors.SignMissingError):
|
|
35
|
+
self.init_data.validate(self.bot_token)
|
|
36
|
+
|
|
37
|
+
def test_auth_date_missing_init_data(self):
|
|
38
|
+
self.init_data.auth_date = None
|
|
39
|
+
with self.assertRaises(errors.AuthDateMissingError):
|
|
40
|
+
self.init_data.validate(self.bot_token)
|
|
41
|
+
|
|
42
|
+
def test_expired_init_data(self):
|
|
43
|
+
lifetime = 10
|
|
44
|
+
self.init_data.auth_date = int(time.time()) - lifetime
|
|
45
|
+
with self.assertRaises(errors.ExpiredError):
|
|
46
|
+
self.init_data.validate(self.bot_token, lifetime)
|