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.
- caspian_sdk-0.1.0/.gitignore +14 -0
- caspian_sdk-0.1.0/LICENSE +21 -0
- caspian_sdk-0.1.0/PKG-INFO +78 -0
- caspian_sdk-0.1.0/README.md +56 -0
- caspian_sdk-0.1.0/pyproject.toml +34 -0
- caspian_sdk-0.1.0/src/caspian_sdk/__init__.py +3 -0
- caspian_sdk-0.1.0/src/caspian_sdk/client.py +536 -0
- caspian_sdk-0.1.0/tests/test_sdk.py +182 -0
|
@@ -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,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
|