caspian-sdk 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ .env
2
+ .venv/
3
+ __pycache__/
4
+ *.py[cod]
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ htmlcov/
9
+ *.db
10
+ dist/
11
+ build/
12
+ *.egg-info/
13
+
14
+ ses-production-access-reply.txt
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Caspian
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: caspian-sdk
3
+ Version: 0.1.0
4
+ Summary: One identity for your AI agent across every channel — email, Slack, Discord, WhatsApp, SMS, X, Telegram, iMessage — behind a single on_message handler.
5
+ Project-URL: Homepage, https://trycaspianai.com
6
+ Project-URL: Documentation, https://api.trycaspianai.com/SKILL.md
7
+ Author-email: Caspian <ayushguptadev1@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agents,ai,chatbot,communication,discord,sdk,slack,sms,whatsapp
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Communications
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx<1,>=0.28
20
+ Requires-Dist: pydantic<3,>=2.11
21
+ Description-Content-Type: text/markdown
22
+
23
+ # caspian-sdk
24
+
25
+ **Give your AI agent one identity that reaches any human, on whatever app they already use** — email, Slack, Discord, WhatsApp, SMS, X, Telegram, iMessage — all behind a single `on_message` handler.
26
+
27
+ You write the handler once. Caspian handles the provider quirks, threading, delivery, and dedup for every channel.
28
+
29
+ ```bash
30
+ pip install caspian-sdk
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ from caspian_sdk import CommClient
37
+
38
+ client = CommClient(api_key="YOUR_KEY")
39
+
40
+ # Connect any channel — email needs nothing; others take a token or one-click OAuth.
41
+ inbox = client.connect_email()
42
+ print("Agent address:", inbox["address"])
43
+
44
+ @client.on_message
45
+ def handle(message):
46
+ # The same handler answers every channel you connect.
47
+ message.reply(f"Thanks! You said: {message.text}")
48
+
49
+ client.listen() # one loop, every channel
50
+ ```
51
+
52
+ ## Channels
53
+
54
+ | | Connect |
55
+ |---|---|
56
+ | **Email** | `connect_email()` — default domain or your own |
57
+ | **Slack** | `install_slack()` (one-click) or `connect_slack(...)` |
58
+ | **Discord** | `install_discord()` (one-click) or `connect_discord(...)` |
59
+ | **X / Twitter** | `install_x()` (one-click) or `connect_x(...)` |
60
+ | **WhatsApp** | `connect_whatsapp(...)` (Twilio or Meta) |
61
+ | **SMS / phone** | `connect_phone(...)` — bring your own Twilio/Telnyx number |
62
+ | **Telegram** | `connect_telegram(bot_token=...)` |
63
+ | **iMessage** | `connect_imessage()` |
64
+
65
+ ## How it works
66
+
67
+ - **One handler, every channel.** Adding a channel is another `connect_*()` call — never new handler code.
68
+ - **`message.reply()`** answers in the right thread on the right channel automatically.
69
+ - **`message.typing()`** shows a "typing…" indicator while your agent thinks (where the platform supports it).
70
+ - **`client.listen()`** is resilient — a handler error or a dropped poll won't stop the loop.
71
+
72
+ ## Docs
73
+
74
+ Point your coding agent at the setup guide and it does the whole integration for you. Full docs and your API key: **[trycaspianai.com](https://trycaspianai.com)**.
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,56 @@
1
+ # caspian-sdk
2
+
3
+ **Give your AI agent one identity that reaches any human, on whatever app they already use** — email, Slack, Discord, WhatsApp, SMS, X, Telegram, iMessage — all behind a single `on_message` handler.
4
+
5
+ You write the handler once. Caspian handles the provider quirks, threading, delivery, and dedup for every channel.
6
+
7
+ ```bash
8
+ pip install caspian-sdk
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from caspian_sdk import CommClient
15
+
16
+ client = CommClient(api_key="YOUR_KEY")
17
+
18
+ # Connect any channel — email needs nothing; others take a token or one-click OAuth.
19
+ inbox = client.connect_email()
20
+ print("Agent address:", inbox["address"])
21
+
22
+ @client.on_message
23
+ def handle(message):
24
+ # The same handler answers every channel you connect.
25
+ message.reply(f"Thanks! You said: {message.text}")
26
+
27
+ client.listen() # one loop, every channel
28
+ ```
29
+
30
+ ## Channels
31
+
32
+ | | Connect |
33
+ |---|---|
34
+ | **Email** | `connect_email()` — default domain or your own |
35
+ | **Slack** | `install_slack()` (one-click) or `connect_slack(...)` |
36
+ | **Discord** | `install_discord()` (one-click) or `connect_discord(...)` |
37
+ | **X / Twitter** | `install_x()` (one-click) or `connect_x(...)` |
38
+ | **WhatsApp** | `connect_whatsapp(...)` (Twilio or Meta) |
39
+ | **SMS / phone** | `connect_phone(...)` — bring your own Twilio/Telnyx number |
40
+ | **Telegram** | `connect_telegram(bot_token=...)` |
41
+ | **iMessage** | `connect_imessage()` |
42
+
43
+ ## How it works
44
+
45
+ - **One handler, every channel.** Adding a channel is another `connect_*()` call — never new handler code.
46
+ - **`message.reply()`** answers in the right thread on the right channel automatically.
47
+ - **`message.typing()`** shows a "typing…" indicator while your agent thinks (where the platform supports it).
48
+ - **`client.listen()`** is resilient — a handler error or a dropped poll won't stop the loop.
49
+
50
+ ## Docs
51
+
52
+ Point your coding agent at the setup guide and it does the whole integration for you. Full docs and your API key: **[trycaspianai.com](https://trycaspianai.com)**.
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "caspian-sdk"
3
+ version = "0.1.0"
4
+ description = "One identity for your AI agent across every channel — email, Slack, Discord, WhatsApp, SMS, X, Telegram, iMessage — behind a single on_message handler."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.12"
8
+ authors = [{ name = "Caspian", email = "ayushguptadev1@gmail.com" }]
9
+ keywords = ["ai", "agents", "communication", "sdk", "slack", "discord", "whatsapp", "sms", "chatbot"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Communications",
17
+ "Topic :: Software Development :: Libraries :: Python Modules",
18
+ ]
19
+ dependencies = [
20
+ "httpx>=0.28,<1",
21
+ "pydantic>=2.11,<3",
22
+ ]
23
+
24
+ [project.urls]
25
+ Homepage = "https://trycaspianai.com"
26
+ Documentation = "https://api.trycaspianai.com/SKILL.md"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ # PyPI name is "caspian-sdk"; the import package is "caspian_sdk".
34
+ packages = ["src/caspian_sdk"]
@@ -0,0 +1,3 @@
1
+ from .client import CommClient, CommError, Message
2
+
3
+ __all__ = ["CommClient", "CommError", "Message"]
@@ -0,0 +1,536 @@
1
+ """Python SDK for the communication gateway.
2
+
3
+ Usage:
4
+
5
+ client = CommClient(api_key="...", base_url="https://gateway.example.com")
6
+ customer = client.create_customer("Acme")
7
+ agent = client.create_agent("Support Agent")
8
+ connection = client.connect_email(customer["id"], agent["id"])
9
+ print(connection["address"])
10
+
11
+ @client.on_message
12
+ def handle(message):
13
+ message.reply(f"You said: {message.text}")
14
+
15
+ client.listen()
16
+ """
17
+
18
+ import logging
19
+ import os
20
+ import time
21
+ from collections.abc import Callable
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+
25
+ import httpx
26
+
27
+ logger = logging.getLogger("caspian_sdk")
28
+
29
+
30
+ def _dotenv() -> dict[str, str]:
31
+ values: dict[str, str] = {}
32
+ path = Path.cwd() / ".env"
33
+ if path.exists():
34
+ for line in path.read_text().splitlines():
35
+ line = line.strip()
36
+ if line and not line.startswith("#") and "=" in line:
37
+ key, _, value = line.partition("=")
38
+ values[key.strip()] = value.strip().strip('"').strip("'")
39
+ return values
40
+
41
+
42
+ def _config(explicit: str | None, env_key: str, default: str | None = None) -> str | None:
43
+ return explicit or os.environ.get(env_key) or _dotenv().get(env_key) or default
44
+
45
+
46
+ class CommError(Exception):
47
+ def __init__(self, status_code: int, detail: str) -> None:
48
+ super().__init__(f"{status_code}: {detail}")
49
+ self.status_code = status_code
50
+ self.detail = detail
51
+
52
+
53
+ @dataclass
54
+ class Message:
55
+ """An inbound message delivered to an on_message handler."""
56
+
57
+ id: str
58
+ conversation_id: str
59
+ connection_id: str
60
+ customer_id: str
61
+ agent_id: str
62
+ channel: str
63
+ sender: dict | None
64
+ subject: str | None
65
+ text: str | None
66
+ html: str | None
67
+ _client: "CommClient" = field(repr=False)
68
+
69
+ def reply(self, text: str | None = None, html: str | None = None) -> dict:
70
+ return self._client.reply(self.id, text=text, html=html)
71
+
72
+ def typing(self) -> None:
73
+ """Show a 'thinking…' typing indicator on the channel (Discord/Telegram;
74
+ no-op where the platform has none). Fired automatically before your
75
+ handler runs; call again during long work to keep it alive."""
76
+ self._client.typing(self.id)
77
+
78
+
79
+ class CommClient:
80
+ def __init__(
81
+ self,
82
+ api_key: str | None = None,
83
+ base_url: str | None = None,
84
+ http: httpx.Client | None = None,
85
+ timeout: float = 30.0,
86
+ ) -> None:
87
+ api_key = _config(api_key, "COMM_API_KEY")
88
+ if not api_key:
89
+ raise CommError(401, "No API key: pass api_key or set COMM_API_KEY (env or ./.env)")
90
+ base_url = _config(base_url, "COMM_BASE_URL", "http://127.0.0.1:8000")
91
+ self._api_key = api_key
92
+ self._http = http or httpx.Client(base_url=base_url, timeout=timeout)
93
+ self._handlers: list[Callable[[Message], None]] = []
94
+
95
+ def close(self) -> None:
96
+ self._http.close()
97
+
98
+ def _request(
99
+ self, method: str, path: str, *, json: dict | None = None, params: dict | None = None
100
+ ):
101
+ response = self._http.request(
102
+ method,
103
+ path,
104
+ json=json,
105
+ params=params,
106
+ headers={"Authorization": f"Bearer {self._api_key}"},
107
+ )
108
+ if response.status_code >= 400:
109
+ try:
110
+ detail = response.json().get("detail", response.text)
111
+ except ValueError:
112
+ detail = response.text
113
+ raise CommError(response.status_code, str(detail))
114
+ if response.status_code == 204:
115
+ return None
116
+ return response.json()
117
+
118
+ # Resources
119
+
120
+ def create_customer(self, name: str) -> dict:
121
+ return self._request("POST", "/v1/customers", json={"name": name})
122
+
123
+ def create_agent(self, name: str) -> dict:
124
+ return self._request("POST", "/v1/agents", json={"name": name})
125
+
126
+ def _connect(
127
+ self,
128
+ channel: str,
129
+ customer_id: str | None = None,
130
+ agent_id: str | None = None,
131
+ display_name: str | None = None,
132
+ capabilities: list[str] | None = None,
133
+ wait: bool = True,
134
+ timeout: float = 60.0,
135
+ poll_interval: float = 0.5,
136
+ **channel_fields,
137
+ ) -> dict:
138
+ connection = self._request(
139
+ "POST",
140
+ f"/v1/connections/{channel}",
141
+ json={
142
+ "customer_id": customer_id,
143
+ "agent_id": agent_id,
144
+ "display_name": display_name,
145
+ "capabilities": capabilities,
146
+ **channel_fields,
147
+ },
148
+ )
149
+ if not wait:
150
+ return connection
151
+ deadline = time.monotonic() + timeout
152
+ while connection["status"] == "provisioning":
153
+ if time.monotonic() >= deadline:
154
+ raise CommError(408, f"connection {connection['id']} still provisioning")
155
+ time.sleep(poll_interval)
156
+ connection = self.get_connection(connection["id"])
157
+ if connection["status"] == "failed":
158
+ raise CommError(502, f"provisioning failed: {connection.get('error')}")
159
+ return connection
160
+
161
+ def connect_email(
162
+ self,
163
+ customer_id: str | None = None,
164
+ agent_id: str | None = None,
165
+ domain: str | None = None,
166
+ username: str | None = None,
167
+ **kwargs,
168
+ ) -> dict:
169
+ """Connect an email inbox. Pass domain= for a verified custom domain and
170
+ username= to pick the exact local part (custom domains only)."""
171
+ return self._connect(
172
+ "email", customer_id, agent_id, domain=domain, username=username, **kwargs
173
+ )
174
+
175
+ def connect_telegram(
176
+ self,
177
+ bot_token: str,
178
+ customer_id: str | None = None,
179
+ agent_id: str | None = None,
180
+ **kwargs,
181
+ ) -> dict:
182
+ """Connect a Telegram bot. Get a token from @BotFather; we do the rest."""
183
+ return self._connect("telegram", customer_id, agent_id, bot_token=bot_token, **kwargs)
184
+
185
+ def add_domain(self, domain: str) -> dict:
186
+ """Register a custom subdomain (e.g. agents.example.com). Returns the
187
+ DNS records to add at the registrar; poll get_domain() until active."""
188
+ return self._request("POST", "/v1/domains", json={"domain": domain})
189
+
190
+ def list_domains(self) -> list[dict]:
191
+ return self._request("GET", "/v1/domains")
192
+
193
+ def get_domain(self, domain_id: str) -> dict:
194
+ return self._request("GET", f"/v1/domains/{domain_id}")
195
+
196
+ def connect_phone(
197
+ self, customer_id: str | None = None, agent_id: str | None = None,
198
+ provider=None, **kwargs,
199
+ ) -> dict:
200
+ """Connect an SMS/voice phone line. `provider` picks the backend when more
201
+ than one is configured (telnyx / twilio / modem / agentphone); omit for
202
+ the deployment default."""
203
+ return self._connect("phone", customer_id, agent_id, provider=provider, **kwargs)
204
+
205
+ def connect_whatsapp(self, customer_id=None, agent_id=None, provider=None, **kwargs) -> dict:
206
+ """Connect a WhatsApp number. When more than one WhatsApp backend is
207
+ configured, `provider` picks it: "twilio-whatsapp" (Twilio sandbox/number
208
+ pool) or "meta-whatsapp" (direct Meta Cloud API). Omit to use the
209
+ deployment's default WhatsApp provider."""
210
+ return self._connect("whatsapp", customer_id, agent_id, provider=provider, **kwargs)
211
+
212
+ def start_whatsapp_onboarding(
213
+ self, customer_id=None, agent_id=None, display_name=None, capabilities=None,
214
+ ) -> dict:
215
+ """Begin Meta WhatsApp Embedded Signup for one of your customers.
216
+
217
+ Returns ``{"session", "launcher_url", "expires_in"}``. Hand ``launcher_url``
218
+ to whoever owns the WhatsApp Business account (open it, or embed it in your
219
+ own UI): they click through Meta's popup once and their number is provisioned
220
+ onto this agent - no tokens to copy, no Meta console steps on your side. The
221
+ API key never reaches the browser (the session token stands in for it).
222
+
223
+ Omit customer_id/agent_id to onboard onto this project's default scope, or
224
+ pass both to target a specific customer+agent. Poll list_connections() /
225
+ get_connection() (or watch for a connection.active event) until it's active.
226
+ """
227
+ body: dict = {}
228
+ if customer_id is not None:
229
+ body["customer_id"] = customer_id
230
+ if agent_id is not None:
231
+ body["agent_id"] = agent_id
232
+ if display_name is not None:
233
+ body["display_name"] = display_name
234
+ if capabilities is not None:
235
+ body["capabilities"] = capabilities
236
+ return self._request(
237
+ "POST", "/v1/connections/whatsapp/onboarding-session", json=body
238
+ )
239
+
240
+ def connect_imessage(self, customer_id=None, agent_id=None, **kwargs) -> dict:
241
+ """Connect an iMessage line (provider: agentphone-imessage)."""
242
+ return self._connect("imessage", customer_id, agent_id, **kwargs)
243
+
244
+ def connect_rcs(self, customer_id=None, agent_id=None, **kwargs) -> dict:
245
+ """Connect an RCS Business Messaging sender (provider: twilio-rcs)."""
246
+ return self._connect("rcs", customer_id, agent_id, **kwargs)
247
+
248
+ def connect_discord(
249
+ self, bot_token: str | None = None, webhook_url: str | None = None,
250
+ username: str | None = None, avatar_url: str | None = None,
251
+ customer_id=None, agent_id=None, **kwargs,
252
+ ) -> dict:
253
+ """Connect a Discord identity. Either a bot (`bot_token` from
254
+ discord.com/developers) OR a channel `webhook_url` for a per-agent
255
+ identity with a custom `username`/`avatar_url` (no bot needed)."""
256
+ return self._connect(
257
+ "discord", customer_id, agent_id, bot_token=bot_token,
258
+ webhook_url=webhook_url, username=username, avatar_url=avatar_url, **kwargs,
259
+ )
260
+
261
+ def install_discord(self, customer_id=None, agent_id=None, display_name=None,
262
+ **kwargs) -> dict:
263
+ """One-click install of the gateway's shared Discord bot (no bot token).
264
+
265
+ Returns a connection with an ``authorize_url``. Open it (or hand it to the
266
+ developer), pick a Discord server, and the shared bot joins it; messages in
267
+ that server route to this agent. Zero setup - no bot to create.
268
+
269
+ Pass ``display_name`` to give the bot YOUR custom name in that server (e.g.
270
+ "Acme Support") - it appears under that name instead of the shared bot's
271
+ name. Use connect_discord(bot_token=...) instead if you want a fully
272
+ separate bot (your own name AND avatar, member-list included)."""
273
+ body = {"customer_id": customer_id, "agent_id": agent_id,
274
+ "display_name": display_name, **kwargs}
275
+ return self._request("POST", "/v1/connections/discord/install", json=body)
276
+
277
+ def connect_slack(
278
+ self,
279
+ slack_client_id: str | None = None,
280
+ slack_client_secret: str | None = None,
281
+ slack_signing_secret: str | None = None,
282
+ customer_id=None,
283
+ agent_id=None,
284
+ **kwargs,
285
+ ) -> dict:
286
+ """Start a Slack install. Bring your own Slack app (create one at
287
+ api.slack.com/apps and pass its client id/secret/signing secret) so the
288
+ bot carries your brand. Returns a connection with an `authorize_url`; the
289
+ workspace owner clicks it to approve, then the connection goes active."""
290
+ return self._connect(
291
+ "slack", customer_id, agent_id, wait=False,
292
+ slack_client_id=slack_client_id,
293
+ slack_client_secret=slack_client_secret,
294
+ slack_signing_secret=slack_signing_secret,
295
+ **kwargs,
296
+ )
297
+
298
+ def install_slack(self, customer_id=None, agent_id=None, display_name=None,
299
+ icon_url=None, **kwargs) -> dict:
300
+ """One-click install of the gateway's shared Slack app (no app to create).
301
+
302
+ Returns a connection with an ``authorize_url`` ("Add to Slack"). Open it
303
+ (or hand it to the developer), pick a workspace, and the shared app
304
+ installs there; messages in that workspace route to this agent. Zero setup
305
+ - no Slack app to build. Pass ``display_name`` and ``icon_url`` to post
306
+ under YOUR own name + icon (the plumbing stays invisible). Use
307
+ connect_slack(slack_client_id=...) instead to bring your own Slack app."""
308
+ body = {"customer_id": customer_id, "agent_id": agent_id,
309
+ "display_name": display_name, "icon_url": icon_url, **kwargs}
310
+ return self._request("POST", "/v1/connections/slack/install", json=body)
311
+
312
+ def update_branding(self, connection_id: str, display_name=None, icon_url=None) -> dict:
313
+ """Change the name/icon the agent posts under, after connecting - no
314
+ re-install. Slack: takes effect on the next message; Discord shared bot:
315
+ re-sets the per-server nickname. Pass either or both."""
316
+ return self._request(
317
+ "PATCH", f"/v1/connections/{connection_id}",
318
+ json={"display_name": display_name, "icon_url": icon_url},
319
+ )
320
+
321
+ def connect_x(
322
+ self, access_token: str, user_id: str, access_secret: str | None = None,
323
+ username: str | None = None, customer_id=None, agent_id=None, **kwargs,
324
+ ) -> dict:
325
+ """Connect an X (Twitter) account as a reactive DM bot.
326
+
327
+ Bring the account's OAuth tokens: `access_token` + `user_id` (the numeric
328
+ id, embedded before the dash in an OAuth 1.0a access token), and
329
+ `access_secret` for a bring-your-own account. People DM the account and
330
+ the agent replies; the gateway polls for inbound DMs (no webhook to set
331
+ up). Reactive only - it never cold-DMs. The account must be labelled
332
+ "Automated" in X settings."""
333
+ return self._connect(
334
+ "x", customer_id, agent_id, access_token=access_token, user_id=user_id,
335
+ access_secret=access_secret, username=username, **kwargs,
336
+ )
337
+
338
+ def install_x(self, customer_id=None, agent_id=None, **kwargs) -> dict:
339
+ """One-click connect of an X account as a DM bot - no tokens to paste.
340
+
341
+ Returns a connection with an ``authorize_url`` ("Sign in with X"). Open it
342
+ (or hand it to the developer), authorize on X, and that account becomes the
343
+ bot: people DM it, the agent replies. Uses the gateway's shared X app
344
+ (OAuth 1.0a 3-legged), so there's no X app to create. Use
345
+ connect_x(access_token=...) instead to bring your own account tokens."""
346
+ body = {"customer_id": customer_id, "agent_id": agent_id, **kwargs}
347
+ return self._request("POST", "/v1/connections/x/install", json=body)
348
+
349
+ def connect_instagram(self, customer_id=None, agent_id=None, **kwargs) -> dict:
350
+ """Start an Instagram DM install (OAuth). Returns an `authorize_url`."""
351
+ return self._connect("instagram", customer_id, agent_id, wait=False, **kwargs)
352
+
353
+ def connect_facebook(self, customer_id=None, agent_id=None, **kwargs) -> dict:
354
+ """Start a Facebook Messenger install (OAuth). Returns an `authorize_url`."""
355
+ return self._connect("facebook", customer_id, agent_id, wait=False, **kwargs)
356
+
357
+ def get_connection(self, connection_id: str) -> dict:
358
+ return self._request("GET", f"/v1/connections/{connection_id}")
359
+
360
+ def list_conversations(self, connection_id: str | None = None) -> list[dict]:
361
+ params = {"connection_id": connection_id} if connection_id else None
362
+ return self._request("GET", "/v1/conversations", params=params)
363
+
364
+ def list_messages(self, conversation_id: str) -> list[dict]:
365
+ return self._request("GET", f"/v1/conversations/{conversation_id}/messages")
366
+
367
+ def reply(self, message_id: str, text: str | None = None, html: str | None = None) -> dict:
368
+ return self._request(
369
+ "POST", f"/v1/messages/{message_id}/reply", json={"text": text, "html": html}
370
+ )
371
+
372
+ def typing(self, message_id: str) -> dict:
373
+ """Show a 'thinking…' indicator on the channel a message arrived on
374
+ (Discord/Telegram; no-op where unsupported). Best-effort."""
375
+ return self._request("POST", f"/v1/messages/{message_id}/typing")
376
+
377
+ def set_webhook(self, url: str, secret: str | None = None) -> dict:
378
+ """Receive events by push instead of (or alongside) polling."""
379
+ return self._request("PUT", "/v1/webhook", json={"url": url, "secret": secret})
380
+
381
+ def get_webhook(self) -> dict:
382
+ return self._request("GET", "/v1/webhook")
383
+
384
+ def channels(self) -> list[dict]:
385
+ """Configured transports and their capabilities."""
386
+ return self._request("GET", "/v1/channels")
387
+
388
+ def send_message(
389
+ self, conversation_id: str, text: str | None = None, html: str | None = None
390
+ ) -> dict:
391
+ """Proactively send into an existing conversation (needs Capability.SEND)."""
392
+ return self._request(
393
+ "POST",
394
+ f"/v1/conversations/{conversation_id}/messages",
395
+ json={"text": text, "html": html},
396
+ )
397
+
398
+ def initiate(self, connection_id: str, recipient: str, text: str) -> dict:
399
+ """Cold-start a conversation (needs Capability.INITIATE — user account)."""
400
+ return self._request(
401
+ "POST",
402
+ f"/v1/connections/{connection_id}/initiate",
403
+ json={"recipient": recipient, "text": text},
404
+ )
405
+
406
+ def backfill(self, conversation_id: str, limit: int = 50) -> dict:
407
+ """Pull history from before the connection (needs Capability.BACKFILL)."""
408
+ return self._request(
409
+ "POST", f"/v1/conversations/{conversation_id}/backfill", json={"limit": limit}
410
+ )
411
+
412
+ def test_email(
413
+ self,
414
+ text: str = "Hello from the comm test sender.",
415
+ subject: str = "Test email",
416
+ connection_id: str | None = None,
417
+ ) -> dict:
418
+ body: dict = {"text": text, "subject": subject}
419
+ if connection_id:
420
+ body["connection_id"] = connection_id
421
+ return self._request("POST", "/v1/test-emails", json=body)
422
+
423
+ def events(self, after_seq: int = 0, limit: int = 100, type: str | None = None) -> list[dict]:
424
+ params: dict = {"after_seq": after_seq, "limit": limit}
425
+ if type:
426
+ params["type"] = type
427
+ return self._request("GET", "/v1/events", params=params)
428
+
429
+ # Event handling
430
+
431
+ def on_message(self, handler: Callable[[Message], None]) -> Callable[[Message], None]:
432
+ self._handlers.append(handler)
433
+ return handler
434
+
435
+ def _dispatch_event(self, event: dict) -> None:
436
+ """Run handlers for one event. A handler that raises is logged and
437
+ swallowed so one bad message can never stop the listener."""
438
+ if event.get("type") != "message.received":
439
+ return
440
+ message = self._build_message(event["data"])
441
+ if self._handlers:
442
+ # Show a 'thinking…' indicator up front so the human sees the agent is
443
+ # working while the handler runs. Best-effort; never blocks dispatch.
444
+ try:
445
+ message.typing()
446
+ except Exception:
447
+ pass
448
+ for handler in self._handlers:
449
+ try:
450
+ handler(message)
451
+ except Exception:
452
+ logger.exception(
453
+ "on_message handler failed for message %s; continuing", message.id
454
+ )
455
+
456
+ def dispatch_pending(self, after_seq: int = 0) -> int:
457
+ """Process all currently available events once. Returns the last seen seq.
458
+
459
+ Handler exceptions are caught per message, so this always drains the
460
+ queue and advances the cursor even if some handlers fail.
461
+ """
462
+ last_seq = after_seq
463
+ while True:
464
+ batch = self.events(after_seq=last_seq)
465
+ if not batch:
466
+ return last_seq
467
+ for event in batch:
468
+ last_seq = event["seq"]
469
+ self._dispatch_event(event)
470
+
471
+ def listen(
472
+ self,
473
+ from_seq: int | None = None,
474
+ poll_interval: float = 1.0,
475
+ max_backoff: float = 30.0,
476
+ ) -> None:
477
+ """Poll the event stream forever, dispatching inbound messages to handlers.
478
+
479
+ Resilient by design: a handler that raises is logged and skipped, and a
480
+ failed poll (network blip, gateway restart) is retried with exponential
481
+ backoff. This loop is meant to run for the lifetime of the agent and
482
+ never exits on error — only KeyboardInterrupt / SIGINT stops it.
483
+ """
484
+ seq = self._latest_seq() if from_seq is None else from_seq
485
+ backoff = poll_interval
486
+ while True:
487
+ try:
488
+ batch = self.events(after_seq=seq)
489
+ except KeyboardInterrupt:
490
+ raise
491
+ except Exception:
492
+ logger.warning(
493
+ "gateway poll failed; retrying in %.1fs", backoff, exc_info=True
494
+ )
495
+ time.sleep(backoff)
496
+ backoff = min(backoff * 2, max_backoff)
497
+ continue
498
+ backoff = poll_interval
499
+ if not batch:
500
+ time.sleep(poll_interval)
501
+ continue
502
+ for event in batch:
503
+ self._dispatch_event(event)
504
+ seq = event["seq"] # advance only after the dispatch attempt
505
+
506
+ def _latest_seq(self) -> int:
507
+ """Newest seq at startup, retrying transient failures instead of crashing."""
508
+ while True:
509
+ try:
510
+ seq = 0
511
+ while True:
512
+ batch = self.events(after_seq=seq, limit=500)
513
+ if not batch:
514
+ return seq
515
+ seq = batch[-1]["seq"]
516
+ except KeyboardInterrupt:
517
+ raise
518
+ except Exception:
519
+ logger.warning("could not read starting cursor; retrying in 2s", exc_info=True)
520
+ time.sleep(2.0)
521
+
522
+ def _build_message(self, data: dict) -> Message:
523
+ message = data["message"]
524
+ return Message(
525
+ id=message["id"],
526
+ conversation_id=message["conversation_id"],
527
+ connection_id=message["connection_id"],
528
+ customer_id=data.get("customer_id", ""),
529
+ agent_id=data.get("agent_id", ""),
530
+ channel=message.get("channel", "email"),
531
+ sender=message.get("sender"),
532
+ subject=message.get("subject"),
533
+ text=message.get("text"),
534
+ html=message.get("html"),
535
+ _client=self,
536
+ )
@@ -0,0 +1,182 @@
1
+ import threading
2
+
3
+ import pytest
4
+ from comm_gateway.config import Settings
5
+ from comm_gateway.jobs import run_pending_jobs
6
+ from comm_gateway.main import create_app
7
+ from comm_gateway.providers.fake import FakeEmailProvider
8
+ from caspian_sdk import CommClient, CommError
9
+ from fastapi.testclient import TestClient
10
+
11
+ API_KEY = "caspian_sdk_test_key"
12
+
13
+
14
+ @pytest.fixture()
15
+ def app():
16
+ settings = Settings(
17
+ database_url="sqlite://",
18
+ provider="fake",
19
+ bootstrap_api_key=API_KEY,
20
+ inline_worker=False,
21
+ )
22
+ return create_app(settings, providers={"fake": FakeEmailProvider()})
23
+
24
+
25
+ @pytest.fixture()
26
+ def sdk(app):
27
+ client = CommClient(api_key=API_KEY, http=TestClient(app))
28
+ yield client
29
+ client.close()
30
+
31
+
32
+ def _run_jobs(app) -> None:
33
+ run_pending_jobs(app.state.session_factory, app.state.providers)
34
+
35
+
36
+ def test_sdk_auth_error(app):
37
+ bad = CommClient(api_key="wrong", http=TestClient(app))
38
+ with pytest.raises(CommError) as excinfo:
39
+ bad.create_customer("Acme")
40
+ assert excinfo.value.status_code == 401
41
+
42
+
43
+ def test_sdk_start_whatsapp_onboarding():
44
+ from comm_gateway.providers.meta_whatsapp import MetaWhatsAppProvider
45
+
46
+ settings = Settings(
47
+ database_url="sqlite://",
48
+ bootstrap_api_key=API_KEY,
49
+ inline_worker=False,
50
+ public_base_url="https://gw.test",
51
+ meta_app_id="APP123",
52
+ meta_app_secret="secret",
53
+ meta_es_config_id="CFG123",
54
+ )
55
+ app = create_app(
56
+ settings, providers={"meta-whatsapp": MetaWhatsAppProvider(app_secret="secret")}
57
+ )
58
+ client = CommClient(api_key=API_KEY, http=TestClient(app))
59
+ try:
60
+ out = client.start_whatsapp_onboarding(display_name="Support")
61
+ finally:
62
+ client.close()
63
+ assert out["launcher_url"].startswith("https://gw.test/connect/whatsapp?session=")
64
+ assert out["session"] and out["expires_in"] > 0
65
+
66
+
67
+ def test_sdk_connect_email_wait(app, sdk):
68
+ customer = sdk.create_customer("Acme")
69
+ agent = sdk.create_agent("Support Agent")
70
+
71
+ # run jobs from a background thread so connect_email(wait=True) can poll
72
+ worker = threading.Timer(0.2, _run_jobs, args=(app,))
73
+ worker.start()
74
+ try:
75
+ connection = sdk.connect_email(
76
+ customer["id"], agent["id"], display_name="Acme Support", timeout=10
77
+ )
78
+ finally:
79
+ worker.join()
80
+ assert connection["status"] == "active"
81
+ assert connection["address"].endswith("@sandbox.comm.local")
82
+
83
+
84
+ def test_sdk_on_message_and_reply(app, sdk):
85
+ customer = sdk.create_customer("Acme")
86
+ agent = sdk.create_agent("Support Agent")
87
+ connection = sdk.connect_email(customer["id"], agent["id"], wait=False)
88
+ _run_jobs(app)
89
+ connection = sdk.get_connection(connection["id"])
90
+ assert connection["status"] == "active"
91
+ assert connection["address"]
92
+
93
+ received = []
94
+
95
+ @sdk.on_message
96
+ def handle(message):
97
+ received.append(message)
98
+ message.reply(f"You said: {message.text}")
99
+
100
+ provider = app.state.providers["fake"]
101
+ inbox_id = next(iter(provider.inboxes))
102
+ webhook = TestClient(app)
103
+ webhook.post(
104
+ "/internal/providers/fake/webhooks",
105
+ json=provider.webhook_payload(inbox_id, subject="Ping", text="hello"),
106
+ )
107
+ _run_jobs(app)
108
+
109
+ last_seq = sdk.dispatch_pending()
110
+ assert len(received) == 1
111
+ message = received[0]
112
+ assert message.text == "hello"
113
+ assert message.subject == "Ping"
114
+ assert message.customer_id == customer["id"]
115
+ assert message.agent_id == agent["id"]
116
+
117
+ _run_jobs(app)
118
+ assert len(provider.replies) == 1
119
+ assert provider.replies[0]["text"] == "You said: hello"
120
+
121
+ # replaying from the cursor only sees the message.sent event; no re-dispatch
122
+ assert sdk.dispatch_pending(after_seq=last_seq) > last_seq
123
+ assert len(received) == 1
124
+
125
+
126
+ def test_handler_exception_does_not_stop_dispatch(app, sdk):
127
+ calls = []
128
+
129
+ @sdk.on_message
130
+ def bad(message):
131
+ calls.append(message.text)
132
+ raise RuntimeError("LLM exploded")
133
+
134
+ customer = sdk.create_customer("Acme")
135
+ agent = sdk.create_agent("A")
136
+ sdk.connect_email(customer["id"], agent["id"], wait=False)
137
+ _run_jobs(app)
138
+ provider = app.state.providers["fake"]
139
+ inbox_id = next(iter(provider.inboxes))
140
+ webhook = TestClient(app)
141
+ webhook.post("/internal/providers/fake/webhooks",
142
+ json=provider.webhook_payload(inbox_id, text="one"))
143
+ webhook.post("/internal/providers/fake/webhooks",
144
+ json=provider.webhook_payload(inbox_id, text="two"))
145
+ _run_jobs(app)
146
+
147
+ # both messages dispatched despite the handler raising each time
148
+ sdk.dispatch_pending()
149
+ assert calls == ["one", "two"]
150
+
151
+
152
+ def test_listen_survives_poll_errors(monkeypatch):
153
+ import caspian_sdk.client as mod
154
+
155
+ client = CommClient(api_key="k", base_url="http://x")
156
+ seen = []
157
+ client.on_message(lambda m: seen.append(m.text))
158
+
159
+ calls = {"n": 0}
160
+ batches = [
161
+ [], # _latest_seq
162
+ RuntimeError("network down"), # first poll fails
163
+ [{"seq": 1, "type": "message.received",
164
+ "data": {"message": {"id": "m1", "conversation_id": "c", "connection_id": "x",
165
+ "text": "hi"}}}],
166
+ KeyboardInterrupt(), # stop the loop
167
+ ]
168
+
169
+ def fake_events(after_seq=0, limit=100, type=None):
170
+ item = batches[min(calls["n"], len(batches) - 1)]
171
+ calls["n"] += 1
172
+ if isinstance(item, BaseException):
173
+ raise item
174
+ return item
175
+
176
+ monkeypatch.setattr(client, "events", fake_events)
177
+ monkeypatch.setattr(mod.time, "sleep", lambda *_: None)
178
+ try:
179
+ client.listen(poll_interval=0)
180
+ except KeyboardInterrupt:
181
+ pass
182
+ assert seen == ["hi"] # delivered even though a poll raised before it