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 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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ 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()