kavchat 0.0.4__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.
- kavchat-0.0.4/LICENSE +21 -0
- kavchat-0.0.4/PKG-INFO +106 -0
- kavchat-0.0.4/README.md +85 -0
- kavchat-0.0.4/pyproject.toml +33 -0
- kavchat-0.0.4/setup.cfg +4 -0
- kavchat-0.0.4/src/kavchat/__init__.py +19 -0
- kavchat-0.0.4/src/kavchat/client.py +160 -0
- kavchat-0.0.4/src/kavchat/errors.py +40 -0
- kavchat-0.0.4/src/kavchat/identity.py +62 -0
- kavchat-0.0.4/src/kavchat/py.typed +0 -0
- kavchat-0.0.4/src/kavchat/types.py +108 -0
- kavchat-0.0.4/src/kavchat/webhook.py +58 -0
- kavchat-0.0.4/src/kavchat.egg-info/PKG-INFO +106 -0
- kavchat-0.0.4/src/kavchat.egg-info/SOURCES.txt +16 -0
- kavchat-0.0.4/src/kavchat.egg-info/dependency_links.txt +1 -0
- kavchat-0.0.4/src/kavchat.egg-info/top_level.txt +1 -0
- kavchat-0.0.4/tests/test_client.py +120 -0
- kavchat-0.0.4/tests/test_vectors.py +68 -0
kavchat-0.0.4/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 maxsave
|
|
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.
|
kavchat-0.0.4/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kavchat
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Kavchat server SDK for Python: identity tokens, webhook verification and the Server API
|
|
5
|
+
Author-email: maxsave <support@kavchat.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://kavchat.com
|
|
8
|
+
Project-URL: Documentation, https://kavchat.com/docs/
|
|
9
|
+
Keywords: kavchat,chatbot,support,webhook,jwt
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Communications :: Chat
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# kavchat (Python)
|
|
23
|
+
|
|
24
|
+
Kavchat server SDK for Python 3.8+. Standard library only (`urllib`, `hmac`, `hashlib`, `json`), fully type hinted.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install kavchat
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from kavchat import Kavchat
|
|
37
|
+
|
|
38
|
+
kavchat = Kavchat(
|
|
39
|
+
os.environ["KAVCHAT_SECRET_KEY"],
|
|
40
|
+
# base_url="https://api.kavchat.com",
|
|
41
|
+
# timeout=30.0,
|
|
42
|
+
# locale="en", # language of error messages
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Identify your users
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
token = kavchat.create_user_token(
|
|
50
|
+
"user_123", # required, 1 to 128 characters
|
|
51
|
+
phone="+972501234567",
|
|
52
|
+
name="Dana",
|
|
53
|
+
email="dana@example.com",
|
|
54
|
+
expires_in=7 * 24 * 3600, # seconds, default 7 days, at most 30 days
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`kavchat.create_user_token(secret_key, user_id, ...)` also exists as a module function.
|
|
59
|
+
|
|
60
|
+
## Verify webhooks
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from flask import Flask, request
|
|
64
|
+
from kavchat import WebhookVerificationError
|
|
65
|
+
|
|
66
|
+
app = Flask(__name__)
|
|
67
|
+
|
|
68
|
+
@app.post("/kavchat/webhook")
|
|
69
|
+
def webhook():
|
|
70
|
+
try:
|
|
71
|
+
event = kavchat.verify_webhook(request.get_data(), request.headers.get("Kavchat-Signature"))
|
|
72
|
+
except WebhookVerificationError as err:
|
|
73
|
+
return err.code, 400
|
|
74
|
+
if event["type"] == "ticket.created":
|
|
75
|
+
print("New ticket", event["data"]["number"])
|
|
76
|
+
return "", 200
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Pass the raw bytes (`request.get_data()`, or `request.body` in Django). The optional third argument is the tolerance in seconds (default 300).
|
|
80
|
+
|
|
81
|
+
## Server API
|
|
82
|
+
|
|
83
|
+
Responses are typed dicts (`kavchat.types`).
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
kavchat.get_workspace()
|
|
87
|
+
kavchat.list_tickets(status="open", limit=50, before=120)
|
|
88
|
+
kavchat.get_ticket(ticket_id)
|
|
89
|
+
kavchat.set_ticket_status(ticket_id, "closed")
|
|
90
|
+
kavchat.get_reports(from_=datetime(2026, 1, 1, tzinfo=timezone.utc), to=datetime.now(timezone.utc))
|
|
91
|
+
kavchat.get_settings()
|
|
92
|
+
kavchat.update_settings(audience="testers", tester_phones=["+972501234567"])
|
|
93
|
+
kavchat.list_releases()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Errors raise `KavchatApiError` with `status`, `code`, `message` and `details`. `status` is 0 for `timeout` and `network_error`.
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License and support
|
|
105
|
+
|
|
106
|
+
MIT. Documentation: https://kavchat.com/docs/. Questions: support@kavchat.com.
|
kavchat-0.0.4/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# kavchat (Python)
|
|
2
|
+
|
|
3
|
+
Kavchat server SDK for Python 3.8+. Standard library only (`urllib`, `hmac`, `hashlib`, `json`), fully type hinted.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install kavchat
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Setup
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import os
|
|
15
|
+
from kavchat import Kavchat
|
|
16
|
+
|
|
17
|
+
kavchat = Kavchat(
|
|
18
|
+
os.environ["KAVCHAT_SECRET_KEY"],
|
|
19
|
+
# base_url="https://api.kavchat.com",
|
|
20
|
+
# timeout=30.0,
|
|
21
|
+
# locale="en", # language of error messages
|
|
22
|
+
)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Identify your users
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
token = kavchat.create_user_token(
|
|
29
|
+
"user_123", # required, 1 to 128 characters
|
|
30
|
+
phone="+972501234567",
|
|
31
|
+
name="Dana",
|
|
32
|
+
email="dana@example.com",
|
|
33
|
+
expires_in=7 * 24 * 3600, # seconds, default 7 days, at most 30 days
|
|
34
|
+
)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`kavchat.create_user_token(secret_key, user_id, ...)` also exists as a module function.
|
|
38
|
+
|
|
39
|
+
## Verify webhooks
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from flask import Flask, request
|
|
43
|
+
from kavchat import WebhookVerificationError
|
|
44
|
+
|
|
45
|
+
app = Flask(__name__)
|
|
46
|
+
|
|
47
|
+
@app.post("/kavchat/webhook")
|
|
48
|
+
def webhook():
|
|
49
|
+
try:
|
|
50
|
+
event = kavchat.verify_webhook(request.get_data(), request.headers.get("Kavchat-Signature"))
|
|
51
|
+
except WebhookVerificationError as err:
|
|
52
|
+
return err.code, 400
|
|
53
|
+
if event["type"] == "ticket.created":
|
|
54
|
+
print("New ticket", event["data"]["number"])
|
|
55
|
+
return "", 200
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Pass the raw bytes (`request.get_data()`, or `request.body` in Django). The optional third argument is the tolerance in seconds (default 300).
|
|
59
|
+
|
|
60
|
+
## Server API
|
|
61
|
+
|
|
62
|
+
Responses are typed dicts (`kavchat.types`).
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
kavchat.get_workspace()
|
|
66
|
+
kavchat.list_tickets(status="open", limit=50, before=120)
|
|
67
|
+
kavchat.get_ticket(ticket_id)
|
|
68
|
+
kavchat.set_ticket_status(ticket_id, "closed")
|
|
69
|
+
kavchat.get_reports(from_=datetime(2026, 1, 1, tzinfo=timezone.utc), to=datetime.now(timezone.utc))
|
|
70
|
+
kavchat.get_settings()
|
|
71
|
+
kavchat.update_settings(audience="testers", tester_phones=["+972501234567"])
|
|
72
|
+
kavchat.list_releases()
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Errors raise `KavchatApiError` with `status`, `code`, `message` and `details`. `status` is 0 for `timeout` and `network_error`.
|
|
76
|
+
|
|
77
|
+
## Tests
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## License and support
|
|
84
|
+
|
|
85
|
+
MIT. Documentation: https://kavchat.com/docs/. Questions: support@kavchat.com.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kavchat"
|
|
7
|
+
version = "0.0.4"
|
|
8
|
+
description = "Kavchat server SDK for Python: identity tokens, webhook verification and the Server API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "maxsave", email = "support@kavchat.com" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["kavchat", "chatbot", "support", "webhook", "jwt"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Communications :: Chat",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://kavchat.com"
|
|
27
|
+
Documentation = "https://kavchat.com/docs/"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools.packages.find]
|
|
30
|
+
where = ["src"]
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.package-data]
|
|
33
|
+
kavchat = ["py.typed"]
|
kavchat-0.0.4/setup.cfg
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Kavchat server SDK: identity tokens, webhook verification and the Server API."""
|
|
2
|
+
from .client import DEFAULT_BASE_URL, VERSION, Kavchat
|
|
3
|
+
from .errors import KavchatApiError, KavchatError, WebhookVerificationError
|
|
4
|
+
from .identity import DEFAULT_TOKEN_TTL_SECONDS, MAX_TOKEN_TTL_SECONDS, create_user_token
|
|
5
|
+
from .webhook import SIGNATURE_HEADER, verify_webhook
|
|
6
|
+
|
|
7
|
+
__version__ = VERSION
|
|
8
|
+
__all__ = [
|
|
9
|
+
"Kavchat",
|
|
10
|
+
"KavchatError",
|
|
11
|
+
"KavchatApiError",
|
|
12
|
+
"WebhookVerificationError",
|
|
13
|
+
"create_user_token",
|
|
14
|
+
"verify_webhook",
|
|
15
|
+
"DEFAULT_BASE_URL",
|
|
16
|
+
"DEFAULT_TOKEN_TTL_SECONDS",
|
|
17
|
+
"MAX_TOKEN_TTL_SECONDS",
|
|
18
|
+
"SIGNATURE_HEADER",
|
|
19
|
+
]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Kavchat Server API client (stdlib only)."""
|
|
2
|
+
import json
|
|
3
|
+
import socket
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.parse
|
|
6
|
+
import urllib.request
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from typing import Any, Dict, List, Optional, Union, cast
|
|
9
|
+
|
|
10
|
+
from .errors import KavchatApiError
|
|
11
|
+
from .identity import DEFAULT_TOKEN_TTL_SECONDS, create_user_token
|
|
12
|
+
from .types import Audience, ReleaseSummary, Report, Settings, Ticket, TicketStatus, WebhookEvent, Workspace
|
|
13
|
+
from .webhook import DEFAULT_TOLERANCE_SECONDS, verify_webhook
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.kavchat.com"
|
|
16
|
+
VERSION = "0.0.4"
|
|
17
|
+
|
|
18
|
+
DateLike = Union[datetime, str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _iso(value: Optional[DateLike]) -> Optional[str]:
|
|
22
|
+
if value is None or isinstance(value, str):
|
|
23
|
+
return value
|
|
24
|
+
if value.tzinfo is None:
|
|
25
|
+
value = value.replace(tzinfo=timezone.utc)
|
|
26
|
+
return value.astimezone(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Kavchat:
|
|
30
|
+
"""Kavchat server client. Keep the secret key on your backend only.
|
|
31
|
+
|
|
32
|
+
>>> kavchat = Kavchat(os.environ["KAVCHAT_SECRET_KEY"])
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
secret_key: str,
|
|
38
|
+
*,
|
|
39
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
40
|
+
timeout: float = 30.0,
|
|
41
|
+
locale: Optional[str] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
if not isinstance(secret_key, str) or not secret_key:
|
|
44
|
+
raise ValueError("secret_key is required")
|
|
45
|
+
self._secret_key = secret_key
|
|
46
|
+
self.base_url = base_url.rstrip("/")
|
|
47
|
+
self.timeout = timeout
|
|
48
|
+
self.locale = locale
|
|
49
|
+
|
|
50
|
+
def __repr__(self) -> str: # never show the key
|
|
51
|
+
return f"Kavchat(base_url={self.base_url!r})"
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------ identity / webhooks
|
|
54
|
+
|
|
55
|
+
def create_user_token(
|
|
56
|
+
self,
|
|
57
|
+
user_id: str,
|
|
58
|
+
*,
|
|
59
|
+
phone: Optional[str] = None,
|
|
60
|
+
name: Optional[str] = None,
|
|
61
|
+
email: Optional[str] = None,
|
|
62
|
+
expires_in: int = DEFAULT_TOKEN_TTL_SECONDS,
|
|
63
|
+
issued_at: Optional[int] = None,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Sign an identity token for one of your users. See :func:`kavchat.create_user_token`."""
|
|
66
|
+
return create_user_token(
|
|
67
|
+
self._secret_key, user_id, phone=phone, name=name, email=email, expires_in=expires_in, issued_at=issued_at
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def verify_webhook(
|
|
71
|
+
self,
|
|
72
|
+
raw_body: Union[str, bytes],
|
|
73
|
+
signature_header: Optional[str],
|
|
74
|
+
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
|
|
75
|
+
*,
|
|
76
|
+
now: Optional[int] = None,
|
|
77
|
+
) -> WebhookEvent:
|
|
78
|
+
"""Verify a webhook request and return the event. See :func:`kavchat.verify_webhook`."""
|
|
79
|
+
return verify_webhook(self._secret_key, raw_body, signature_header, tolerance_seconds, now=now)
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------ Server API
|
|
82
|
+
|
|
83
|
+
def get_workspace(self) -> Workspace:
|
|
84
|
+
return cast(Workspace, self._request("GET", "/workspace"))
|
|
85
|
+
|
|
86
|
+
def list_tickets(
|
|
87
|
+
self, *, status: Optional[TicketStatus] = None, limit: Optional[int] = None, before: Optional[int] = None
|
|
88
|
+
) -> List[Ticket]:
|
|
89
|
+
"""Newest first. Page with ``before`` = the number of the last ticket you have."""
|
|
90
|
+
return cast(List[Ticket], self._request("GET", "/tickets", query={"status": status, "limit": limit, "before": before}))
|
|
91
|
+
|
|
92
|
+
def get_ticket(self, ticket_id: str) -> Ticket:
|
|
93
|
+
return cast(Ticket, self._request("GET", f"/tickets/{urllib.parse.quote(ticket_id, safe='')}"))
|
|
94
|
+
|
|
95
|
+
def set_ticket_status(self, ticket_id: str, status: TicketStatus) -> Ticket:
|
|
96
|
+
path = f"/tickets/{urllib.parse.quote(ticket_id, safe='')}/status"
|
|
97
|
+
return cast(Ticket, self._request("PUT", path, body={"status": status}))
|
|
98
|
+
|
|
99
|
+
def get_reports(self, *, from_: Optional[DateLike] = None, to: Optional[DateLike] = None) -> Report:
|
|
100
|
+
"""Defaults to the last 30 days. ``from_`` has a trailing underscore because ``from`` is reserved."""
|
|
101
|
+
return cast(Report, self._request("GET", "/reports", query={"from": _iso(from_), "to": _iso(to)}))
|
|
102
|
+
|
|
103
|
+
def get_settings(self) -> Settings:
|
|
104
|
+
return cast(Settings, self._request("GET", "/settings"))
|
|
105
|
+
|
|
106
|
+
def update_settings(self, *, audience: Optional[Audience] = None, tester_phones: Optional[List[str]] = None) -> Settings:
|
|
107
|
+
"""Only the audience and the tester phones can be changed from the server."""
|
|
108
|
+
body: Dict[str, Any] = {}
|
|
109
|
+
if audience is not None:
|
|
110
|
+
body["audience"] = audience
|
|
111
|
+
if tester_phones is not None:
|
|
112
|
+
body["testerPhones"] = list(tester_phones)
|
|
113
|
+
return cast(Settings, self._request("PUT", "/settings", body=body))
|
|
114
|
+
|
|
115
|
+
def list_releases(self) -> List[ReleaseSummary]:
|
|
116
|
+
return cast(List[ReleaseSummary], self._request("GET", "/releases"))
|
|
117
|
+
|
|
118
|
+
# ------------------------------------------------------------ transport
|
|
119
|
+
|
|
120
|
+
def _request(self, method: str, path: str, *, query: Optional[Dict[str, Any]] = None, body: Any = None) -> Any:
|
|
121
|
+
url = f"{self.base_url}/v1/server{path}"
|
|
122
|
+
params = {k: str(v) for k, v in (query or {}).items() if v is not None and v != ""}
|
|
123
|
+
if params:
|
|
124
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
125
|
+
headers = {
|
|
126
|
+
"Authorization": f"Bearer {self._secret_key}",
|
|
127
|
+
"Accept": "application/json",
|
|
128
|
+
"User-Agent": f"kavchat-python/{VERSION}",
|
|
129
|
+
}
|
|
130
|
+
if self.locale:
|
|
131
|
+
headers["Accept-Language"] = self.locale
|
|
132
|
+
data = None
|
|
133
|
+
if body is not None:
|
|
134
|
+
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
135
|
+
headers["Content-Type"] = "application/json"
|
|
136
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as res:
|
|
140
|
+
status, raw = res.status, res.read()
|
|
141
|
+
except urllib.error.HTTPError as err:
|
|
142
|
+
status, raw = err.code, err.read()
|
|
143
|
+
except (socket.timeout, TimeoutError):
|
|
144
|
+
raise KavchatApiError(0, "timeout", f"Request timed out after {self.timeout} s") from None
|
|
145
|
+
except urllib.error.URLError as err:
|
|
146
|
+
if isinstance(err.reason, (socket.timeout, TimeoutError)):
|
|
147
|
+
raise KavchatApiError(0, "timeout", f"Request timed out after {self.timeout} s") from None
|
|
148
|
+
raise KavchatApiError(0, "network_error", f"Could not reach Kavchat: {err.reason}") from None
|
|
149
|
+
except OSError as err:
|
|
150
|
+
raise KavchatApiError(0, "network_error", f"Could not reach Kavchat: {err}") from None
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
payload = json.loads(raw.decode("utf-8")) if raw else None
|
|
154
|
+
except (UnicodeDecodeError, ValueError):
|
|
155
|
+
payload = None
|
|
156
|
+
if not isinstance(payload, dict) or not (200 <= status < 300) or payload.get("success") is not True:
|
|
157
|
+
if isinstance(payload, dict) and isinstance(payload.get("code"), str):
|
|
158
|
+
raise KavchatApiError(status, payload["code"], str(payload.get("message", payload["code"])), payload.get("details"))
|
|
159
|
+
raise KavchatApiError(status, "invalid_response", f"Unexpected response from Kavchat (HTTP {status})")
|
|
160
|
+
return payload.get("data")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Errors raised by the Kavchat SDK."""
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class KavchatError(Exception):
|
|
6
|
+
"""Base class of every error raised by this SDK."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class KavchatApiError(KavchatError):
|
|
10
|
+
"""The Server API answered with an error, or could not be reached.
|
|
11
|
+
|
|
12
|
+
``status`` is the HTTP status, or 0 when no response arrived (``code`` is then
|
|
13
|
+
``timeout`` or ``network_error``).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, status: int, code: str, message: str, details: Any = None) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status = status
|
|
19
|
+
self.code = code
|
|
20
|
+
self.message = message
|
|
21
|
+
self.details = details
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"KavchatApiError(status={self.status!r}, code={self.code!r}, message={self.message!r})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WebhookVerificationError(KavchatError):
|
|
28
|
+
"""A webhook request failed verification. Answer it with HTTP 400.
|
|
29
|
+
|
|
30
|
+
``code`` is one of ``malformed_header``, ``timestamp_out_of_range``,
|
|
31
|
+
``invalid_signature`` or ``invalid_payload``.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, code: str, message: str) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.code = code
|
|
37
|
+
self.message = message
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
__all__ = ["KavchatError", "KavchatApiError", "WebhookVerificationError"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Identity tokens: HS256 JWTs signed with the secret key, built with the standard library."""
|
|
2
|
+
import base64
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Dict, Optional
|
|
8
|
+
|
|
9
|
+
DEFAULT_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60
|
|
10
|
+
MAX_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60
|
|
11
|
+
|
|
12
|
+
# base64url of {"alg":"HS256","typ":"JWT"}
|
|
13
|
+
_HEADER = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _b64url(data: bytes) -> str:
|
|
17
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _utf16_length(value: str) -> int:
|
|
21
|
+
# The server counts characters as JavaScript does (UTF-16 code units).
|
|
22
|
+
return len(value.encode("utf-16-le")) // 2
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def create_user_token(
|
|
26
|
+
secret_key: str,
|
|
27
|
+
user_id: str,
|
|
28
|
+
*,
|
|
29
|
+
phone: Optional[str] = None,
|
|
30
|
+
name: Optional[str] = None,
|
|
31
|
+
email: Optional[str] = None,
|
|
32
|
+
expires_in: int = DEFAULT_TOKEN_TTL_SECONDS,
|
|
33
|
+
issued_at: Optional[int] = None,
|
|
34
|
+
) -> str:
|
|
35
|
+
"""Sign an identity token for one of your users.
|
|
36
|
+
|
|
37
|
+
``expires_in`` is in seconds (default 7 days, at most 30 days). ``issued_at`` is
|
|
38
|
+
unix seconds and defaults to now.
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(secret_key, str) or not secret_key:
|
|
41
|
+
raise ValueError("secret_key is required")
|
|
42
|
+
if not isinstance(user_id, str) or not 1 <= _utf16_length(user_id) <= 128:
|
|
43
|
+
raise ValueError("user_id must be a string of 1 to 128 characters")
|
|
44
|
+
if isinstance(expires_in, bool) or not isinstance(expires_in, int) or not 0 < expires_in <= MAX_TOKEN_TTL_SECONDS:
|
|
45
|
+
raise ValueError(f"expires_in must be a whole number of seconds between 1 and {MAX_TOKEN_TTL_SECONDS}")
|
|
46
|
+
iat = int(time.time()) if issued_at is None else int(issued_at)
|
|
47
|
+
|
|
48
|
+
# Claim order is fixed (sub, phone, name, email, iat, exp) so every SDK signs the same bytes.
|
|
49
|
+
claims: Dict[str, Any] = {"sub": user_id}
|
|
50
|
+
if phone:
|
|
51
|
+
claims["phone"] = phone
|
|
52
|
+
if name:
|
|
53
|
+
claims["name"] = name
|
|
54
|
+
if email:
|
|
55
|
+
claims["email"] = email
|
|
56
|
+
claims["iat"] = iat
|
|
57
|
+
claims["exp"] = iat + expires_in
|
|
58
|
+
|
|
59
|
+
payload = _b64url(json.dumps(claims, ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
|
|
60
|
+
signing_input = f"{_HEADER}.{payload}".encode("ascii")
|
|
61
|
+
signature = _b64url(hmac.new(secret_key.encode("utf-8"), signing_input, hashlib.sha256).digest())
|
|
62
|
+
return f"{_HEADER}.{payload}.{signature}"
|
|
File without changes
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Typed shapes of Server API responses (plain dicts at runtime)."""
|
|
2
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
3
|
+
|
|
4
|
+
TicketStatus = Literal["open", "closed"]
|
|
5
|
+
Audience = Literal["off", "testers", "all"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# "from" is a Python keyword, so these two use the functional TypedDict syntax.
|
|
9
|
+
TranscriptEntry = TypedDict(
|
|
10
|
+
"TranscriptEntry",
|
|
11
|
+
{"from": Literal["bot", "user"], "text": str, "stepId": str, "at": str},
|
|
12
|
+
total=False,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Ticket(TypedDict, total=False):
|
|
17
|
+
id: str
|
|
18
|
+
number: int
|
|
19
|
+
status: TicketStatus
|
|
20
|
+
name: Optional[str]
|
|
21
|
+
email: Optional[str]
|
|
22
|
+
phone: Optional[str]
|
|
23
|
+
userId: Optional[str]
|
|
24
|
+
topicId: Optional[str]
|
|
25
|
+
topicName: Optional[str]
|
|
26
|
+
scriptId: Optional[str]
|
|
27
|
+
sessionId: Optional[str]
|
|
28
|
+
message: str
|
|
29
|
+
transcript: List[TranscriptEntry]
|
|
30
|
+
createdAt: str
|
|
31
|
+
updatedAt: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ContactTopic(TypedDict):
|
|
35
|
+
id: str
|
|
36
|
+
name: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Settings(TypedDict, total=False):
|
|
40
|
+
enabled: bool
|
|
41
|
+
audience: Audience
|
|
42
|
+
testerPhones: List[str]
|
|
43
|
+
botName: str
|
|
44
|
+
avatarText: str
|
|
45
|
+
avatarImageUrl: Optional[str]
|
|
46
|
+
subtitle: str
|
|
47
|
+
primaryColor: str
|
|
48
|
+
position: Literal["start", "end"]
|
|
49
|
+
contactTopics: List[ContactTopic]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Workspace(TypedDict, total=False):
|
|
53
|
+
id: str
|
|
54
|
+
name: str
|
|
55
|
+
locale: str
|
|
56
|
+
defaultCountry: str
|
|
57
|
+
plan: str
|
|
58
|
+
publicKey: str
|
|
59
|
+
secretKeyHint: str
|
|
60
|
+
webhookUrl: Optional[str]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ReleaseScript(TypedDict, total=False):
|
|
64
|
+
id: str
|
|
65
|
+
kind: str
|
|
66
|
+
name: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ReleaseSummary(TypedDict, total=False):
|
|
70
|
+
version: int
|
|
71
|
+
note: str
|
|
72
|
+
restoredFrom: int
|
|
73
|
+
publishedAt: str
|
|
74
|
+
scripts: List[ReleaseScript]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ReportTotals(TypedDict, total=False):
|
|
78
|
+
sessions: int
|
|
79
|
+
open: int
|
|
80
|
+
answered: int
|
|
81
|
+
contact: int
|
|
82
|
+
abandoned: int
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class UnmatchedQuestion(TypedDict, total=False):
|
|
86
|
+
groupKey: str
|
|
87
|
+
sample: str
|
|
88
|
+
count: int
|
|
89
|
+
lastAt: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
Report = TypedDict(
|
|
93
|
+
"Report",
|
|
94
|
+
{
|
|
95
|
+
"from": str,
|
|
96
|
+
"to": str,
|
|
97
|
+
"totals": ReportTotals,
|
|
98
|
+
"byScript": List[Dict[str, Any]],
|
|
99
|
+
"dropOff": List[Dict[str, Any]],
|
|
100
|
+
"unmatched": List[UnmatchedQuestion],
|
|
101
|
+
},
|
|
102
|
+
total=False,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class WebhookEvent(TypedDict):
|
|
107
|
+
type: Literal["ticket.created"]
|
|
108
|
+
data: Ticket
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Webhook verification for the ``Kavchat-Signature`` header."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import List, Optional, Union, cast
|
|
7
|
+
|
|
8
|
+
from .errors import WebhookVerificationError
|
|
9
|
+
from .types import WebhookEvent
|
|
10
|
+
|
|
11
|
+
SIGNATURE_HEADER = "Kavchat-Signature"
|
|
12
|
+
DEFAULT_TOLERANCE_SECONDS = 300
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def verify_webhook(
|
|
16
|
+
secret_key: str,
|
|
17
|
+
raw_body: Union[str, bytes],
|
|
18
|
+
signature_header: Optional[str],
|
|
19
|
+
tolerance_seconds: int = DEFAULT_TOLERANCE_SECONDS,
|
|
20
|
+
*,
|
|
21
|
+
now: Optional[int] = None,
|
|
22
|
+
) -> WebhookEvent:
|
|
23
|
+
"""Check the signature of a webhook request and return the parsed event.
|
|
24
|
+
|
|
25
|
+
``raw_body`` must be the exact bytes received, before any JSON parsing.
|
|
26
|
+
Raises :class:`WebhookVerificationError` when the request is not authentic or too old.
|
|
27
|
+
"""
|
|
28
|
+
timestamp: Optional[int] = None
|
|
29
|
+
bad_timestamp = False
|
|
30
|
+
signatures: List[str] = []
|
|
31
|
+
for part in (signature_header or "").split(","):
|
|
32
|
+
key, sep, value = part.partition("=")
|
|
33
|
+
if not sep:
|
|
34
|
+
continue
|
|
35
|
+
key, value = key.strip(), value.strip()
|
|
36
|
+
if key == "t":
|
|
37
|
+
if value.isascii() and value.isdigit():
|
|
38
|
+
timestamp = int(value)
|
|
39
|
+
else:
|
|
40
|
+
bad_timestamp = True
|
|
41
|
+
elif key == "v1" and value:
|
|
42
|
+
signatures.append(value)
|
|
43
|
+
if timestamp is None or bad_timestamp or not signatures:
|
|
44
|
+
raise WebhookVerificationError("malformed_header", "Kavchat-Signature header is missing or malformed")
|
|
45
|
+
|
|
46
|
+
current = int(time.time()) if now is None else int(now)
|
|
47
|
+
if abs(current - timestamp) > tolerance_seconds:
|
|
48
|
+
raise WebhookVerificationError("timestamp_out_of_range", "Webhook timestamp is outside the tolerance")
|
|
49
|
+
|
|
50
|
+
body = raw_body.encode("utf-8") if isinstance(raw_body, str) else bytes(raw_body)
|
|
51
|
+
expected = hmac.new(secret_key.encode("utf-8"), f"{timestamp}.".encode("ascii") + body, hashlib.sha256).hexdigest()
|
|
52
|
+
if not any(hmac.compare_digest(expected.encode("ascii"), sig.encode("utf-8")) for sig in signatures):
|
|
53
|
+
raise WebhookVerificationError("invalid_signature", "Webhook signature does not match")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
return cast(WebhookEvent, json.loads(body.decode("utf-8")))
|
|
57
|
+
except (UnicodeDecodeError, ValueError):
|
|
58
|
+
raise WebhookVerificationError("invalid_payload", "Webhook body is not valid JSON") from None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kavchat
|
|
3
|
+
Version: 0.0.4
|
|
4
|
+
Summary: Kavchat server SDK for Python: identity tokens, webhook verification and the Server API
|
|
5
|
+
Author-email: maxsave <support@kavchat.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://kavchat.com
|
|
8
|
+
Project-URL: Documentation, https://kavchat.com/docs/
|
|
9
|
+
Keywords: kavchat,chatbot,support,webhook,jwt
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Communications :: Chat
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.8
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# kavchat (Python)
|
|
23
|
+
|
|
24
|
+
Kavchat server SDK for Python 3.8+. Standard library only (`urllib`, `hmac`, `hashlib`, `json`), fully type hinted.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
pip install kavchat
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Setup
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
import os
|
|
36
|
+
from kavchat import Kavchat
|
|
37
|
+
|
|
38
|
+
kavchat = Kavchat(
|
|
39
|
+
os.environ["KAVCHAT_SECRET_KEY"],
|
|
40
|
+
# base_url="https://api.kavchat.com",
|
|
41
|
+
# timeout=30.0,
|
|
42
|
+
# locale="en", # language of error messages
|
|
43
|
+
)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Identify your users
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
token = kavchat.create_user_token(
|
|
50
|
+
"user_123", # required, 1 to 128 characters
|
|
51
|
+
phone="+972501234567",
|
|
52
|
+
name="Dana",
|
|
53
|
+
email="dana@example.com",
|
|
54
|
+
expires_in=7 * 24 * 3600, # seconds, default 7 days, at most 30 days
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`kavchat.create_user_token(secret_key, user_id, ...)` also exists as a module function.
|
|
59
|
+
|
|
60
|
+
## Verify webhooks
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from flask import Flask, request
|
|
64
|
+
from kavchat import WebhookVerificationError
|
|
65
|
+
|
|
66
|
+
app = Flask(__name__)
|
|
67
|
+
|
|
68
|
+
@app.post("/kavchat/webhook")
|
|
69
|
+
def webhook():
|
|
70
|
+
try:
|
|
71
|
+
event = kavchat.verify_webhook(request.get_data(), request.headers.get("Kavchat-Signature"))
|
|
72
|
+
except WebhookVerificationError as err:
|
|
73
|
+
return err.code, 400
|
|
74
|
+
if event["type"] == "ticket.created":
|
|
75
|
+
print("New ticket", event["data"]["number"])
|
|
76
|
+
return "", 200
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Pass the raw bytes (`request.get_data()`, or `request.body` in Django). The optional third argument is the tolerance in seconds (default 300).
|
|
80
|
+
|
|
81
|
+
## Server API
|
|
82
|
+
|
|
83
|
+
Responses are typed dicts (`kavchat.types`).
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
kavchat.get_workspace()
|
|
87
|
+
kavchat.list_tickets(status="open", limit=50, before=120)
|
|
88
|
+
kavchat.get_ticket(ticket_id)
|
|
89
|
+
kavchat.set_ticket_status(ticket_id, "closed")
|
|
90
|
+
kavchat.get_reports(from_=datetime(2026, 1, 1, tzinfo=timezone.utc), to=datetime.now(timezone.utc))
|
|
91
|
+
kavchat.get_settings()
|
|
92
|
+
kavchat.update_settings(audience="testers", tester_phones=["+972501234567"])
|
|
93
|
+
kavchat.list_releases()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Errors raise `KavchatApiError` with `status`, `code`, `message` and `details`. `status` is 0 for `timeout` and `network_error`.
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
PYTHONPATH=src python3 -m unittest discover -s tests -v
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## License and support
|
|
105
|
+
|
|
106
|
+
MIT. Documentation: https://kavchat.com/docs/. Questions: support@kavchat.com.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/kavchat/__init__.py
|
|
5
|
+
src/kavchat/client.py
|
|
6
|
+
src/kavchat/errors.py
|
|
7
|
+
src/kavchat/identity.py
|
|
8
|
+
src/kavchat/py.typed
|
|
9
|
+
src/kavchat/types.py
|
|
10
|
+
src/kavchat/webhook.py
|
|
11
|
+
src/kavchat.egg-info/PKG-INFO
|
|
12
|
+
src/kavchat.egg-info/SOURCES.txt
|
|
13
|
+
src/kavchat.egg-info/dependency_links.txt
|
|
14
|
+
src/kavchat.egg-info/top_level.txt
|
|
15
|
+
tests/test_client.py
|
|
16
|
+
tests/test_vectors.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kavchat
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import socket
|
|
3
|
+
import threading
|
|
4
|
+
import unittest
|
|
5
|
+
import urllib.parse
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
|
|
9
|
+
from kavchat import Kavchat, KavchatApiError
|
|
10
|
+
|
|
11
|
+
SECRET = "sk_live_client_test_key"
|
|
12
|
+
TICKET = {"id": "abc", "number": 3, "status": "open", "message": "hi", "transcript": []}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
16
|
+
requests = []
|
|
17
|
+
|
|
18
|
+
def log_message(self, *args):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def _send(self, status, payload, raw=None):
|
|
22
|
+
data = raw if raw is not None else json.dumps(payload).encode()
|
|
23
|
+
self.send_response(status)
|
|
24
|
+
self.send_header("Content-Type", "application/json")
|
|
25
|
+
self.send_header("Content-Length", str(len(data)))
|
|
26
|
+
self.end_headers()
|
|
27
|
+
self.wfile.write(data)
|
|
28
|
+
|
|
29
|
+
def _handle(self):
|
|
30
|
+
url = urllib.parse.urlsplit(self.path)
|
|
31
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
32
|
+
body = json.loads(self.rfile.read(length)) if length else None
|
|
33
|
+
self.requests.append({"method": self.command, "path": url.path, "query": dict(urllib.parse.parse_qsl(url.query)), "headers": dict(self.headers), "body": body})
|
|
34
|
+
if self.headers.get("Authorization") != f"Bearer {SECRET}":
|
|
35
|
+
return self._send(401, {"success": False, "code": "invalid_key", "message": "Invalid key"})
|
|
36
|
+
p = url.path
|
|
37
|
+
if p == "/v1/server/tickets/missing":
|
|
38
|
+
return self._send(404, {"success": False, "code": "ticket_not_found", "message": "Not found", "details": {"id": "missing"}})
|
|
39
|
+
if p == "/v1/server/releases":
|
|
40
|
+
return self._send(502, None, raw=b"<html>bad gateway</html>")
|
|
41
|
+
if p == "/v1/server/tickets":
|
|
42
|
+
return self._send(200, {"success": True, "data": [TICKET]})
|
|
43
|
+
if p.startswith("/v1/server/tickets/"):
|
|
44
|
+
return self._send(200, {"success": True, "data": dict(TICKET, status=(body or {}).get("status", "open"))})
|
|
45
|
+
if p == "/v1/server/workspace":
|
|
46
|
+
return self._send(200, {"success": True, "data": {"id": "w1", "name": "Acme"}})
|
|
47
|
+
if p == "/v1/server/reports":
|
|
48
|
+
return self._send(200, {"success": True, "data": {"totals": {"sessions": 2}}})
|
|
49
|
+
if p == "/v1/server/settings":
|
|
50
|
+
return self._send(200, {"success": True, "data": {"audience": (body or {}).get("audience", "all")}})
|
|
51
|
+
return self._send(404, {"success": False, "code": "not_found", "message": "nope"})
|
|
52
|
+
|
|
53
|
+
do_GET = _handle
|
|
54
|
+
do_PUT = _handle
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClientTest(unittest.TestCase):
|
|
58
|
+
@classmethod
|
|
59
|
+
def setUpClass(cls):
|
|
60
|
+
cls.server = ThreadingHTTPServer(("127.0.0.1", 0), _Handler)
|
|
61
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
62
|
+
cls.base_url = f"http://127.0.0.1:{cls.server.server_address[1]}/"
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def tearDownClass(cls):
|
|
66
|
+
cls.server.shutdown()
|
|
67
|
+
cls.server.server_close()
|
|
68
|
+
|
|
69
|
+
def test_every_endpoint(self):
|
|
70
|
+
k = Kavchat(SECRET, base_url=self.base_url, locale="he")
|
|
71
|
+
self.assertEqual(k.get_workspace()["name"], "Acme")
|
|
72
|
+
self.assertEqual(k.list_tickets(status="open", limit=10, before=99)[0]["number"], 3)
|
|
73
|
+
self.assertEqual(k.get_ticket("a/b")["id"], "abc")
|
|
74
|
+
self.assertEqual(k.set_ticket_status("abc", "closed")["status"], "closed")
|
|
75
|
+
self.assertEqual(k.get_reports(from_=datetime(2026, 1, 1, tzinfo=timezone.utc), to="2026-02-01T00:00:00Z")["totals"]["sessions"], 2)
|
|
76
|
+
self.assertEqual(k.get_settings()["audience"], "all")
|
|
77
|
+
self.assertEqual(k.update_settings(audience="testers", tester_phones=["+972501234567"])["audience"], "testers")
|
|
78
|
+
|
|
79
|
+
ws, lst, one, status, reports, settings, update = _Handler.requests[-7:]
|
|
80
|
+
self.assertEqual(ws["path"], "/v1/server/workspace")
|
|
81
|
+
self.assertEqual(ws["headers"]["Authorization"], f"Bearer {SECRET}")
|
|
82
|
+
self.assertEqual(ws["headers"]["Accept-Language"], "he")
|
|
83
|
+
self.assertTrue(ws["headers"]["User-Agent"].startswith("kavchat-python/"))
|
|
84
|
+
self.assertEqual(lst["query"], {"status": "open", "limit": "10", "before": "99"})
|
|
85
|
+
self.assertEqual(one["path"], "/v1/server/tickets/a%2Fb")
|
|
86
|
+
self.assertEqual((status["method"], status["path"], status["body"]), ("PUT", "/v1/server/tickets/abc/status", {"status": "closed"}))
|
|
87
|
+
self.assertEqual(reports["query"], {"from": "2026-01-01T00:00:00.000Z", "to": "2026-02-01T00:00:00Z"})
|
|
88
|
+
self.assertEqual(settings["method"], "GET")
|
|
89
|
+
self.assertEqual((update["method"], update["body"]), ("PUT", {"audience": "testers", "testerPhones": ["+972501234567"]}))
|
|
90
|
+
|
|
91
|
+
def test_api_errors(self):
|
|
92
|
+
k = Kavchat(SECRET, base_url=self.base_url)
|
|
93
|
+
with self.assertRaises(KavchatApiError) as ctx:
|
|
94
|
+
k.get_ticket("missing")
|
|
95
|
+
e = ctx.exception
|
|
96
|
+
self.assertEqual((e.status, e.code, e.message, e.details), (404, "ticket_not_found", "Not found", {"id": "missing"}))
|
|
97
|
+
with self.assertRaises(KavchatApiError) as ctx:
|
|
98
|
+
Kavchat("sk_live_wrong", base_url=self.base_url).get_workspace()
|
|
99
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (401, "invalid_key"))
|
|
100
|
+
with self.assertRaises(KavchatApiError) as ctx:
|
|
101
|
+
k.list_releases()
|
|
102
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (502, "invalid_response"))
|
|
103
|
+
|
|
104
|
+
def test_timeout_and_network_errors(self):
|
|
105
|
+
silent = socket.socket()
|
|
106
|
+
silent.bind(("127.0.0.1", 0))
|
|
107
|
+
silent.listen(1) # accepts the connection and never answers
|
|
108
|
+
try:
|
|
109
|
+
with self.assertRaises(KavchatApiError) as ctx:
|
|
110
|
+
Kavchat(SECRET, base_url=f"http://127.0.0.1:{silent.getsockname()[1]}", timeout=0.2).get_workspace()
|
|
111
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (0, "timeout"))
|
|
112
|
+
finally:
|
|
113
|
+
silent.close()
|
|
114
|
+
with self.assertRaises(KavchatApiError) as ctx:
|
|
115
|
+
Kavchat(SECRET, base_url="http://127.0.0.1:1").get_workspace()
|
|
116
|
+
self.assertEqual((ctx.exception.status, ctx.exception.code), (0, "network_error"))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
unittest.main()
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from kavchat import Kavchat, WebhookVerificationError, create_user_token, verify_webhook
|
|
8
|
+
|
|
9
|
+
VECTORS = Path(__file__).resolve().parents[2] / "test-vectors"
|
|
10
|
+
IDENTITY = json.loads((VECTORS / "identity.json").read_text("utf-8"))
|
|
11
|
+
WEBHOOK = json.loads((VECTORS / "webhook.json").read_text("utf-8"))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _kwargs(data):
|
|
15
|
+
out = {"phone": data.get("phone"), "name": data.get("name"), "email": data.get("email")}
|
|
16
|
+
if "expiresIn" in data:
|
|
17
|
+
out["expires_in"] = data["expiresIn"]
|
|
18
|
+
return out
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class IdentityVectors(unittest.TestCase):
|
|
22
|
+
def test_valid(self):
|
|
23
|
+
for v in IDENTITY["valid"]:
|
|
24
|
+
with self.subTest(v["name"]):
|
|
25
|
+
token = create_user_token(IDENTITY["secretKey"], v["input"]["userId"], issued_at=IDENTITY["iat"], **_kwargs(v["input"]))
|
|
26
|
+
self.assertEqual(token, v["token"])
|
|
27
|
+
|
|
28
|
+
def test_invalid(self):
|
|
29
|
+
for v in IDENTITY["invalid"]:
|
|
30
|
+
with self.subTest(v["name"]):
|
|
31
|
+
with self.assertRaises(ValueError):
|
|
32
|
+
create_user_token(IDENTITY["secretKey"], v["input"]["userId"], issued_at=IDENTITY["iat"], **_kwargs(v["input"]))
|
|
33
|
+
|
|
34
|
+
def test_default_issued_at_is_now(self):
|
|
35
|
+
token = create_user_token("sk_live_x", "u")
|
|
36
|
+
part = token.split(".")[1]
|
|
37
|
+
claims = json.loads(base64.urlsafe_b64decode(part + "=" * (-len(part) % 4)))
|
|
38
|
+
self.assertLess(abs(claims["iat"] - time.time()), 5)
|
|
39
|
+
self.assertEqual(claims["exp"] - claims["iat"], 7 * 24 * 3600)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class WebhookVectors(unittest.TestCase):
|
|
43
|
+
def test_valid(self):
|
|
44
|
+
for v in WEBHOOK["valid"]:
|
|
45
|
+
for body in (v["body"], v["body"].encode("utf-8")):
|
|
46
|
+
with self.subTest(v["name"], body_type=type(body).__name__):
|
|
47
|
+
event = verify_webhook(WEBHOOK["secretKey"], body, v["header"], v["toleranceSeconds"], now=v["now"])
|
|
48
|
+
self.assertEqual(event["type"], v["expectedType"])
|
|
49
|
+
self.assertEqual(event["data"]["number"], v["expectedTicketNumber"])
|
|
50
|
+
|
|
51
|
+
def test_invalid(self):
|
|
52
|
+
for v in WEBHOOK["invalid"]:
|
|
53
|
+
with self.subTest(v["name"]):
|
|
54
|
+
with self.assertRaises(WebhookVerificationError) as ctx:
|
|
55
|
+
verify_webhook(WEBHOOK["secretKey"], v["body"], v["header"], v["toleranceSeconds"], now=v["now"])
|
|
56
|
+
self.assertEqual(ctx.exception.code, v["expectedError"])
|
|
57
|
+
|
|
58
|
+
def test_client_methods(self):
|
|
59
|
+
client = Kavchat(IDENTITY["secretKey"])
|
|
60
|
+
v = IDENTITY["valid"][1]
|
|
61
|
+
self.assertEqual(client.create_user_token(v["input"]["userId"], issued_at=IDENTITY["iat"], **_kwargs(v["input"])), v["token"])
|
|
62
|
+
w = WEBHOOK["valid"][0]
|
|
63
|
+
self.assertEqual(client.verify_webhook(w["body"], w["header"], now=w["now"])["type"], "ticket.created")
|
|
64
|
+
self.assertNotIn(IDENTITY["secretKey"], repr(client))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
unittest.main()
|