hatang 0.1.0__py3-none-any.whl

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.
hatang/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Official Python SDK for Ha Tang (hatang.dev).
2
+
3
+ from hatang import Hatang
4
+
5
+ hatang = Hatang() # HATANG_API_KEY
6
+ hatang.chat.subscriptions.upsert("web", "sub_123", {"status": "active", "external_user_id": "u1"})
7
+ """
8
+
9
+ from ._client import DEFAULT_BASE_URL
10
+ from ._errors import HatangError, HatangSignatureError
11
+ from ._hatang import AsyncHatang, Hatang
12
+ from ._version import VERSION
13
+ from .helpers import identity_hash, purchase_token
14
+ from .webhooks import construct_ipn_event, construct_webhook_event, verify_ipn, verify_webhook
15
+
16
+ __version__ = VERSION
17
+
18
+ __all__ = [
19
+ "DEFAULT_BASE_URL",
20
+ "VERSION",
21
+ "AsyncHatang",
22
+ "Hatang",
23
+ "HatangError",
24
+ "HatangSignatureError",
25
+ "construct_ipn_event",
26
+ "construct_webhook_event",
27
+ "identity_hash",
28
+ "purchase_token",
29
+ "verify_ipn",
30
+ "verify_webhook",
31
+ ]
hatang/_client.py ADDED
@@ -0,0 +1,150 @@
1
+ """The HTTP layer shared by ``Hatang`` and ``AsyncHatang``: headers, query strings, errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ from typing import Any, Dict, Mapping, Optional, Union
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+
12
+ from ._errors import HatangError
13
+ from ._version import VERSION
14
+
15
+ DEFAULT_BASE_URL = "https://api.hatang.dev"
16
+ DEFAULT_TIMEOUT = 30.0
17
+
18
+ JSON = Dict[str, Any]
19
+ Query = Mapping[str, Any]
20
+
21
+
22
+ def seg(value: str) -> str:
23
+ """One path segment: ids may hold ``/`` (Play purchase tokens, your own order ids)."""
24
+ return quote(str(value), safe="")
25
+
26
+
27
+ def query_params(params: Optional[Query]) -> Dict[str, str]:
28
+ """Query parameters as the API reads them: None dropped, booleans as true/false, lists comma-joined."""
29
+ out: Dict[str, str] = {}
30
+ for key, value in (params or {}).items():
31
+ if value is None:
32
+ continue
33
+ if isinstance(value, bool):
34
+ out[key] = "true" if value else "false"
35
+ elif isinstance(value, (list, tuple)):
36
+ out[key] = ",".join(str(v) for v in value)
37
+ else:
38
+ out[key] = str(value)
39
+ return out
40
+
41
+
42
+ class _Config:
43
+ def __init__(
44
+ self,
45
+ api_key: Optional[str],
46
+ base_url: Optional[str],
47
+ environment: Optional[str],
48
+ user_agent: Optional[str],
49
+ ) -> None:
50
+ key = api_key or os.environ.get("HATANG_API_KEY")
51
+ if not key:
52
+ raise ValueError('Missing API key. Pass it to the constructor: Hatang("ht_123"), or set HATANG_API_KEY')
53
+ self.api_key = key
54
+ self.base_url = (base_url or os.environ.get("HATANG_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
55
+ self.environment = environment or os.environ.get("HATANG_ENVIRONMENT")
56
+ self.user_agent = user_agent or f"hatang-sdk-python/{VERSION} (python/{platform.python_version()})"
57
+
58
+ def headers(self, has_body: bool, extra: Optional[Mapping[str, str]]) -> Dict[str, str]:
59
+ h = {"Authorization": f"Bearer {self.api_key}", "User-Agent": self.user_agent, "Accept": "application/json"}
60
+ if self.environment:
61
+ h["Hatang-Environment"] = self.environment
62
+ if has_body:
63
+ h["Content-Type"] = "application/json"
64
+ h.update(extra or {})
65
+ return h
66
+
67
+
68
+ def _parse(res: httpx.Response) -> Any:
69
+ try:
70
+ body: Any = res.json() if res.content else None
71
+ except ValueError:
72
+ body = None
73
+ if res.is_success:
74
+ return body if body is not None else {}
75
+ err: Mapping[str, Any] = body if isinstance(body, dict) else {}
76
+ raise HatangError(
77
+ int(err.get("statusCode") or res.status_code),
78
+ str(err.get("name") or "application_error"),
79
+ str(err.get("message") or res.reason_phrase or "Request failed"),
80
+ )
81
+
82
+
83
+ def _unreachable(error: Exception) -> HatangError:
84
+ return HatangError(0, "application_error", f"Unable to reach the API: {error}")
85
+
86
+
87
+ RequestHeaders = Optional[Mapping[str, str]]
88
+ Body = Union[JSON, list, None] # type: ignore[type-arg]
89
+
90
+
91
+ class SyncTransport:
92
+ def __init__(self, config: _Config, timeout: float, http_client: Optional[httpx.Client]) -> None:
93
+ self._config = config
94
+ self._http = http_client or httpx.Client(timeout=timeout)
95
+
96
+ def request(self, method: str, path: str, body: Body = None, query: Optional[Query] = None, headers: RequestHeaders = None) -> Any:
97
+ try:
98
+ res = self._http.request(
99
+ method,
100
+ f"{self._config.base_url}{path}",
101
+ params=query_params(query),
102
+ json=body,
103
+ headers=self._config.headers(body is not None, headers),
104
+ )
105
+ except httpx.HTTPError as error:
106
+ raise _unreachable(error) from error
107
+ return _parse(res)
108
+
109
+ def text(self, path: str, query: Optional[Query] = None) -> Any:
110
+ try:
111
+ res = self._http.get(f"{self._config.base_url}{path}", params=query_params(query), headers=self._config.headers(False, None))
112
+ except httpx.HTTPError as error:
113
+ raise _unreachable(error) from error
114
+ if not res.is_success:
115
+ _parse(res)
116
+ return res.text
117
+
118
+ def close(self) -> None:
119
+ self._http.close()
120
+
121
+
122
+ class AsyncTransport:
123
+ def __init__(self, config: _Config, timeout: float, http_client: Optional[httpx.AsyncClient]) -> None:
124
+ self._config = config
125
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
126
+
127
+ async def request(self, method: str, path: str, body: Body = None, query: Optional[Query] = None, headers: RequestHeaders = None) -> Any:
128
+ try:
129
+ res = await self._http.request(
130
+ method,
131
+ f"{self._config.base_url}{path}",
132
+ params=query_params(query),
133
+ json=body,
134
+ headers=self._config.headers(body is not None, headers),
135
+ )
136
+ except httpx.HTTPError as error:
137
+ raise _unreachable(error) from error
138
+ return _parse(res)
139
+
140
+ async def text(self, path: str, query: Optional[Query] = None) -> Any:
141
+ try:
142
+ res = await self._http.get(f"{self._config.base_url}{path}", params=query_params(query), headers=self._config.headers(False, None))
143
+ except httpx.HTTPError as error:
144
+ raise _unreachable(error) from error
145
+ if not res.is_success:
146
+ _parse(res)
147
+ return res.text
148
+
149
+ async def aclose(self) -> None:
150
+ await self._http.aclose()
hatang/_errors.py ADDED
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class HatangError(Exception):
5
+ """An API call that did not succeed.
6
+
7
+ ``status_code`` is the HTTP status, or 0 when Ha Tang was not reached (network error, timeout).
8
+ ``name`` is the API's error code (``validation_error``, ``not_found``, ``rate_limit_exceeded``…).
9
+ """
10
+
11
+ def __init__(self, status_code: int, name: str, message: str) -> None:
12
+ super().__init__(f"{status_code} {name}: {message}")
13
+ self.status_code = status_code
14
+ self.name = name
15
+ self.message = message
16
+
17
+ @property
18
+ def retryable(self) -> bool:
19
+ """True for outages worth retrying: unreachable, rate limited, or a server error."""
20
+ return self.status_code == 0 or self.status_code == 429 or self.status_code >= 500
21
+
22
+
23
+ class HatangSignatureError(Exception):
24
+ """A webhook or IPN whose signature (or timestamp) does not match."""
hatang/_hatang.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from types import TracebackType
4
+ from typing import Any, Awaitable, Optional, Type
5
+
6
+ import httpx
7
+
8
+ from ._client import DEFAULT_TIMEOUT, JSON, AsyncTransport, SyncTransport, _Config
9
+ from ._resources import Batch, Chat, Emails, Events, Knowledge, Payments
10
+
11
+
12
+ class Hatang:
13
+ """The Ha Tang API.
14
+
15
+ ``Hatang("ht_…")``, or set ``HATANG_API_KEY``. Every method returns the API's JSON as a dict and raises
16
+ ``HatangError`` when the call does not succeed. Use it as a context manager, or call ``close()``, to release
17
+ its connections.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ api_key: Optional[str] = None,
23
+ *,
24
+ base_url: Optional[str] = None,
25
+ environment: Optional[str] = None,
26
+ timeout: float = DEFAULT_TIMEOUT,
27
+ http_client: Optional[httpx.Client] = None,
28
+ user_agent: Optional[str] = None,
29
+ ) -> None:
30
+ config = _Config(api_key, base_url, environment, user_agent)
31
+ self.base_url = config.base_url
32
+ self._transport = SyncTransport(config, timeout, http_client)
33
+ request, text = self._transport.request, self._transport.text
34
+ self.emails: Emails[JSON, str] = Emails(request, text)
35
+ self.batch: Batch[JSON, str] = Batch(request, text)
36
+ self.knowledge: Knowledge[JSON, str] = Knowledge(request, text)
37
+ self.chat: Chat[JSON, str] = Chat(request, text)
38
+ self.payments: Payments[JSON, str] = Payments(request, text)
39
+ self.events: Events[JSON, str] = Events(request, text)
40
+
41
+ def close(self) -> None:
42
+ self._transport.close()
43
+
44
+ def __enter__(self) -> "Hatang":
45
+ return self
46
+
47
+ def __exit__(self, exc_type: Optional[Type[BaseException]], exc: Optional[BaseException], tb: Optional[TracebackType]) -> None:
48
+ self.close()
49
+
50
+
51
+ class AsyncHatang:
52
+ """The Ha Tang API for asyncio (FastAPI, aiohttp…): the same namespaces as ``Hatang``, every method awaited."""
53
+
54
+ def __init__(
55
+ self,
56
+ api_key: Optional[str] = None,
57
+ *,
58
+ base_url: Optional[str] = None,
59
+ environment: Optional[str] = None,
60
+ timeout: float = DEFAULT_TIMEOUT,
61
+ http_client: Optional[httpx.AsyncClient] = None,
62
+ user_agent: Optional[str] = None,
63
+ ) -> None:
64
+ config = _Config(api_key, base_url, environment, user_agent)
65
+ self.base_url = config.base_url
66
+ self._transport = AsyncTransport(config, timeout, http_client)
67
+ request, text = self._transport.request, self._transport.text
68
+ self.emails: Emails[Awaitable[JSON], Awaitable[str]] = Emails(request, text)
69
+ self.batch: Batch[Awaitable[JSON], Awaitable[str]] = Batch(request, text)
70
+ self.knowledge: Knowledge[Awaitable[JSON], Awaitable[str]] = Knowledge(request, text)
71
+ self.chat: Chat[Awaitable[JSON], Awaitable[str]] = Chat(request, text)
72
+ self.payments: Payments[Awaitable[JSON], Awaitable[str]] = Payments(request, text)
73
+ self.events: Events[Awaitable[JSON], Awaitable[str]] = Events(request, text)
74
+
75
+ async def aclose(self) -> None:
76
+ await self._transport.aclose()
77
+
78
+ async def __aenter__(self) -> "AsyncHatang":
79
+ return self
80
+
81
+ async def __aexit__(self, *args: Any) -> None:
82
+ await self.aclose()
hatang/_resources.py ADDED
@@ -0,0 +1,262 @@
1
+ """API namespaces, shared by the sync and async clients.
2
+
3
+ Each method returns what the transport's ``request`` returns: the response dict for ``Hatang``, an awaitable of it
4
+ for ``AsyncHatang``. Names follow ``@hatang/sdk`` in snake_case.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ from typing import Any, Callable, Dict, Generic, List, Mapping, Optional, Sequence, TypeVar, Union
11
+
12
+ from ._client import Query, seg
13
+ from .types import (
14
+ ChatProductOptions,
15
+ ChatPurchaseOptions,
16
+ ChatSubscriptionOptions,
17
+ CreateChatOrderOptions,
18
+ CreateEmailOptions,
19
+ CreatePaymentOptions,
20
+ KnowledgeDocumentInput,
21
+ Platform,
22
+ SendChatMessageOptions,
23
+ )
24
+
25
+ R = TypeVar("R")
26
+ #: What text endpoints (CSV) return: ``str``, or an awaitable of it.
27
+ S = TypeVar("S")
28
+ Request = Callable[..., R]
29
+
30
+
31
+ def _idempotency(key: Optional[str]) -> Optional[Dict[str, str]]:
32
+ return {"Idempotency-Key": key} if key else None
33
+
34
+
35
+ def _encode_email(payload: Mapping[str, Any]) -> Dict[str, Any]:
36
+ """Attachments given as bytes travel base64-encoded, like the Node SDK's Buffers."""
37
+ body = dict(payload)
38
+ files = body.get("attachments")
39
+ if files:
40
+ body["attachments"] = [
41
+ {**a, "content": base64.b64encode(a["content"]).decode("ascii")} if isinstance(a.get("content"), (bytes, bytearray)) else dict(a)
42
+ for a in files
43
+ ]
44
+ return body
45
+
46
+
47
+ class _Namespace(Generic[R, S]):
48
+ def __init__(self, request: Request[R], text: Callable[..., S]) -> None:
49
+ self._request = request
50
+ self._text = text
51
+
52
+
53
+ class Emails(_Namespace[R, S]):
54
+ def send(self, payload: CreateEmailOptions, *, idempotency_key: Optional[str] = None) -> R:
55
+ """Send an email (or schedule it with ``scheduled_at``)."""
56
+ return self._request("POST", "/emails", _encode_email(payload), headers=_idempotency(idempotency_key))
57
+
58
+ def get(self, id: str) -> R:
59
+ return self._request("GET", f"/emails/{seg(id)}")
60
+
61
+ def list(self, *, limit: Optional[int] = None, after: Optional[str] = None, before: Optional[str] = None) -> R:
62
+ return self._request("GET", "/emails", query={"limit": limit, "after": after, "before": before})
63
+
64
+ def update(self, id: str, *, scheduled_at: str) -> R:
65
+ """Move a scheduled email."""
66
+ return self._request("PATCH", f"/emails/{seg(id)}", {"scheduled_at": scheduled_at})
67
+
68
+ def cancel(self, id: str) -> R:
69
+ """Cancel a scheduled email."""
70
+ return self._request("POST", f"/emails/{seg(id)}/cancel")
71
+
72
+
73
+ class Batch(_Namespace[R, S]):
74
+ def send(self, payloads: Sequence[CreateEmailOptions]) -> R:
75
+ """Up to 100 emails in one call."""
76
+ return self._request("POST", "/emails/batch", [_encode_email(p) for p in payloads])
77
+
78
+
79
+ class Knowledge(_Namespace[R, S]):
80
+ """The documents the AI assistant answers from, under your own ids."""
81
+
82
+ def upsert(self, id: str, document: KnowledgeDocumentInput) -> R:
83
+ return self._request("PUT", f"/chat/knowledge/documents/{seg(id)}", dict(document))
84
+
85
+ def batch(self, documents: Sequence[KnowledgeDocumentInput]) -> R:
86
+ """Several documents at once; each needs its ``external_id``."""
87
+ return self._request("POST", "/chat/knowledge/documents/batch", {"documents": [dict(d) for d in documents]})
88
+
89
+ def sync(self, collection: str, documents: Sequence[KnowledgeDocumentInput]) -> R:
90
+ """The collection then holds exactly these documents; unchanged ones are not re-indexed."""
91
+ return self._request("POST", f"/chat/knowledge/collections/{seg(collection)}/sync", {"documents": [dict(d) for d in documents]})
92
+
93
+ def get(self, id: str) -> R:
94
+ return self._request("GET", f"/chat/knowledge/documents/{seg(id)}")
95
+
96
+ def list(self, *, collection: Optional[str] = None, tag: Optional[str] = None, locale: Optional[str] = None, after: Optional[str] = None, limit: Optional[int] = None) -> R:
97
+ return self._request("GET", "/chat/knowledge/documents", query={"collection": collection, "tag": tag, "locale": locale, "after": after, "limit": limit})
98
+
99
+ def remove(self, id: str) -> R:
100
+ return self._request("DELETE", f"/chat/knowledge/documents/{seg(id)}")
101
+
102
+ def search(self, q: str, *, locale: Optional[str] = None, limit: Optional[int] = None) -> R:
103
+ """What the assistant would find for a question."""
104
+ return self._request("GET", "/chat/knowledge/search", query={"q": q, "locale": locale, "limit": limit})
105
+
106
+
107
+ class Subscriptions(_Namespace[R, S]):
108
+ """Plans on the web, the App Store and Google Play, on the customer's profile."""
109
+
110
+ def upsert(self, platform: Platform, original_id: str, payload: ChatSubscriptionOptions) -> R:
111
+ """Create or update by platform and your / the store's id (Apple originalTransactionId, Play purchase
112
+ token). Sending the same state again changes nothing."""
113
+ return self._request("PUT", f"/chat/subscriptions/{platform}/{seg(original_id)}", dict(payload))
114
+
115
+ def list(self, *, external_user_id: Optional[str] = None, platform: Optional[Platform] = None, status: Union[str, Sequence[str], None] = None, limit: Optional[int] = None) -> R:
116
+ return self._request("GET", "/chat/subscriptions", query={"external_user_id": external_user_id, "platform": platform, "status": status, "limit": limit})
117
+
118
+ def for_customer(self, customer_id: str) -> R:
119
+ return self._request("GET", f"/chat/customers/{seg(customer_id)}/subscriptions")
120
+
121
+ def purchase_token(self, site_id: str, user_id: str) -> R:
122
+ """The app account token, from the API. ``hatang.purchase_token`` computes the same value locally."""
123
+ return self._request("GET", f"/chat/sites/{seg(site_id)}/purchase-token", query={"user_id": user_id})
124
+
125
+
126
+ class Purchases(_Namespace[R, S]):
127
+ """One-time purchases: credit packs, unlocks, courses; refunds included."""
128
+
129
+ def upsert(self, platform: Platform, transaction_id: str, payload: ChatPurchaseOptions) -> R:
130
+ """Record or update by platform and your order id (web) or the store's transaction id."""
131
+ return self._request("PUT", f"/chat/purchases/{platform}/{seg(transaction_id)}", dict(payload))
132
+
133
+ def list(self, *, external_user_id: Optional[str] = None, platform: Optional[Platform] = None, limit: Optional[int] = None) -> R:
134
+ return self._request("GET", "/chat/purchases", query={"external_user_id": external_user_id, "platform": platform, "limit": limit})
135
+
136
+ def for_customer(self, customer_id: str) -> R:
137
+ return self._request("GET", f"/chat/customers/{seg(customer_id)}/purchases")
138
+
139
+
140
+ class Customers(_Namespace[R, S]):
141
+ def list(self, *, q: Optional[str] = None, stage: Optional[str] = None, label: Optional[str] = None, owner_id: Optional[str] = None, channel: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> R:
142
+ return self._request("GET", "/chat/customers", query={"q": q, "stage": stage, "label": label, "owner_id": owner_id, "channel": channel, "limit": limit, "after": after})
143
+
144
+ def get(self, id: str) -> R:
145
+ return self._request("GET", f"/chat/customers/{seg(id)}")
146
+
147
+ def update(self, id: str, payload: Mapping[str, Any]) -> R:
148
+ """Name, email, phone, owner, lead stage, labels and custom ``fields``."""
149
+ return self._request("PATCH", f"/chat/customers/{seg(id)}", dict(payload))
150
+
151
+ def conversations(self, id: str) -> R:
152
+ return self._request("GET", f"/chat/customers/{seg(id)}/conversations")
153
+
154
+ def export_csv(self, query: Optional[Query] = None) -> S:
155
+ """Everyone matching the filters, as CSV text."""
156
+ return self._text("/chat/customers/export.csv", query)
157
+
158
+
159
+ class Conversations(_Namespace[R, S]):
160
+ def list(self, *, status: Union[str, Sequence[str], None] = None, unanswered: Optional[bool] = None, limit: Optional[int] = None, after: Optional[str] = None, **filters: Any) -> R:
161
+ return self._request("GET", "/chat/conversations", query={"status": status, "unanswered": unanswered, "limit": limit, "after": after, **filters})
162
+
163
+ def get(self, id: str) -> R:
164
+ return self._request("GET", f"/chat/conversations/{seg(id)}")
165
+
166
+ def update(self, id: str, payload: Mapping[str, Any]) -> R:
167
+ """Assign, tag, resolve, hide."""
168
+ return self._request("PATCH", f"/chat/conversations/{seg(id)}", dict(payload))
169
+
170
+ def messages(self, id: str, *, after_seq: Optional[int] = None, limit: Optional[int] = None, include_private: Optional[bool] = None) -> R:
171
+ return self._request("GET", f"/chat/conversations/{seg(id)}/messages", query={"after_seq": after_seq, "limit": limit, "include_private": include_private})
172
+
173
+ def send(self, id: str, payload: SendChatMessageOptions, *, idempotency_key: Optional[str] = None) -> R:
174
+ """Reply as an agent, or add a private note."""
175
+ return self._request("POST", f"/chat/conversations/{seg(id)}/messages", dict(payload), headers=_idempotency(idempotency_key))
176
+
177
+ def send_products(self, id: str, product_ids: List[str], intro: Optional[str] = None) -> R:
178
+ """A product card (up to 10 products) with an optional intro line."""
179
+ body: Dict[str, Any] = {"product_ids": product_ids}
180
+ if intro:
181
+ body["intro"] = intro
182
+ return self._request("POST", f"/chat/conversations/{seg(id)}/products", body)
183
+
184
+
185
+ class Products(_Namespace[R, S]):
186
+ def list(self, *, q: Optional[str] = None, category: Optional[str] = None, active: Optional[bool] = None, limit: Optional[int] = None, after: Optional[str] = None) -> R:
187
+ return self._request("GET", "/chat/products", query={"q": q, "category": category, "active": active, "limit": limit, "after": after})
188
+
189
+ def search(self, q: str, limit: Optional[int] = None) -> R:
190
+ return self._request("GET", "/chat/products/search", query={"q": q, "limit": limit})
191
+
192
+ def get(self, id: str) -> R:
193
+ return self._request("GET", f"/chat/products/{seg(id)}")
194
+
195
+ def create(self, payload: ChatProductOptions) -> R:
196
+ return self._request("POST", "/chat/products", dict(payload))
197
+
198
+ def update(self, id: str, payload: ChatProductOptions) -> R:
199
+ return self._request("PATCH", f"/chat/products/{seg(id)}", dict(payload))
200
+
201
+ def remove(self, id: str) -> R:
202
+ return self._request("DELETE", f"/chat/products/{seg(id)}")
203
+
204
+
205
+ class Orders(_Namespace[R, S]):
206
+ def list(self, *, status: Optional[str] = None, customer_id: Optional[str] = None, conversation_id: Optional[str] = None, payment_status: Optional[str] = None, q: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> R:
207
+ return self._request("GET", "/chat/orders", query={"status": status, "customer_id": customer_id, "conversation_id": conversation_id, "payment_status": payment_status, "q": q, "limit": limit, "after": after})
208
+
209
+ def get(self, id: str) -> R:
210
+ return self._request("GET", f"/chat/orders/{seg(id)}")
211
+
212
+ def create(self, payload: CreateChatOrderOptions) -> R:
213
+ return self._request("POST", "/chat/orders", dict(payload))
214
+
215
+ def update(self, id: str, payload: Mapping[str, Any]) -> R:
216
+ return self._request("PATCH", f"/chat/orders/{seg(id)}", dict(payload))
217
+
218
+ def payment_link(self, id: str, *, expires_in: Optional[int] = None, send_card: Optional[bool] = None) -> R:
219
+ """A VietQR payment for the order's total; the card lands in the conversation unless ``send_card=False``."""
220
+ body = {k: v for k, v in {"expires_in": expires_in, "send_card": send_card}.items() if v is not None}
221
+ return self._request("POST", f"/chat/orders/{seg(id)}/payment-link", body)
222
+
223
+
224
+ class Chat(Generic[R, S]):
225
+ """Support: customers, conversations, plans and purchases, products, orders."""
226
+
227
+ def __init__(self, request: Request[R], text: Callable[..., S]) -> None:
228
+ self.customers = Customers(request, text)
229
+ self.conversations = Conversations(request, text)
230
+ self.subscriptions = Subscriptions(request, text)
231
+ self.purchases = Purchases(request, text)
232
+ self.products = Products(request, text)
233
+ self.orders = Orders(request, text)
234
+
235
+
236
+ class Payments(_Namespace[R, S]):
237
+ """VietQR payments, confirmed by your bank's transfer notification."""
238
+
239
+ def create(self, payload: CreatePaymentOptions, *, idempotency_key: Optional[str] = None) -> R:
240
+ return self._request("POST", "/payments", dict(payload), headers=_idempotency(idempotency_key))
241
+
242
+ def get(self, id: str) -> R:
243
+ return self._request("GET", f"/payments/{seg(id)}")
244
+
245
+ def list(self, *, status: Optional[str] = None, connection_id: Optional[str] = None, limit: Optional[int] = None, after: Optional[str] = None) -> R:
246
+ return self._request("GET", "/payments", query={"status": status, "connection_id": connection_id, "limit": limit, "after": after})
247
+
248
+ def cancel(self, id: str) -> R:
249
+ return self._request("POST", f"/payments/{seg(id)}/cancel")
250
+
251
+
252
+ class Events(_Namespace[R, S]):
253
+ def send(self, event: str, *, contact_id: Optional[str] = None, email: Optional[str] = None, payload: Optional[Mapping[str, Any]] = None) -> R:
254
+ """Trigger automations waiting for this event, for one contact (by id or email)."""
255
+ body: Dict[str, Any] = {"event": event}
256
+ if contact_id:
257
+ body["contact_id"] = contact_id
258
+ if email:
259
+ body["email"] = email
260
+ if payload is not None:
261
+ body["payload"] = dict(payload)
262
+ return self._request("POST", "/events/send", body)
hatang/_version.py ADDED
@@ -0,0 +1 @@
1
+ VERSION = "0.1.0"
hatang/helpers.py ADDED
@@ -0,0 +1,22 @@
1
+ """Values your backend computes itself, the same way Ha Tang's servers and the app SDKs do."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import uuid
8
+
9
+
10
+ def identity_hash(user_id: str, identity_secret: str) -> str:
11
+ """The signature that makes a chat identity trusted: hex HMAC-SHA256 of the user id with the site's identity
12
+ secret (Ha Tang → Support → Channels → the site → Widget). Pass it as ``hash`` with ``userId`` to ``identify``
13
+ in the widget or the app SDKs; never send the secret to the browser or the app."""
14
+ return hmac.new(identity_secret.encode("utf-8"), user_id.encode("utf-8"), hashlib.sha256).hexdigest()
15
+
16
+
17
+ def purchase_token(site_id: str, user_id: str) -> str:
18
+ """The app account token for one of your users: pass it as StoreKit ``appAccountToken`` / Google Play
19
+ ``obfuscatedAccountId`` so the stores' notifications reach that customer in Ha Tang. A UUID v5 of the user id
20
+ in the site's namespace; the app SDKs' ``purchaseToken`` gives the same value."""
21
+ namespace = uuid.uuid5(uuid.NAMESPACE_URL, f"hatang:site:{site_id}")
22
+ return str(uuid.uuid5(namespace, user_id))
hatang/py.typed ADDED
File without changes
hatang/types.py ADDED
@@ -0,0 +1,189 @@
1
+ """Typed request bodies, matching the REST API and ``@hatang/sdk``. Responses are plain dicts as the API sends them.
2
+
3
+ Every key is optional to the type checker (``total=False``); the docstrings say which ones the API requires.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, List, Literal, TypedDict, Union
9
+
10
+ Platform = Literal["web", "ios", "android"]
11
+ SubscriptionStatus = Literal["trial", "active", "grace", "on_hold", "paused", "canceled", "expired", "refunded", "revoked"]
12
+ PurchaseKind = Literal["consumable", "non_consumable", "non_renewing"]
13
+
14
+
15
+ class ChatSubscriptionOptions(TypedDict, total=False):
16
+ """A plan your backend knows about. ``status`` is required."""
17
+
18
+ status: SubscriptionStatus
19
+ store_product_id: str | None
20
+ base_plan_id: str | None
21
+ product_id: str | None
22
+ #: The catalog product's SKU: how a plan sold on your own site finds its product.
23
+ sku: str | None
24
+ #: The user id you pass to ``identify`` in the chat: links the plan to the customer.
25
+ external_user_id: str | None
26
+ account_token: str | None
27
+ customer_id: str | None
28
+ auto_renew: bool | None
29
+ environment: Literal["production", "sandbox"]
30
+ started_at: str | None
31
+ current_period_end: str | None
32
+ canceled_at: str | None
33
+ amount: float | None
34
+ currency: str | None
35
+ #: What happened, for the customer's timeline (``renewed``, ``canceled``…), and when.
36
+ event: str
37
+ event_at: str
38
+
39
+
40
+ class ChatPurchaseOptions(TypedDict, total=False):
41
+ """A one-time purchase (credit pack, unlock, course). ``store_product_id`` is required: the store's product
42
+ id, or on the web your own code for what was sold."""
43
+
44
+ store_product_id: str
45
+ kind: PurchaseKind
46
+ status: Literal["purchased", "refunded"]
47
+ product_id: str | None
48
+ sku: str | None
49
+ quantity: int
50
+ external_user_id: str | None
51
+ customer_id: str | None
52
+ environment: Literal["production", "sandbox"]
53
+ amount: float | None
54
+ currency: str | None
55
+ purchased_at: str | None
56
+ refunded_at: str | None
57
+
58
+
59
+ class KnowledgeDocumentInput(TypedDict, total=False):
60
+ """A document the AI assistant answers from. ``title`` and ``content`` (Markdown, up to 200,000 characters)
61
+ are required."""
62
+
63
+ title: str
64
+ content: str
65
+ #: The page the document lives at, cited in answers.
66
+ url: str | None
67
+ #: Its language, such as ``vi`` or ``en``.
68
+ locale: str | None
69
+ tags: List[str]
70
+ #: A set you can sync as a whole (``knowledge.sync``).
71
+ collection: str | None
72
+ #: Your id for it, in ``knowledge.batch`` and ``knowledge.sync``.
73
+ external_id: str
74
+
75
+
76
+ class Attachment(TypedDict, total=False):
77
+ """``filename`` and ``content`` are required; ``bytes`` content is base64-encoded for you."""
78
+
79
+ filename: str
80
+ content: Union[str, bytes]
81
+ content_type: str
82
+ content_id: str
83
+
84
+
85
+ class Tag(TypedDict):
86
+ name: str
87
+ value: str
88
+
89
+
90
+ class EmailTemplate(TypedDict, total=False):
91
+ id: str
92
+ variables: Dict[str, Union[str, int, float, bool, None]]
93
+
94
+
95
+ class CreateEmailOptions(TypedDict, total=False):
96
+ """``to`` is required; ``from`` and ``subject`` too unless a ``template`` sets them. Pass ``from`` with
97
+ ``**{"from": …}`` or as a dict literal, since ``from`` is a Python keyword."""
98
+
99
+ to: Union[str, List[str]]
100
+ subject: str
101
+ html: str
102
+ text: str
103
+ template: EmailTemplate
104
+ cc: Union[str, List[str]]
105
+ bcc: Union[str, List[str]]
106
+ reply_to: Union[str, List[str]]
107
+ headers: Dict[str, str]
108
+ tags: List[Tag]
109
+ attachments: List[Attachment]
110
+ scheduled_at: str
111
+ topic_id: str
112
+
113
+
114
+ class SendChatMessageOptions(TypedDict, total=False):
115
+ """``content`` is required. ``visibility="private"`` is an internal note the visitor never sees."""
116
+
117
+ content: str
118
+ visibility: Literal["public", "private"]
119
+ attachments: List[str]
120
+ sender_name: str
121
+ metadata: Dict[str, Any]
122
+
123
+
124
+ class PaymentCustomer(TypedDict, total=False):
125
+ name: str
126
+ email: str
127
+ phone: str
128
+
129
+
130
+ class CreatePaymentOptions(TypedDict, total=False):
131
+ """A VietQR payment. ``amount`` (VND) is required."""
132
+
133
+ amount: int
134
+ description: str
135
+ #: Your own transfer description (your order number): letters and digits, 6 to 20.
136
+ code: str
137
+ connection_id: str
138
+ expires_in: int
139
+ return_url: str
140
+ #: Your endpoint for this payment's IPN calls; check them with ``verify_ipn``.
141
+ ipn_url: str
142
+ customer: PaymentCustomer
143
+ metadata: Dict[str, Union[str, int, float, bool, None]]
144
+
145
+
146
+ class ChatOrderItem(TypedDict, total=False):
147
+ """``name``, ``price`` and ``quantity`` are required."""
148
+
149
+ product_id: str | None
150
+ name: str
151
+ sku: str | None
152
+ variant: str | None
153
+ price: int
154
+ quantity: int
155
+
156
+
157
+ class CreateChatOrderOptions(TypedDict, total=False):
158
+ """``items`` is required."""
159
+
160
+ conversation_id: str
161
+ customer_id: str
162
+ items: List[ChatOrderItem]
163
+ discount: int
164
+ shipping: int
165
+ shipping_address: Dict[str, Any]
166
+ note: str | None
167
+ status: str
168
+ invoice: bool
169
+ send_card: bool
170
+
171
+
172
+ class ChatProductOptions(TypedDict, total=False):
173
+ """``name`` is required. ``prices`` holds the App Store / Google Play prices and store ids."""
174
+
175
+ name: str
176
+ sku: str | None
177
+ description: str | None
178
+ price: int
179
+ compare_at_price: int | None
180
+ image_url: str | None
181
+ url: str | None
182
+ category: str | None
183
+ stock: int | None
184
+ active: bool
185
+ kind: str
186
+ billing_period: str | None
187
+ trial_days: int | None
188
+ prices: List[Dict[str, Any]]
189
+ variants: List[Dict[str, Any]]
hatang/webhooks.py ADDED
@@ -0,0 +1,83 @@
1
+ """Checking what Ha Tang sends to your endpoints: webhook deliveries and payment IPN calls.
2
+
3
+ Always pass the raw request body exactly as received (bytes or str), before any JSON parsing.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import base64
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import time
13
+ from typing import Any, Dict, Mapping, Optional, Union
14
+
15
+ from ._errors import HatangSignatureError
16
+
17
+ RawBody = Union[bytes, bytearray, str]
18
+ Headers = Mapping[str, Any]
19
+
20
+
21
+ def _text(body: RawBody) -> str:
22
+ return body.decode("utf-8") if isinstance(body, (bytes, bytearray)) else body
23
+
24
+
25
+ def _header(headers: Headers, name: str) -> Optional[str]:
26
+ getter = getattr(headers, "get", None)
27
+ for key in (name, name.title()):
28
+ value = getter(key) if getter else None
29
+ if value is not None:
30
+ break
31
+ else:
32
+ value = next((v for k, v in headers.items() if str(k).lower() == name), None)
33
+ if isinstance(value, (list, tuple)):
34
+ value = value[0] if value else None
35
+ return None if value is None else str(value)
36
+
37
+
38
+ def verify_webhook(raw_body: RawBody, headers: Headers, secret: str, *, tolerance_sec: int = 300) -> bool:
39
+ """True when a webhook delivery is signed with your endpoint's ``whsec_…`` secret (Standard Webhooks /
40
+ Svix headers) and is no older than ``tolerance_sec`` (5 minutes by default, against replays)."""
41
+ msg_id = _header(headers, "svix-id") or _header(headers, "webhook-id")
42
+ ts = _header(headers, "svix-timestamp") or _header(headers, "webhook-timestamp")
43
+ sigs = _header(headers, "svix-signature") or _header(headers, "webhook-signature")
44
+ if not msg_id or not ts or not sigs:
45
+ return False
46
+ try:
47
+ sent = int(ts)
48
+ except ValueError:
49
+ return False
50
+ if abs(time.time() - sent) > tolerance_sec:
51
+ return False
52
+ key = base64.b64decode(secret[6:] if secret.startswith("whsec_") else secret)
53
+ expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.{_text(raw_body)}".encode("utf-8"), hashlib.sha256).digest()).decode("ascii")
54
+ for part in sigs.split(" "):
55
+ version, _, sig = part.partition(",")
56
+ if version == "v1" and sig and hmac.compare_digest(sig, expected):
57
+ return True
58
+ return False
59
+
60
+
61
+ def construct_webhook_event(raw_body: RawBody, headers: Headers, secret: str, *, tolerance_sec: int = 300) -> Dict[str, Any]:
62
+ """The verified event (``type``, ``created_at``, ``data``), or ``HatangSignatureError``."""
63
+ if not verify_webhook(raw_body, headers, secret, tolerance_sec=tolerance_sec):
64
+ raise HatangSignatureError("Webhook signature or timestamp is not valid")
65
+ event: Dict[str, Any] = json.loads(_text(raw_body))
66
+ return event
67
+
68
+
69
+ def verify_ipn(raw_body: RawBody, signature: Optional[str], secret_key: str) -> bool:
70
+ """True when a payment IPN call matches its ``Hatang-Signature`` header: hex HMAC-SHA256 of the body with
71
+ your secret key (Payments settings → Integration keys, of the call's environment)."""
72
+ if not signature:
73
+ return False
74
+ expected = hmac.new(secret_key.encode("utf-8"), _text(raw_body).encode("utf-8"), hashlib.sha256).hexdigest()
75
+ return hmac.compare_digest(signature.strip().lower(), expected)
76
+
77
+
78
+ def construct_ipn_event(raw_body: RawBody, headers: Headers, secret_key: str) -> Dict[str, Any]:
79
+ """The verified IPN call (``data.status``, ``data.metadata``…), or ``HatangSignatureError``."""
80
+ if not verify_ipn(raw_body, _header(headers, "hatang-signature"), secret_key):
81
+ raise HatangSignatureError("Hatang-Signature does not match the body and secret key")
82
+ event: Dict[str, Any] = json.loads(_text(raw_body))
83
+ return event
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.5
2
+ Name: hatang
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for Ha Tang (hatang.dev): support CRM, plans and purchases, AI knowledge, email, payments, webhooks.
5
+ Project-URL: Homepage, https://hatang.dev
6
+ Project-URL: Documentation, https://hatang.dev/docs/guides/sdks/python
7
+ Project-URL: Source, https://github.com/sota-labs/hatang/tree/main/sdks/python
8
+ Project-URL: Changelog, https://github.com/sota-labs/hatang/blob/main/sdks/python/CHANGELOG.md
9
+ Author-email: Sota Labs <hello@hatang.dev>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: chat,crm,email,hatang,in-app purchases,support,vietqr,webhooks
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Topic :: Communications :: Email
19
+ Classifier: Topic :: Software Development :: Libraries
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx<1,>=0.25
23
+ Description-Content-Type: text/markdown
24
+
25
+ # hatang
26
+
27
+ The official Python SDK for [Ha Tang](https://hatang.dev): support CRM, plans and purchases on the customer's profile,
28
+ the AI assistant's knowledge, email, VietQR payments and webhooks.
29
+
30
+ ```bash
31
+ pip install hatang
32
+ ```
33
+
34
+ Python 3.9+. Sync (`Hatang`) and asyncio (`AsyncHatang`) clients, typed request bodies.
35
+
36
+ ## Quick start
37
+
38
+ ```python
39
+ from hatang import Hatang
40
+
41
+ hatang = Hatang() # reads HATANG_API_KEY; or Hatang("ht_…")
42
+
43
+ # What a customer pays for, from your backend (web sales included).
44
+ hatang.chat.subscriptions.upsert("web", "sub_123", {
45
+ "status": "active",
46
+ "sku": "PRO-1M", # finds the product in your catalog
47
+ "external_user_id": "user-42", # the id you pass to identify() in the chat
48
+ "current_period_end": "2026-12-01T00:00:00Z",
49
+ "amount": 99000, "currency": "VND",
50
+ })
51
+ hatang.chat.purchases.upsert("web", "order_981", {
52
+ "store_product_id": "credits.300", "sku": "XU-300", "kind": "consumable",
53
+ "external_user_id": "user-42", "amount": 29000, "currency": "VND",
54
+ })
55
+ ```
56
+
57
+ asyncio (FastAPI, aiohttp…):
58
+
59
+ ```python
60
+ from hatang import AsyncHatang
61
+
62
+ async with AsyncHatang() as hatang:
63
+ hits = await hatang.knowledge.search("hoàn tiền", limit=3)
64
+ ```
65
+
66
+ Every method returns the API's JSON as a `dict`. A call that does not succeed raises `HatangError` with `status_code`
67
+ (0 when Ha Tang was not reached), `name` (`validation_error`, `not_found`…), `message`, and `retryable` (unreachable,
68
+ 429 or 5xx).
69
+
70
+ ## What is in it
71
+
72
+ | Namespace | Methods |
73
+ |---|---|
74
+ | `chat.subscriptions` | `upsert`, `list`, `for_customer`, `purchase_token` |
75
+ | `chat.purchases` | `upsert`, `list`, `for_customer` |
76
+ | `chat.customers` | `list`, `get`, `update`, `conversations`, `export_csv` |
77
+ | `chat.conversations` | `list`, `get`, `update`, `messages`, `send`, `send_products` |
78
+ | `chat.products` | `list`, `search`, `get`, `create`, `update`, `remove` |
79
+ | `chat.orders` | `list`, `get`, `create`, `update`, `payment_link` |
80
+ | `knowledge` | `upsert`, `batch`, `sync`, `get`, `list`, `remove`, `search` |
81
+ | `emails`, `batch` | `send`, `get`, `list`, `update`, `cancel`; `batch.send` |
82
+ | `payments` | `create`, `get`, `list`, `cancel` |
83
+ | `events` | `send` |
84
+
85
+ ## Helpers
86
+
87
+ ```python
88
+ from hatang import identity_hash, purchase_token, construct_webhook_event, construct_ipn_event
89
+
90
+ # Signed chat identity: send this as `hash` with userId to identify() in the widget or the app SDKs.
91
+ identity_hash("user-42", IDENTITY_SECRET)
92
+
93
+ # StoreKit appAccountToken / Google Play obfuscatedAccountId for one of your users.
94
+ purchase_token("site_…", "user-42")
95
+
96
+ # Webhooks (whsec_… secret) and payment IPN calls: pass the raw body, before JSON parsing.
97
+ event = construct_webhook_event(request.body, request.headers, WEBHOOK_SECRET)
98
+ ipn = construct_ipn_event(request.body, request.headers, SECRET_KEY)
99
+ ```
100
+
101
+ Both raise `HatangSignatureError` when the signature does not match; `verify_webhook` / `verify_ipn` return a bool.
102
+
103
+ ## Configuration
104
+
105
+ | Argument | Environment | Default |
106
+ |---|---|---|
107
+ | `api_key` | `HATANG_API_KEY` | required |
108
+ | `base_url` | `HATANG_BASE_URL` | `https://api.hatang.dev` |
109
+ | `environment` | `HATANG_ENVIRONMENT` | the key's own |
110
+ | `timeout` | | 30 s |
111
+ | `http_client` | | an `httpx.Client` / `httpx.AsyncClient` of your own (proxies, retries, tests) |
112
+
113
+ Docs: [hatang.dev/docs](https://hatang.dev/docs/guides/sdks/python) · License: MIT
@@ -0,0 +1,14 @@
1
+ hatang/__init__.py,sha256=epToB2gBaRvt9rSDFwwmqnBOjycmue-_8SheDMExo48,837
2
+ hatang/_client.py,sha256=I066Q6S_4O9Zw20tlQo-ClZTcCa2wAu1jCkFy-65kik,5471
3
+ hatang/_errors.py,sha256=ivX2UrVqsdH8cg5RkYIb-dHAVhtLv-XQAY7Wd39wrMc,900
4
+ hatang/_hatang.py,sha256=2yUYjblfSwzr5IosAHwQEb71KR1bHcna0KpNhiXhKpA,3259
5
+ hatang/_resources.py,sha256=uhV9ZCU44APnWUv1djmVZse_022dGKns7PPO18XN-2c,13458
6
+ hatang/_version.py,sha256=CpXi3jGlx23RvRyU7iytOMZrnspdWw4yofS8lpP1AJU,18
7
+ hatang/helpers.py,sha256=-2nazAkSF0zY8-x2SUCLYW4fIsmXo2Won8CHnf2AB3w,1147
8
+ hatang/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ hatang/types.py,sha256=yfkRPR4usyCCeSpK85cUJYJaUuJbV3nDf237kC-g6fQ,5537
10
+ hatang/webhooks.py,sha256=Lyoq_SBJm5hHNXwyMHWRGjvQyP6tcoNLbhI6qgwKrnU,3660
11
+ hatang-0.1.0.dist-info/METADATA,sha256=QXWymCQFjTqmVf3ULfuNqMVziOXbRm-m-JCCKAPP4BI,4482
12
+ hatang-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
13
+ hatang-0.1.0.dist-info/licenses/LICENSE,sha256=AGhx7zYemIO7k3AXES_KqzvSBYQFmz906OI3mf1SpZE,1071
14
+ hatang-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-2026 Sota Labs
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.