lipay-sdk 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lipay
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
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
@@ -0,0 +1,230 @@
1
+ Metadata-Version: 2.4
2
+ Name: lipay-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for Lipay Messaging Platform
5
+ Author-email: Evans Musamia <evans@lipay.store>
6
+ Maintainer-email: Lipay Platform <dev@lipay.store>
7
+ License: MIT
8
+ Project-URL: Homepage, https://message.lipay.store
9
+ Project-URL: Documentation, https://github.com/Evans-musamia/LipayMessagingPlatform#readme
10
+ Project-URL: Repository, https://github.com/Evans-musamia/LipayMessagingPlatform
11
+ Project-URL: Issues, https://github.com/Evans-musamia/LipayMessagingPlatform/issues
12
+ Project-URL: Changelog, https://github.com/Evans-musamia/LipayMessagingPlatform/releases
13
+ Keywords: lipay,whatsapp,messaging,africa,customer-service-window,api-sdk
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Environment :: Web Environment
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Communications :: Telephony
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: httpx>=0.27.0
29
+ Requires-Dist: pydantic>=2.9.0
30
+ Provides-Extra: fastapi
31
+ Requires-Dist: fastapi>=0.115.0; extra == "fastapi"
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8.0; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
35
+ Requires-Dist: build>=1.0; extra == "dev"
36
+ Requires-Dist: twine>=5.0; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # Lipay Python SDK
40
+
41
+ Official Python client for [Lipay](https://message.lipay.store) — the developer platform for WhatsApp messaging, routing, and Customer Service Window (CSW) management across Africa.
42
+
43
+ Use this library in your application (BrandFlow, Teacher, custom backends) to check whether the Meta **24-hour customer service window** is open before sending free-text WhatsApp messages.
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ **From PyPI (recommended):**
50
+
51
+ ```bash
52
+ pip install lipay-sdk
53
+ pip install "lipay-sdk[fastapi]"
54
+ ```
55
+
56
+ Pin in `requirements.txt`:
57
+
58
+ ```text
59
+ lipay-sdk==1.0.0
60
+ ```
61
+
62
+ **From Git (tagged release):**
63
+
64
+ ```bash
65
+ pip install git+https://github.com/Evans-musamia/LipayMessagingPlatform.git@v1.0.0
66
+ ```
67
+
68
+ **TestPyPI (maintainers — sandbox):**
69
+
70
+ ```bash
71
+ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ lipay-sdk
72
+ ```
73
+
74
+ **Editable (local development):**
75
+
76
+ ```bash
77
+ git clone https://github.com/Evans-musamia/LipayMessagingPlatform.git
78
+ cd LipayMessagingPlatform
79
+ pip install -e ".[dev]"
80
+ ```
81
+
82
+ Publishing guide: [PYPI.md](PYPI.md)
83
+
84
+ ---
85
+
86
+ ## Quick start
87
+
88
+ ```python
89
+ import asyncio
90
+
91
+ from lipay_sdk import LipayCswSessionGuard
92
+
93
+
94
+ async def main() -> None:
95
+ guard = LipayCswSessionGuard("https://message.lipay.store")
96
+
97
+ active = await guard.is_window_active(
98
+ customer_phone="+254700000000",
99
+ business_phone="+254711111111",
100
+ )
101
+
102
+ if active:
103
+ print("CSW open — safe to send free-text via Lipay /v1/whatsapp/send")
104
+ else:
105
+ print("CSW closed — send an approved template first")
106
+
107
+ await guard.close()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ asyncio.run(main())
112
+ ```
113
+
114
+ ### Configuration object (Pydantic)
115
+
116
+ For explicit control over timeouts, cache TTL, and API paths:
117
+
118
+ ```python
119
+ from lipay_sdk.csw_session_guard import LipayCswSessionGuard, LipaySdkConfig
120
+
121
+ config = LipaySdkConfig(
122
+ gateway_url="https://message.lipay.store",
123
+ local_active_ttl_seconds=180,
124
+ http_timeout_seconds=10.0,
125
+ )
126
+ guard = LipayCswSessionGuard(config)
127
+ ```
128
+
129
+ ### Full status payload
130
+
131
+ ```python
132
+ status = await guard.get_session_status(
133
+ customer_phone="254700000000",
134
+ business_phone="254711111111",
135
+ )
136
+ print(status.is_communication_window_active, status.window_expires_at)
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Caching behavior
142
+
143
+ The SDK implements an **in-process memory guard** in front of Lipay's Redis-backed `GET /api/v1/switchboard/session-status` endpoint.
144
+
145
+ | Event | Behavior |
146
+ |--------|----------|
147
+ | Lipay returns `active=true` | Response cached in your app process RAM for **180 seconds** (configurable via `LipaySdkConfig.local_active_ttl_seconds`) |
148
+ | Repeat lookup within TTL | Served from local memory — **no HTTP call** to Lipay |
149
+ | Lipay returns `active=false` | **Never cached** — every lookup hits Lipay |
150
+ | Local TTL expires | Cache entry dropped; next lookup syncs from Lipay |
151
+
152
+ This mirrors how Twilio-style SDKs reduce read amplification: your container avoids redundant session-status traffic while the gateway remains the source of truth on Redis.
153
+
154
+ ---
155
+
156
+ ## FastAPI integration
157
+
158
+ Install the optional extra:
159
+
160
+ ```bash
161
+ pip install "lipay-sdk[fastapi] @ git+https://github.com/Evans-musamia/LipayMessagingPlatform.git@v1.0.0"
162
+ ```
163
+
164
+ Wire the guard on application startup:
165
+
166
+ ```python
167
+ from contextlib import asynccontextmanager
168
+
169
+ import httpx
170
+ from fastapi import Depends, FastAPI, Request
171
+
172
+ from lipay_sdk import LipayCswSessionGuard
173
+ from lipay_sdk.csw_session_guard import LipaySdkConfig
174
+
175
+
176
+ @asynccontextmanager
177
+ async def lifespan(app: FastAPI):
178
+ http = httpx.AsyncClient(timeout=10.0)
179
+ config = LipaySdkConfig(gateway_url="https://message.lipay.store")
180
+ app.state.lipay_guard = LipayCswSessionGuard(config, http_client=http)
181
+ yield
182
+ await app.state.lipay_guard.close()
183
+
184
+
185
+ app = FastAPI(lifespan=lifespan)
186
+
187
+
188
+ def get_guard(request: Request) -> LipayCswSessionGuard:
189
+ return request.app.state.lipay_guard
190
+
191
+
192
+ @app.get("/check-window")
193
+ async def check_window(
194
+ customer_phone: str,
195
+ business_phone: str,
196
+ guard: LipayCswSessionGuard = Depends(get_guard),
197
+ ):
198
+ return await guard.get_session_status(
199
+ customer_phone=customer_phone,
200
+ business_phone=business_phone,
201
+ )
202
+ ```
203
+
204
+ ---
205
+
206
+ ## API reference
207
+
208
+ ### `LipayCswSessionGuard`
209
+
210
+ | Method | Description |
211
+ |--------|-------------|
212
+ | `is_window_active(customer_phone, business_phone)` | `bool` — CSW open for pair |
213
+ | `get_session_status(...)` | `SessionStatus` — full gateway payload |
214
+ | `fetch_from_lipay(...)` | Always calls Lipay (bypasses local cache) |
215
+ | `close()` | Close owned `httpx` client |
216
+
217
+ ### `LipaySdkConfig` (Pydantic)
218
+
219
+ | Field | Default | Description |
220
+ |-------|---------|-------------|
221
+ | `gateway_url` | required | Lipay gateway base URL |
222
+ | `local_active_ttl_seconds` | `180` | Local RAM cache TTL |
223
+ | `http_timeout_seconds` | `5.0` | HTTP client timeout |
224
+ | `session_status_path` | `/api/v1/switchboard/session-status` | Status endpoint path |
225
+
226
+ ---
227
+
228
+ ## License
229
+
230
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,192 @@
1
+ # Lipay Python SDK
2
+
3
+ Official Python client for [Lipay](https://message.lipay.store) — the developer platform for WhatsApp messaging, routing, and Customer Service Window (CSW) management across Africa.
4
+
5
+ Use this library in your application (BrandFlow, Teacher, custom backends) to check whether the Meta **24-hour customer service window** is open before sending free-text WhatsApp messages.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ **From PyPI (recommended):**
12
+
13
+ ```bash
14
+ pip install lipay-sdk
15
+ pip install "lipay-sdk[fastapi]"
16
+ ```
17
+
18
+ Pin in `requirements.txt`:
19
+
20
+ ```text
21
+ lipay-sdk==1.0.0
22
+ ```
23
+
24
+ **From Git (tagged release):**
25
+
26
+ ```bash
27
+ pip install git+https://github.com/Evans-musamia/LipayMessagingPlatform.git@v1.0.0
28
+ ```
29
+
30
+ **TestPyPI (maintainers — sandbox):**
31
+
32
+ ```bash
33
+ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ lipay-sdk
34
+ ```
35
+
36
+ **Editable (local development):**
37
+
38
+ ```bash
39
+ git clone https://github.com/Evans-musamia/LipayMessagingPlatform.git
40
+ cd LipayMessagingPlatform
41
+ pip install -e ".[dev]"
42
+ ```
43
+
44
+ Publishing guide: [PYPI.md](PYPI.md)
45
+
46
+ ---
47
+
48
+ ## Quick start
49
+
50
+ ```python
51
+ import asyncio
52
+
53
+ from lipay_sdk import LipayCswSessionGuard
54
+
55
+
56
+ async def main() -> None:
57
+ guard = LipayCswSessionGuard("https://message.lipay.store")
58
+
59
+ active = await guard.is_window_active(
60
+ customer_phone="+254700000000",
61
+ business_phone="+254711111111",
62
+ )
63
+
64
+ if active:
65
+ print("CSW open — safe to send free-text via Lipay /v1/whatsapp/send")
66
+ else:
67
+ print("CSW closed — send an approved template first")
68
+
69
+ await guard.close()
70
+
71
+
72
+ if __name__ == "__main__":
73
+ asyncio.run(main())
74
+ ```
75
+
76
+ ### Configuration object (Pydantic)
77
+
78
+ For explicit control over timeouts, cache TTL, and API paths:
79
+
80
+ ```python
81
+ from lipay_sdk.csw_session_guard import LipayCswSessionGuard, LipaySdkConfig
82
+
83
+ config = LipaySdkConfig(
84
+ gateway_url="https://message.lipay.store",
85
+ local_active_ttl_seconds=180,
86
+ http_timeout_seconds=10.0,
87
+ )
88
+ guard = LipayCswSessionGuard(config)
89
+ ```
90
+
91
+ ### Full status payload
92
+
93
+ ```python
94
+ status = await guard.get_session_status(
95
+ customer_phone="254700000000",
96
+ business_phone="254711111111",
97
+ )
98
+ print(status.is_communication_window_active, status.window_expires_at)
99
+ ```
100
+
101
+ ---
102
+
103
+ ## Caching behavior
104
+
105
+ The SDK implements an **in-process memory guard** in front of Lipay's Redis-backed `GET /api/v1/switchboard/session-status` endpoint.
106
+
107
+ | Event | Behavior |
108
+ |--------|----------|
109
+ | Lipay returns `active=true` | Response cached in your app process RAM for **180 seconds** (configurable via `LipaySdkConfig.local_active_ttl_seconds`) |
110
+ | Repeat lookup within TTL | Served from local memory — **no HTTP call** to Lipay |
111
+ | Lipay returns `active=false` | **Never cached** — every lookup hits Lipay |
112
+ | Local TTL expires | Cache entry dropped; next lookup syncs from Lipay |
113
+
114
+ This mirrors how Twilio-style SDKs reduce read amplification: your container avoids redundant session-status traffic while the gateway remains the source of truth on Redis.
115
+
116
+ ---
117
+
118
+ ## FastAPI integration
119
+
120
+ Install the optional extra:
121
+
122
+ ```bash
123
+ pip install "lipay-sdk[fastapi] @ git+https://github.com/Evans-musamia/LipayMessagingPlatform.git@v1.0.0"
124
+ ```
125
+
126
+ Wire the guard on application startup:
127
+
128
+ ```python
129
+ from contextlib import asynccontextmanager
130
+
131
+ import httpx
132
+ from fastapi import Depends, FastAPI, Request
133
+
134
+ from lipay_sdk import LipayCswSessionGuard
135
+ from lipay_sdk.csw_session_guard import LipaySdkConfig
136
+
137
+
138
+ @asynccontextmanager
139
+ async def lifespan(app: FastAPI):
140
+ http = httpx.AsyncClient(timeout=10.0)
141
+ config = LipaySdkConfig(gateway_url="https://message.lipay.store")
142
+ app.state.lipay_guard = LipayCswSessionGuard(config, http_client=http)
143
+ yield
144
+ await app.state.lipay_guard.close()
145
+
146
+
147
+ app = FastAPI(lifespan=lifespan)
148
+
149
+
150
+ def get_guard(request: Request) -> LipayCswSessionGuard:
151
+ return request.app.state.lipay_guard
152
+
153
+
154
+ @app.get("/check-window")
155
+ async def check_window(
156
+ customer_phone: str,
157
+ business_phone: str,
158
+ guard: LipayCswSessionGuard = Depends(get_guard),
159
+ ):
160
+ return await guard.get_session_status(
161
+ customer_phone=customer_phone,
162
+ business_phone=business_phone,
163
+ )
164
+ ```
165
+
166
+ ---
167
+
168
+ ## API reference
169
+
170
+ ### `LipayCswSessionGuard`
171
+
172
+ | Method | Description |
173
+ |--------|-------------|
174
+ | `is_window_active(customer_phone, business_phone)` | `bool` — CSW open for pair |
175
+ | `get_session_status(...)` | `SessionStatus` — full gateway payload |
176
+ | `fetch_from_lipay(...)` | Always calls Lipay (bypasses local cache) |
177
+ | `close()` | Close owned `httpx` client |
178
+
179
+ ### `LipaySdkConfig` (Pydantic)
180
+
181
+ | Field | Default | Description |
182
+ |-------|---------|-------------|
183
+ | `gateway_url` | required | Lipay gateway base URL |
184
+ | `local_active_ttl_seconds` | `180` | Local RAM cache TTL |
185
+ | `http_timeout_seconds` | `5.0` | HTTP client timeout |
186
+ | `session_status_path` | `/api/v1/switchboard/session-status` | Status endpoint path |
187
+
188
+ ---
189
+
190
+ ## License
191
+
192
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,5 @@
1
+ """Official Lipay Python SDK — public tenant integration surface."""
2
+
3
+ from lipay_sdk.csw_session_guard import LipayCswSessionGuard
4
+
5
+ __all__ = ["LipayCswSessionGuard"]
@@ -0,0 +1,196 @@
1
+ """
2
+ Customer Service Window (CSW) session guard for Lipay tenant applications.
3
+
4
+ Queries Lipay's Redis-backed session-status API with an in-process TTL cache
5
+ so active-window lookups avoid redundant network round-trips.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from typing import Any
12
+ from urllib.parse import urlencode
13
+
14
+ import httpx
15
+ from pydantic import BaseModel, Field, field_validator
16
+
17
+
18
+ def _normalize_phone(phone: str) -> str:
19
+ cleaned = phone.strip().replace(" ", "").replace("-", "")
20
+ if not cleaned:
21
+ return cleaned
22
+ if not cleaned.startswith("+"):
23
+ return f"+{cleaned.lstrip('+')}"
24
+ return cleaned
25
+
26
+
27
+ class LipaySdkConfig(BaseModel):
28
+ """Runtime configuration for the Lipay HTTP client and local cache."""
29
+
30
+ gateway_url: str = Field(
31
+ ...,
32
+ description="Lipay gateway base URL, e.g. https://message.lipay.store",
33
+ min_length=8,
34
+ )
35
+ local_active_ttl_seconds: int = Field(
36
+ default=180,
37
+ ge=0,
38
+ description="In-process cache TTL after a successful active=true response",
39
+ )
40
+ http_timeout_seconds: float = Field(default=5.0, gt=0)
41
+ session_status_path: str = Field(
42
+ default="/api/v1/switchboard/session-status",
43
+ description="Path appended to gateway_url for CSW lookups",
44
+ )
45
+
46
+ @field_validator("gateway_url", "session_status_path")
47
+ @classmethod
48
+ def strip_slashes(cls, value: str) -> str:
49
+ return value.strip()
50
+
51
+
52
+ class SessionStatus(BaseModel):
53
+ """Normalized response from Lipay session-status."""
54
+
55
+ customer_phone_number: str
56
+ business_phone_number: str
57
+ is_communication_window_active: bool
58
+ window_expires_at: str | None = None
59
+
60
+ @classmethod
61
+ def from_api_payload(cls, payload: dict[str, Any]) -> SessionStatus:
62
+ return cls.model_validate(payload)
63
+
64
+
65
+ class LipayCswSessionGuard:
66
+ """
67
+ In-process TTL cache + Lipay ``GET /api/v1/switchboard/session-status`` client.
68
+
69
+ Use before outbound free-text WhatsApp sends to confirm the 24-hour CSW is open.
70
+ """
71
+
72
+ def __init__(
73
+ self,
74
+ config: LipaySdkConfig | str,
75
+ *,
76
+ http_client: httpx.AsyncClient | None = None,
77
+ ) -> None:
78
+ if isinstance(config, str):
79
+ config = LipaySdkConfig(gateway_url=config)
80
+ self._config = config
81
+ self._base_url = config.gateway_url.rstrip("/")
82
+ self._status_path = (
83
+ config.session_status_path
84
+ if config.session_status_path.startswith("/")
85
+ else f"/{config.session_status_path}"
86
+ )
87
+ self._http = http_client
88
+ self._owns_client = http_client is None
89
+ self._local: dict[str, tuple[SessionStatus, float]] = {}
90
+
91
+ @property
92
+ def config(self) -> LipaySdkConfig:
93
+ return self._config
94
+
95
+ def _pair_key(self, business_phone: str, customer_phone: str) -> str:
96
+ return f"{_normalize_phone(business_phone)}:{_normalize_phone(customer_phone)}"
97
+
98
+ def _read_local(self, pair_key: str) -> SessionStatus | None:
99
+ entry = self._local.get(pair_key)
100
+ if not entry:
101
+ return None
102
+ payload, expires_at = entry
103
+ if time.monotonic() >= expires_at:
104
+ self._local.pop(pair_key, None)
105
+ return None
106
+ return payload
107
+
108
+ def _write_local_active(self, pair_key: str, status: SessionStatus) -> None:
109
+ ttl = self._config.local_active_ttl_seconds
110
+ self._local[pair_key] = (status, time.monotonic() + ttl)
111
+
112
+ def _clear_local(self, pair_key: str) -> None:
113
+ self._local.pop(pair_key, None)
114
+
115
+ async def _client(self) -> httpx.AsyncClient:
116
+ if self._http is None:
117
+ self._http = httpx.AsyncClient(timeout=self._config.http_timeout_seconds)
118
+ return self._http
119
+
120
+ async def close(self) -> None:
121
+ if self._owns_client and self._http is not None:
122
+ await self._http.aclose()
123
+ self._http = None
124
+
125
+ async def fetch_from_lipay(
126
+ self,
127
+ *,
128
+ customer_phone: str,
129
+ business_phone: str,
130
+ ) -> SessionStatus:
131
+ params = urlencode(
132
+ {
133
+ "customer_phone": customer_phone.lstrip("+"),
134
+ "business_phone": business_phone.lstrip("+"),
135
+ }
136
+ )
137
+ url = f"{self._base_url}{self._status_path}?{params}"
138
+ client = await self._client()
139
+ response = await client.get(url)
140
+ response.raise_for_status()
141
+ return SessionStatus.from_api_payload(response.json())
142
+
143
+ async def get_session_status(
144
+ self,
145
+ *,
146
+ customer_phone: str,
147
+ business_phone: str,
148
+ ) -> SessionStatus:
149
+ """Return CSW status — local memory first, then Lipay gateway."""
150
+ pair_key = self._pair_key(business_phone, customer_phone)
151
+
152
+ cached = self._read_local(pair_key)
153
+ if cached is not None:
154
+ return cached
155
+
156
+ status = await self.fetch_from_lipay(
157
+ customer_phone=customer_phone,
158
+ business_phone=business_phone,
159
+ )
160
+
161
+ if status.is_communication_window_active:
162
+ self._write_local_active(pair_key, status)
163
+ else:
164
+ self._clear_local(pair_key)
165
+
166
+ return status
167
+
168
+ async def is_window_active(
169
+ self,
170
+ *,
171
+ customer_phone: str,
172
+ business_phone: str,
173
+ ) -> bool:
174
+ """
175
+ Convenience API: ``True`` when the Meta 24-hour CSW is open for this pair.
176
+
177
+ Example::
178
+
179
+ guard = LipayCswSessionGuard("https://message.lipay.store")
180
+ if await guard.is_window_active(
181
+ customer_phone="+254700000000",
182
+ business_phone="+254711111111",
183
+ ):
184
+ ...
185
+ """
186
+ status = await self.get_session_status(
187
+ customer_phone=customer_phone,
188
+ business_phone=business_phone,
189
+ )
190
+ return status.is_communication_window_active
191
+
192
+ def is_communication_window_active(self, status: SessionStatus | dict[str, Any]) -> bool:
193
+ """Evaluate a status object returned by :meth:`get_session_status`."""
194
+ if isinstance(status, SessionStatus):
195
+ return status.is_communication_window_active
196
+ return bool(status.get("is_communication_window_active"))
@@ -0,0 +1,230 @@
1
+ Metadata-Version: 2.4
2
+ Name: lipay-sdk
3
+ Version: 1.0.0
4
+ Summary: Official Python SDK for Lipay Messaging Platform
5
+ Author-email: Evans Musamia <evans@lipay.store>
6
+ Maintainer-email: Lipay Platform <dev@lipay.store>
7
+ License: MIT
8
+ Project-URL: Homepage, https://message.lipay.store
9
+ Project-URL: Documentation, https://github.com/Evans-musamia/LipayMessagingPlatform#readme
10
+ Project-URL: Repository, https://github.com/Evans-musamia/LipayMessagingPlatform
11
+ Project-URL: Issues, https://github.com/Evans-musamia/LipayMessagingPlatform/issues
12
+ Project-URL: Changelog, https://github.com/Evans-musamia/LipayMessagingPlatform/releases
13
+ Keywords: lipay,whatsapp,messaging,africa,customer-service-window,api-sdk
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Environment :: Web Environment
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Communications :: Telephony
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: httpx>=0.27.0
29
+ Requires-Dist: pydantic>=2.9.0
30
+ Provides-Extra: fastapi
31
+ Requires-Dist: fastapi>=0.115.0; extra == "fastapi"
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8.0; extra == "dev"
34
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
35
+ Requires-Dist: build>=1.0; extra == "dev"
36
+ Requires-Dist: twine>=5.0; extra == "dev"
37
+ Dynamic: license-file
38
+
39
+ # Lipay Python SDK
40
+
41
+ Official Python client for [Lipay](https://message.lipay.store) — the developer platform for WhatsApp messaging, routing, and Customer Service Window (CSW) management across Africa.
42
+
43
+ Use this library in your application (BrandFlow, Teacher, custom backends) to check whether the Meta **24-hour customer service window** is open before sending free-text WhatsApp messages.
44
+
45
+ ---
46
+
47
+ ## Installation
48
+
49
+ **From PyPI (recommended):**
50
+
51
+ ```bash
52
+ pip install lipay-sdk
53
+ pip install "lipay-sdk[fastapi]"
54
+ ```
55
+
56
+ Pin in `requirements.txt`:
57
+
58
+ ```text
59
+ lipay-sdk==1.0.0
60
+ ```
61
+
62
+ **From Git (tagged release):**
63
+
64
+ ```bash
65
+ pip install git+https://github.com/Evans-musamia/LipayMessagingPlatform.git@v1.0.0
66
+ ```
67
+
68
+ **TestPyPI (maintainers — sandbox):**
69
+
70
+ ```bash
71
+ pip install --index-url https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ lipay-sdk
72
+ ```
73
+
74
+ **Editable (local development):**
75
+
76
+ ```bash
77
+ git clone https://github.com/Evans-musamia/LipayMessagingPlatform.git
78
+ cd LipayMessagingPlatform
79
+ pip install -e ".[dev]"
80
+ ```
81
+
82
+ Publishing guide: [PYPI.md](PYPI.md)
83
+
84
+ ---
85
+
86
+ ## Quick start
87
+
88
+ ```python
89
+ import asyncio
90
+
91
+ from lipay_sdk import LipayCswSessionGuard
92
+
93
+
94
+ async def main() -> None:
95
+ guard = LipayCswSessionGuard("https://message.lipay.store")
96
+
97
+ active = await guard.is_window_active(
98
+ customer_phone="+254700000000",
99
+ business_phone="+254711111111",
100
+ )
101
+
102
+ if active:
103
+ print("CSW open — safe to send free-text via Lipay /v1/whatsapp/send")
104
+ else:
105
+ print("CSW closed — send an approved template first")
106
+
107
+ await guard.close()
108
+
109
+
110
+ if __name__ == "__main__":
111
+ asyncio.run(main())
112
+ ```
113
+
114
+ ### Configuration object (Pydantic)
115
+
116
+ For explicit control over timeouts, cache TTL, and API paths:
117
+
118
+ ```python
119
+ from lipay_sdk.csw_session_guard import LipayCswSessionGuard, LipaySdkConfig
120
+
121
+ config = LipaySdkConfig(
122
+ gateway_url="https://message.lipay.store",
123
+ local_active_ttl_seconds=180,
124
+ http_timeout_seconds=10.0,
125
+ )
126
+ guard = LipayCswSessionGuard(config)
127
+ ```
128
+
129
+ ### Full status payload
130
+
131
+ ```python
132
+ status = await guard.get_session_status(
133
+ customer_phone="254700000000",
134
+ business_phone="254711111111",
135
+ )
136
+ print(status.is_communication_window_active, status.window_expires_at)
137
+ ```
138
+
139
+ ---
140
+
141
+ ## Caching behavior
142
+
143
+ The SDK implements an **in-process memory guard** in front of Lipay's Redis-backed `GET /api/v1/switchboard/session-status` endpoint.
144
+
145
+ | Event | Behavior |
146
+ |--------|----------|
147
+ | Lipay returns `active=true` | Response cached in your app process RAM for **180 seconds** (configurable via `LipaySdkConfig.local_active_ttl_seconds`) |
148
+ | Repeat lookup within TTL | Served from local memory — **no HTTP call** to Lipay |
149
+ | Lipay returns `active=false` | **Never cached** — every lookup hits Lipay |
150
+ | Local TTL expires | Cache entry dropped; next lookup syncs from Lipay |
151
+
152
+ This mirrors how Twilio-style SDKs reduce read amplification: your container avoids redundant session-status traffic while the gateway remains the source of truth on Redis.
153
+
154
+ ---
155
+
156
+ ## FastAPI integration
157
+
158
+ Install the optional extra:
159
+
160
+ ```bash
161
+ pip install "lipay-sdk[fastapi] @ git+https://github.com/Evans-musamia/LipayMessagingPlatform.git@v1.0.0"
162
+ ```
163
+
164
+ Wire the guard on application startup:
165
+
166
+ ```python
167
+ from contextlib import asynccontextmanager
168
+
169
+ import httpx
170
+ from fastapi import Depends, FastAPI, Request
171
+
172
+ from lipay_sdk import LipayCswSessionGuard
173
+ from lipay_sdk.csw_session_guard import LipaySdkConfig
174
+
175
+
176
+ @asynccontextmanager
177
+ async def lifespan(app: FastAPI):
178
+ http = httpx.AsyncClient(timeout=10.0)
179
+ config = LipaySdkConfig(gateway_url="https://message.lipay.store")
180
+ app.state.lipay_guard = LipayCswSessionGuard(config, http_client=http)
181
+ yield
182
+ await app.state.lipay_guard.close()
183
+
184
+
185
+ app = FastAPI(lifespan=lifespan)
186
+
187
+
188
+ def get_guard(request: Request) -> LipayCswSessionGuard:
189
+ return request.app.state.lipay_guard
190
+
191
+
192
+ @app.get("/check-window")
193
+ async def check_window(
194
+ customer_phone: str,
195
+ business_phone: str,
196
+ guard: LipayCswSessionGuard = Depends(get_guard),
197
+ ):
198
+ return await guard.get_session_status(
199
+ customer_phone=customer_phone,
200
+ business_phone=business_phone,
201
+ )
202
+ ```
203
+
204
+ ---
205
+
206
+ ## API reference
207
+
208
+ ### `LipayCswSessionGuard`
209
+
210
+ | Method | Description |
211
+ |--------|-------------|
212
+ | `is_window_active(customer_phone, business_phone)` | `bool` — CSW open for pair |
213
+ | `get_session_status(...)` | `SessionStatus` — full gateway payload |
214
+ | `fetch_from_lipay(...)` | Always calls Lipay (bypasses local cache) |
215
+ | `close()` | Close owned `httpx` client |
216
+
217
+ ### `LipaySdkConfig` (Pydantic)
218
+
219
+ | Field | Default | Description |
220
+ |-------|---------|-------------|
221
+ | `gateway_url` | required | Lipay gateway base URL |
222
+ | `local_active_ttl_seconds` | `180` | Local RAM cache TTL |
223
+ | `http_timeout_seconds` | `5.0` | HTTP client timeout |
224
+ | `session_status_path` | `/api/v1/switchboard/session-status` | Status endpoint path |
225
+
226
+ ---
227
+
228
+ ## License
229
+
230
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ lipay_sdk/__init__.py
5
+ lipay_sdk/csw_session_guard.py
6
+ lipay_sdk.egg-info/PKG-INFO
7
+ lipay_sdk.egg-info/SOURCES.txt
8
+ lipay_sdk.egg-info/dependency_links.txt
9
+ lipay_sdk.egg-info/requires.txt
10
+ lipay_sdk.egg-info/top_level.txt
11
+ tests/test_csw_session_guard.py
@@ -0,0 +1,11 @@
1
+ httpx>=0.27.0
2
+ pydantic>=2.9.0
3
+
4
+ [dev]
5
+ pytest>=8.0
6
+ pytest-asyncio>=0.24
7
+ build>=1.0
8
+ twine>=5.0
9
+
10
+ [fastapi]
11
+ fastapi>=0.115.0
@@ -0,0 +1 @@
1
+ lipay_sdk
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "lipay-sdk"
7
+ version = "1.0.0"
8
+ description = "Official Python SDK for Lipay Messaging Platform"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.10"
12
+ authors = [{ name = "Evans Musamia", email = "evans@lipay.store" }]
13
+ maintainers = [{ name = "Lipay Platform", email = "dev@lipay.store" }]
14
+ keywords = ["lipay", "whatsapp", "messaging", "africa", "customer-service-window", "api-sdk"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Environment :: Web Environment",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Topic :: Communications :: Telephony",
25
+ "Topic :: Software Development :: Libraries :: Python Modules",
26
+ "Typing :: Typed",
27
+ ]
28
+ dependencies = [
29
+ "httpx>=0.27.0",
30
+ "pydantic>=2.9.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ fastapi = ["fastapi>=0.115.0"]
35
+ dev = ["pytest>=8.0", "pytest-asyncio>=0.24", "build>=1.0", "twine>=5.0"]
36
+
37
+ [project.urls]
38
+ Homepage = "https://message.lipay.store"
39
+ Documentation = "https://github.com/Evans-musamia/LipayMessagingPlatform#readme"
40
+ Repository = "https://github.com/Evans-musamia/LipayMessagingPlatform"
41
+ Issues = "https://github.com/Evans-musamia/LipayMessagingPlatform/issues"
42
+ Changelog = "https://github.com/Evans-musamia/LipayMessagingPlatform/releases"
43
+
44
+ [tool.setuptools.packages.find]
45
+ include = ["lipay_sdk*"]
46
+
47
+ [tool.pytest.ini_options]
48
+ asyncio_mode = "auto"
49
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,105 @@
1
+ """Unit tests for lipay_sdk.csw_session_guard."""
2
+
3
+ import time
4
+ from unittest.mock import AsyncMock, patch
5
+
6
+ import pytest
7
+
8
+ from lipay_sdk import LipayCswSessionGuard
9
+ from lipay_sdk.csw_session_guard import LipaySdkConfig, SessionStatus
10
+
11
+
12
+ ACTIVE = SessionStatus(
13
+ customer_phone_number="+254700000000",
14
+ business_phone_number="+254711111111",
15
+ is_communication_window_active=True,
16
+ window_expires_at="2026-06-01T15:30:22Z",
17
+ )
18
+
19
+ INACTIVE = SessionStatus(
20
+ customer_phone_number="+254700000000",
21
+ business_phone_number="+254711111111",
22
+ is_communication_window_active=False,
23
+ window_expires_at=None,
24
+ )
25
+
26
+
27
+ def test_config_from_string_gateway_url():
28
+ guard = LipayCswSessionGuard("https://message.lipay.store")
29
+ assert guard.config.gateway_url == "https://message.lipay.store"
30
+
31
+
32
+ def test_config_pydantic_overrides():
33
+ config = LipaySdkConfig(
34
+ gateway_url="https://message.lipay.store",
35
+ local_active_ttl_seconds=60,
36
+ session_status_path="/api/v1/switchboard/session-status",
37
+ )
38
+ guard = LipayCswSessionGuard(config)
39
+ assert guard.config.local_active_ttl_seconds == 60
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_is_window_active_true():
44
+ guard = LipayCswSessionGuard("https://message.lipay.store")
45
+ with patch.object(
46
+ guard, "fetch_from_lipay", new_callable=AsyncMock, return_value=ACTIVE
47
+ ):
48
+ assert await guard.is_window_active(
49
+ customer_phone="254700000000",
50
+ business_phone="254711111111",
51
+ )
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_local_cache_skips_second_fetch():
56
+ guard = LipayCswSessionGuard("https://message.lipay.store")
57
+ with patch.object(
58
+ guard, "fetch_from_lipay", new_callable=AsyncMock, return_value=ACTIVE
59
+ ) as mock_fetch:
60
+ await guard.get_session_status(
61
+ customer_phone="254700000000",
62
+ business_phone="254711111111",
63
+ )
64
+ await guard.get_session_status(
65
+ customer_phone="254700000000",
66
+ business_phone="254711111111",
67
+ )
68
+ mock_fetch.assert_awaited_once()
69
+
70
+
71
+ @pytest.mark.asyncio
72
+ async def test_inactive_never_cached():
73
+ guard = LipayCswSessionGuard("https://message.lipay.store")
74
+ with patch.object(
75
+ guard, "fetch_from_lipay", new_callable=AsyncMock, return_value=INACTIVE
76
+ ) as mock_fetch:
77
+ await guard.get_session_status(
78
+ customer_phone="254700000000",
79
+ business_phone="254711111111",
80
+ )
81
+ await guard.get_session_status(
82
+ customer_phone="254700000000",
83
+ business_phone="254711111111",
84
+ )
85
+ assert mock_fetch.await_count == 2
86
+
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_expired_local_cache_refetches():
90
+ config = LipaySdkConfig(
91
+ gateway_url="https://message.lipay.store",
92
+ local_active_ttl_seconds=1,
93
+ )
94
+ guard = LipayCswSessionGuard(config)
95
+ pair_key = guard._pair_key("254711111111", "254700000000")
96
+ guard._local[pair_key] = (ACTIVE, time.monotonic() - 1)
97
+
98
+ with patch.object(
99
+ guard, "fetch_from_lipay", new_callable=AsyncMock, return_value=ACTIVE
100
+ ) as mock_fetch:
101
+ await guard.get_session_status(
102
+ customer_phone="254700000000",
103
+ business_phone="254711111111",
104
+ )
105
+ mock_fetch.assert_awaited_once()