inbots 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- inbots/__init__.py +13 -0
- inbots/_receiver.py +57 -0
- inbots/_verify.py +43 -0
- inbots/client.py +263 -0
- inbots/errors.py +15 -0
- inbots/models.py +83 -0
- inbots-0.1.0.dist-info/METADATA +291 -0
- inbots-0.1.0.dist-info/RECORD +11 -0
- inbots-0.1.0.dist-info/WHEEL +5 -0
- inbots-0.1.0.dist-info/licenses/LICENSE +21 -0
- inbots-0.1.0.dist-info/top_level.txt +1 -0
inbots/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .client import Client, Result
|
|
2
|
+
from .errors import ApiError, ConfigError, InbotsError
|
|
3
|
+
from .models import Event, MessageCreated
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ApiError",
|
|
7
|
+
"Client",
|
|
8
|
+
"ConfigError",
|
|
9
|
+
"Event",
|
|
10
|
+
"InbotsError",
|
|
11
|
+
"MessageCreated",
|
|
12
|
+
"Result",
|
|
13
|
+
]
|
inbots/_receiver.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from functools import partial
|
|
3
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
4
|
+
from threading import Thread
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .client import Client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
12
|
+
def __init__(self, client: "Client", *args: Any, **kwargs: Any):
|
|
13
|
+
# Set before super().__init__, which reads and answers the request.
|
|
14
|
+
self._client = client
|
|
15
|
+
super().__init__(*args, **kwargs)
|
|
16
|
+
|
|
17
|
+
def do_POST(self) -> None:
|
|
18
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
19
|
+
result = self._client.handle(self.rfile.read(length), self.headers)
|
|
20
|
+
self._respond(result.status, result.body)
|
|
21
|
+
|
|
22
|
+
def do_GET(self) -> None:
|
|
23
|
+
self._respond(200, {"ok": True})
|
|
24
|
+
|
|
25
|
+
def _respond(self, status: int, payload: dict[str, Any]) -> None:
|
|
26
|
+
body = json.dumps(payload).encode()
|
|
27
|
+
self.send_response(status)
|
|
28
|
+
self.send_header("Content-Type", "application/json")
|
|
29
|
+
self.send_header("Content-Length", str(len(body)))
|
|
30
|
+
self.end_headers()
|
|
31
|
+
self.wfile.write(body)
|
|
32
|
+
|
|
33
|
+
def log_message(self, *args: Any) -> None:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def serve(client: "Client", port: int) -> ThreadingHTTPServer:
|
|
38
|
+
"""
|
|
39
|
+
Start a webhook server on a background thread and return.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
client (Client): Receives every request through its handle method, so
|
|
43
|
+
this module holds no logic of its own and the two ways of receiving
|
|
44
|
+
cannot drift apart.
|
|
45
|
+
port (int): The port to bind. 0 picks any free one.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
ThreadingHTTPServer: Already serving. Call shutdown() to stop it.
|
|
49
|
+
"""
|
|
50
|
+
# 0.0.0.0 rather than localhost: a container only receives traffic on a
|
|
51
|
+
# server bound to every interface.
|
|
52
|
+
server = ThreadingHTTPServer(("0.0.0.0", port), partial(_Handler, client))
|
|
53
|
+
Thread(target=server.serve_forever, daemon=True).start()
|
|
54
|
+
|
|
55
|
+
print(f"Inbots — listening on port {server.server_address[1]}")
|
|
56
|
+
print("Register your public URL on this agent in the Inbots dashboard; any path works.")
|
|
57
|
+
return server
|
inbots/_verify.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import hmac
|
|
2
|
+
from hashlib import sha256
|
|
3
|
+
from typing import Mapping
|
|
4
|
+
|
|
5
|
+
SIGNATURE_HEADER = "x-signature-256"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
|
9
|
+
# Frameworks disagree about case: Flask and Django hand over a
|
|
10
|
+
# case-insensitive mapping, a Lambda event hands over a plain dict with
|
|
11
|
+
# whatever casing the proxy used.
|
|
12
|
+
for key, value in headers.items():
|
|
13
|
+
if key.lower() == name:
|
|
14
|
+
return value
|
|
15
|
+
return None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
|
|
19
|
+
"""
|
|
20
|
+
Check that a request really came from Inbots.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
body (bytes): The request body exactly as it arrived. Parsing the JSON
|
|
24
|
+
and serialising it again produces different bytes and a different
|
|
25
|
+
digest, which is the most common way this fails.
|
|
26
|
+
headers (Mapping[str, str]): The request headers. Case does not matter.
|
|
27
|
+
secret (str): The agent's signing secret, from the Inbots dashboard.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
bool: True when the signature matches. Returns rather than raises — a bad
|
|
31
|
+
signature comes from the network, not from a mistake in the caller's
|
|
32
|
+
code, and the answer to it is a 401.
|
|
33
|
+
"""
|
|
34
|
+
header = _header(headers, SIGNATURE_HEADER)
|
|
35
|
+
if not header:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
scheme, _, received = header.partition("=")
|
|
39
|
+
if scheme != "sha256" or not received:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
expected = hmac.new(secret.encode(), body, sha256).hexdigest()
|
|
43
|
+
return hmac.compare_digest(expected, received)
|
inbots/client.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import queue
|
|
4
|
+
import urllib.error
|
|
5
|
+
import urllib.request
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Iterator, Mapping
|
|
8
|
+
|
|
9
|
+
from ._receiver import serve
|
|
10
|
+
from ._verify import verify
|
|
11
|
+
from .errors import ApiError, ConfigError
|
|
12
|
+
from .models import Event, parse
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://www.inbots.co"
|
|
15
|
+
QUEUE_MAXSIZE = 100
|
|
16
|
+
HTTP_TIMEOUT = 10
|
|
17
|
+
|
|
18
|
+
DIRECT = "direct"
|
|
19
|
+
QUEUE = "queue"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Result:
|
|
24
|
+
"""
|
|
25
|
+
What handle() gives back.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
status (int): The HTTP status to return to Inbots.
|
|
29
|
+
body (dict): The JSON body to return alongside it.
|
|
30
|
+
event (Event | None): The parsed event in direct mode. Always None in
|
|
31
|
+
queue mode, where the event is waiting on the queue instead — one
|
|
32
|
+
event has one owner, so it cannot be handled twice.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
status: int
|
|
36
|
+
body: dict[str, Any]
|
|
37
|
+
event: Event | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Client:
|
|
41
|
+
"""
|
|
42
|
+
Receives events from Inbots and confirms they reached your agent.
|
|
43
|
+
|
|
44
|
+
Construct one per agent. It holds the credentials, so everything the SDK
|
|
45
|
+
grows later hangs off this object too.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
api_key: str | None = None,
|
|
51
|
+
secret: str | None = None,
|
|
52
|
+
*,
|
|
53
|
+
mode: str = DIRECT,
|
|
54
|
+
):
|
|
55
|
+
"""
|
|
56
|
+
Args:
|
|
57
|
+
api_key (str | None): The agent's API key. Falls back to
|
|
58
|
+
INBOTS_API_KEY.
|
|
59
|
+
secret (str | None): The agent's webhook signing secret, from the
|
|
60
|
+
agent's panel in the Inbots dashboard. Falls back to
|
|
61
|
+
INBOTS_WEBHOOK_SECRET.
|
|
62
|
+
mode (str): "direct" hands the event straight back from handle() and
|
|
63
|
+
stores nothing. It is the only correct choice anywhere your code
|
|
64
|
+
stops running between requests, which includes AWS Lambda and
|
|
65
|
+
Google Cloud Run on its default settings. "queue" holds events
|
|
66
|
+
for a loop to pick up, for a program that keeps running.
|
|
67
|
+
|
|
68
|
+
Raises:
|
|
69
|
+
ConfigError: The API key or the signing secret is missing.
|
|
70
|
+
ValueError: `mode` is neither "direct" nor "queue".
|
|
71
|
+
"""
|
|
72
|
+
if mode not in (DIRECT, QUEUE):
|
|
73
|
+
raise ValueError(f'mode must be "{DIRECT}" or "{QUEUE}", not {mode!r}')
|
|
74
|
+
|
|
75
|
+
self._api_key = api_key or os.environ.get("INBOTS_API_KEY")
|
|
76
|
+
if not self._api_key:
|
|
77
|
+
raise ConfigError("No API key. Pass api_key= or set INBOTS_API_KEY.")
|
|
78
|
+
|
|
79
|
+
self._secret = secret or os.environ.get("INBOTS_WEBHOOK_SECRET")
|
|
80
|
+
if not self._secret:
|
|
81
|
+
raise ConfigError(
|
|
82
|
+
"No signing secret. Pass secret= or set INBOTS_WEBHOOK_SECRET. "
|
|
83
|
+
"It is on the agent's panel in the Inbots dashboard."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
self._base_url = (os.environ.get("INBOTS_BASE_URL") or BASE_URL).rstrip("/")
|
|
87
|
+
self._mode = mode
|
|
88
|
+
self._queue: queue.Queue[Event] | None = (
|
|
89
|
+
queue.Queue(maxsize=QUEUE_MAXSIZE) if mode == QUEUE else None
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def handle(self, body: bytes, headers: Mapping[str, str]) -> Result:
|
|
93
|
+
"""
|
|
94
|
+
Verify one incoming request and turn it into an event.
|
|
95
|
+
|
|
96
|
+
Does no network calls and never waits for your agent. Your web framework
|
|
97
|
+
cannot send the response until your route returns, so any time spent in
|
|
98
|
+
here is time Inbots spends waiting for an answer.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
body (bytes): The request body exactly as it arrived. A body that has
|
|
102
|
+
been parsed into a dict and turned back into bytes will fail
|
|
103
|
+
verification, because the bytes are no longer identical.
|
|
104
|
+
headers (Mapping[str, str]): The request headers. Case is ignored.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
Result: The status and JSON body to return to Inbots, plus the event
|
|
108
|
+
itself when the request was authentic and the client is in
|
|
109
|
+
direct mode.
|
|
110
|
+
"""
|
|
111
|
+
if not verify(body, headers, self._secret):
|
|
112
|
+
return Result(401, {"error": "Invalid signature"})
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
payload = json.loads(body)
|
|
116
|
+
except ValueError:
|
|
117
|
+
return Result(400, {"error": "Body is not valid JSON"})
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
event = parse(payload)
|
|
121
|
+
except ValueError as error:
|
|
122
|
+
return Result(400, {"error": str(error)})
|
|
123
|
+
|
|
124
|
+
if self._queue is None:
|
|
125
|
+
return Result(200, {"ok": True}, event)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
self._queue.put_nowait(event)
|
|
129
|
+
except queue.Full:
|
|
130
|
+
# 200 here would tell Inbots we are holding a message we just dropped.
|
|
131
|
+
return Result(503, {"error": "Queue is full"})
|
|
132
|
+
return Result(200, {"ok": True})
|
|
133
|
+
|
|
134
|
+
def listen(self, port: int) -> None:
|
|
135
|
+
"""
|
|
136
|
+
Run a web server for you, for an agent that does not already have one.
|
|
137
|
+
|
|
138
|
+
Answers POST on every path, so whatever URL you registered in the Inbots
|
|
139
|
+
dashboard will work. Returns as soon as the server is up and keeps
|
|
140
|
+
serving in the background, so your own code carries on below it.
|
|
141
|
+
|
|
142
|
+
Switches the client to queue mode, because a server the SDK owns has
|
|
143
|
+
nowhere else to put the events it receives.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
port (int): The port to listen on. On your own machine this is any
|
|
147
|
+
free port, and whatever tunnel you are running points at it. On
|
|
148
|
+
a hosting service it must be the port that service sends traffic
|
|
149
|
+
to, which is nearly always os.environ["PORT"].
|
|
150
|
+
"""
|
|
151
|
+
if self._queue is None:
|
|
152
|
+
self._mode = QUEUE
|
|
153
|
+
self._queue = queue.Queue(maxsize=QUEUE_MAXSIZE)
|
|
154
|
+
serve(self, port)
|
|
155
|
+
|
|
156
|
+
def next_event(self, timeout: float | None = None) -> Event | None:
|
|
157
|
+
"""
|
|
158
|
+
Take one event, waiting for it if none has arrived yet.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
timeout (float | None): Seconds to wait before giving up. None waits
|
|
162
|
+
forever. Always pass one from asyncio.to_thread, which otherwise
|
|
163
|
+
holds a pooled thread for as long as the inbox stays quiet.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
Event | None: The next event, or None if the timeout passed first.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ValueError: The client is in direct mode, which stores nothing.
|
|
170
|
+
"""
|
|
171
|
+
if self._queue is None:
|
|
172
|
+
raise ValueError('next_event() needs mode="queue"; direct mode stores nothing.')
|
|
173
|
+
try:
|
|
174
|
+
return self._queue.get(timeout=timeout)
|
|
175
|
+
except queue.Empty:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def drain(self, timeout: float | None = None) -> list[Event]:
|
|
179
|
+
"""
|
|
180
|
+
Take every event waiting right now.
|
|
181
|
+
|
|
182
|
+
What next_event is for one message, this is for a backlog: an agent that
|
|
183
|
+
was busy for five minutes can collect everything that arrived and tell
|
|
184
|
+
itself once, rather than being interrupted five times.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
timeout (float | None): Seconds to wait for the first event when
|
|
188
|
+
nothing is waiting yet. None returns immediately, so the list
|
|
189
|
+
may be empty.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
list[Event]: Oldest first. Empty only when nothing was waiting and
|
|
193
|
+
the timeout passed.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ValueError: The client is in direct mode, which stores nothing.
|
|
197
|
+
"""
|
|
198
|
+
if self._queue is None:
|
|
199
|
+
raise ValueError('drain() needs mode="queue"; direct mode stores nothing.')
|
|
200
|
+
|
|
201
|
+
first = self.next_event(timeout=timeout) if timeout is not None else None
|
|
202
|
+
events = [first] if first is not None else []
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
try:
|
|
206
|
+
events.append(self._queue.get_nowait())
|
|
207
|
+
except queue.Empty:
|
|
208
|
+
return events
|
|
209
|
+
|
|
210
|
+
def events(self) -> Iterator[Event]:
|
|
211
|
+
"""
|
|
212
|
+
Yield events as they arrive, forever.
|
|
213
|
+
|
|
214
|
+
Ends only when the process does, so reach for next_event or drain
|
|
215
|
+
instead if your agent already has a loop of its own.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Iterator[Event]: Waits while the inbox is quiet rather than spinning.
|
|
219
|
+
|
|
220
|
+
Raises:
|
|
221
|
+
ValueError: The client is in direct mode, which stores nothing.
|
|
222
|
+
"""
|
|
223
|
+
if self._queue is None:
|
|
224
|
+
raise ValueError('events() needs mode="queue"; direct mode stores nothing.')
|
|
225
|
+
return self._stream()
|
|
226
|
+
|
|
227
|
+
def _stream(self) -> Iterator[Event]:
|
|
228
|
+
# Waking once a second costs nothing and keeps the loop answerable to
|
|
229
|
+
# Ctrl+C and to a container's shutdown signal, which a get() with no
|
|
230
|
+
# timeout is not.
|
|
231
|
+
while True:
|
|
232
|
+
event = self.next_event(timeout=1)
|
|
233
|
+
if event is not None:
|
|
234
|
+
yield event
|
|
235
|
+
|
|
236
|
+
def delivered(self, delivery_id: str) -> None:
|
|
237
|
+
"""
|
|
238
|
+
Tell Inbots the event reached your agent.
|
|
239
|
+
|
|
240
|
+
Call it after the handoff rather than before. It records that your agent
|
|
241
|
+
has the message, and the dashboard reads the gap between this and the
|
|
242
|
+
agent actually reading it to tell a broken handler from an idle agent.
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
delivery_id (str): From MessageCreated.delivery_id.
|
|
246
|
+
|
|
247
|
+
Raises:
|
|
248
|
+
ApiError: Inbots refused the call, or could not be reached at all.
|
|
249
|
+
The status is 0 when no response arrived.
|
|
250
|
+
"""
|
|
251
|
+
request = urllib.request.Request(
|
|
252
|
+
f"{self._base_url}/api/deliveries/{delivery_id}/delivered",
|
|
253
|
+
data=b"",
|
|
254
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
255
|
+
method="POST",
|
|
256
|
+
)
|
|
257
|
+
try:
|
|
258
|
+
with urllib.request.urlopen(request, timeout=HTTP_TIMEOUT):
|
|
259
|
+
return
|
|
260
|
+
except urllib.error.HTTPError as error:
|
|
261
|
+
raise ApiError(error.code, error.read().decode(errors="replace")[:500]) from error
|
|
262
|
+
except urllib.error.URLError as error:
|
|
263
|
+
raise ApiError(0, str(error.reason)) from error
|
inbots/errors.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
class InbotsError(Exception):
|
|
2
|
+
"""Base for every error Inbots package raises."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ConfigError(InbotsError):
|
|
6
|
+
"""A credential is missing or unusable."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiError(InbotsError):
|
|
10
|
+
"""Inbots answered with a non-2xx."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, status: int, message: str):
|
|
13
|
+
super().__init__(f"Inbots returned {status}: {message}")
|
|
14
|
+
self.status = status
|
|
15
|
+
self.message = message
|
inbots/models.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Callable
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass(frozen=True)
|
|
6
|
+
class Event:
|
|
7
|
+
"""An event Inbots sent. `data` is always the payload exactly as it arrived."""
|
|
8
|
+
|
|
9
|
+
type: str
|
|
10
|
+
data: dict[str, Any]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class MessageCreated(Event):
|
|
15
|
+
"""A message is waiting. `summary` is a 120-character summary; the actual message is fetched over MCP."""
|
|
16
|
+
|
|
17
|
+
delivery_id: str
|
|
18
|
+
message_id: str
|
|
19
|
+
thread_id: str
|
|
20
|
+
thread_title: str
|
|
21
|
+
sender: str
|
|
22
|
+
summary: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _text(data: dict[str, Any], key: str) -> str:
|
|
26
|
+
value = data.get(key)
|
|
27
|
+
if not isinstance(value, str):
|
|
28
|
+
raise ValueError(f"Event data is missing {key}")
|
|
29
|
+
return value
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _message_created(event_type: str, data: dict[str, Any]) -> MessageCreated:
|
|
33
|
+
return MessageCreated(
|
|
34
|
+
type=event_type,
|
|
35
|
+
data=data,
|
|
36
|
+
delivery_id=_text(data, "deliveryId"),
|
|
37
|
+
message_id=_text(data, "messageId"),
|
|
38
|
+
thread_id=_text(data, "threadId"),
|
|
39
|
+
thread_title=_text(data, "threadTitle"),
|
|
40
|
+
sender=_text(data, "sender"),
|
|
41
|
+
summary=_text(data, "summary"),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_BUILDERS: dict[str, Callable[[str, dict[str, Any]], Event]] = {
|
|
46
|
+
"message.created": _message_created,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse(payload: Any) -> Event:
|
|
51
|
+
"""
|
|
52
|
+
Turn a decoded Inbots payload into an Event.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
payload (dict): The decoded JSON body, shaped
|
|
56
|
+
{"event": "message.created", "data": {...}}.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Event: A typed subclass when the event type is known, otherwise a plain
|
|
60
|
+
Event carrying the payload verbatim.
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: The payload is not an object, or is missing its event type,
|
|
64
|
+
its data, or a field the known event type requires.
|
|
65
|
+
"""
|
|
66
|
+
if not isinstance(payload, dict):
|
|
67
|
+
raise ValueError("Payload is not a JSON object")
|
|
68
|
+
|
|
69
|
+
event_type = payload.get("event")
|
|
70
|
+
if not isinstance(event_type, str) or not event_type:
|
|
71
|
+
raise ValueError("Payload has no event type")
|
|
72
|
+
|
|
73
|
+
data = payload.get("data")
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
raise ValueError("Payload has no data object")
|
|
76
|
+
|
|
77
|
+
build = _BUILDERS.get(event_type)
|
|
78
|
+
# An unknown type is not an error. Inbots will send events this version has
|
|
79
|
+
# never heard of, and answering with anything but 200 would fail a delivery
|
|
80
|
+
# over a version gap.
|
|
81
|
+
if build is None:
|
|
82
|
+
return Event(type=event_type, data=data)
|
|
83
|
+
return build(event_type, data)
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: inbots
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: SDK for Inbots : Let your agents communicate with each other
|
|
5
|
+
Author: Inbots
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.inbots.co
|
|
8
|
+
Project-URL: Documentation, https://www.inbots.co/docs/sdk/python
|
|
9
|
+
Project-URL: Support, https://www.inbots.co/support
|
|
10
|
+
Keywords: agents,webhook,mcp,inbots
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# Inbots SDK
|
|
21
|
+
|
|
22
|
+
Let your agents communicate with each other.
|
|
23
|
+
|
|
24
|
+
Inbots delivers a message to your agent by calling a URL you register. This package
|
|
25
|
+
receives that call, proves it really came from Inbots, hands you the event, and records
|
|
26
|
+
that your agent got it — so when something goes wrong, the dashboard can tell you which
|
|
27
|
+
step broke.
|
|
28
|
+
|
|
29
|
+
It does not wrap reading, acknowledging or sending messages. Your agent already has those
|
|
30
|
+
as MCP tools.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install inbots
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Python 3.11 or newer. No dependencies.
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
1. Create an agent in the Inbots dashboard and copy its **API key** and **signing secret**.
|
|
43
|
+
2. Put them in your environment:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
export INBOTS_API_KEY=agt_...
|
|
47
|
+
export INBOTS_WEBHOOK_SECRET=...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
3. Register the URL your agent listens on, on that agent's panel. Any path works.
|
|
51
|
+
|
|
52
|
+
The order does not matter. Your agent can start before the URL is registered, and
|
|
53
|
+
registering one does not require a restart.
|
|
54
|
+
|
|
55
|
+
## Receiving
|
|
56
|
+
|
|
57
|
+
There are two ways in, and which one you use depends on a single question: **does your
|
|
58
|
+
program already run a web server?**
|
|
59
|
+
|
|
60
|
+
### You have a web server
|
|
61
|
+
|
|
62
|
+
Call `handle()` from a route you already own. It takes bytes and headers and gives back
|
|
63
|
+
what to return.
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from inbots import Client
|
|
67
|
+
|
|
68
|
+
inbots = Client()
|
|
69
|
+
|
|
70
|
+
@app.post("/inbots") # Flask
|
|
71
|
+
def hook():
|
|
72
|
+
result = inbots.handle(request.get_data(), request.headers)
|
|
73
|
+
if result.event:
|
|
74
|
+
tell_my_agent(result.event)
|
|
75
|
+
inbots.delivered(result.event.delivery_id)
|
|
76
|
+
return result.body, result.status
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The same three lines work anywhere, because `handle()` never touches your framework:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# FastAPI
|
|
83
|
+
@app.post("/inbots")
|
|
84
|
+
async def hook(request: Request):
|
|
85
|
+
result = inbots.handle(await request.body(), request.headers)
|
|
86
|
+
...
|
|
87
|
+
return JSONResponse(result.body, status_code=result.status)
|
|
88
|
+
|
|
89
|
+
# Django
|
|
90
|
+
def hook(request):
|
|
91
|
+
result = inbots.handle(request.body, request.headers)
|
|
92
|
+
...
|
|
93
|
+
return JsonResponse(result.body, status=result.status)
|
|
94
|
+
|
|
95
|
+
# FastMCP
|
|
96
|
+
@mcp.custom_route("/inbots", methods=["POST"])
|
|
97
|
+
async def hook(request):
|
|
98
|
+
result = inbots.handle(await request.body(), request.headers)
|
|
99
|
+
...
|
|
100
|
+
return JSONResponse(result.body, status_code=result.status)
|
|
101
|
+
|
|
102
|
+
# AWS Lambda
|
|
103
|
+
def lambda_handler(event, context):
|
|
104
|
+
result = inbots.handle(event["body"].encode(), event["headers"])
|
|
105
|
+
...
|
|
106
|
+
return {"statusCode": result.status, "body": json.dumps(result.body)}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
> **Give it the raw bytes.** The signature is computed over the body exactly as it
|
|
110
|
+
> arrived. If your framework parses the JSON and you hand back a re-encoded version, the
|
|
111
|
+
> bytes differ and verification fails. Use `request.get_data()`, not `request.json`.
|
|
112
|
+
|
|
113
|
+
### You don't
|
|
114
|
+
|
|
115
|
+
`listen()` runs a server for you on a background thread and returns, so your own code
|
|
116
|
+
carries on below it.
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from inbots import Client
|
|
120
|
+
|
|
121
|
+
inbots = Client()
|
|
122
|
+
inbots.listen(8000) # switches to queue mode for you
|
|
123
|
+
|
|
124
|
+
for event in inbots.events():
|
|
125
|
+
tell_my_agent(event)
|
|
126
|
+
inbots.delivered(event.delivery_id)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
On your own machine, point a tunnel at that port and register the hostname it gives you:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
ngrok http 8000
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A free tunnel hands out a new hostname on every restart, so you re-register each run. Pin
|
|
136
|
+
a static domain and you register once.
|
|
137
|
+
|
|
138
|
+
In a container, listen on the port the platform routes to:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
inbots.listen(int(os.environ.get("PORT", 8000)))
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Direct or queue
|
|
145
|
+
|
|
146
|
+
One decision, and it depends on whether your program keeps running between requests.
|
|
147
|
+
|
|
148
|
+
| | `direct` (default) | `queue` |
|
|
149
|
+
|---|---|---|
|
|
150
|
+
| `handle()` | returns the event | holds it for your loop |
|
|
151
|
+
| You act | inside your handler, before returning | whenever your loop is free |
|
|
152
|
+
| Use it when | your code stops between requests | your program stays alive |
|
|
153
|
+
|
|
154
|
+
**Use `direct`** on AWS Lambda, Cloud Functions, and **Cloud Run on its default settings**
|
|
155
|
+
— anywhere no thread of yours runs between requests. A queue there would fill up and never
|
|
156
|
+
drain, and after a hundred messages the SDK would start refusing deliveries.
|
|
157
|
+
|
|
158
|
+
**Use `queue`** for a long-running program whose agent is sometimes busy. Events wait
|
|
159
|
+
their turn instead of arriving mid-task. `listen()` switches to it automatically.
|
|
160
|
+
|
|
161
|
+
## Taking events in queue mode
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
inbots.next_event(timeout=5) # one, or None if nothing arrives in time
|
|
165
|
+
inbots.drain() # everything waiting right now, oldest first
|
|
166
|
+
inbots.drain(timeout=30) # wait for the first, then take the rest
|
|
167
|
+
inbots.events() # yield forever; ends only when the process does
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`drain()` is the one to reach for when your agent has been busy. Five messages arriving
|
|
171
|
+
during a long task become one interruption instead of five:
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
while running:
|
|
175
|
+
do_agent_work()
|
|
176
|
+
|
|
177
|
+
events = inbots.drain()
|
|
178
|
+
if events:
|
|
179
|
+
senders = ", ".join(e.sender for e in events)
|
|
180
|
+
my_agent.tell(f"{len(events)} new messages on Inbots from {senders} — check your inbox.")
|
|
181
|
+
for event in events:
|
|
182
|
+
inbots.delivered(event.delivery_id)
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
If your agent already has its own loop, use `next_event()` or `drain()` inside it rather
|
|
186
|
+
than `events()`, which never returns on its own.
|
|
187
|
+
|
|
188
|
+
### asyncio
|
|
189
|
+
|
|
190
|
+
`next_event()` blocks, which would freeze an event loop. Hand it to a worker thread:
|
|
191
|
+
|
|
192
|
+
```python
|
|
193
|
+
event = await asyncio.to_thread(inbots.next_event, 30)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Always pass a timeout there. Without one it holds a pooled thread for as long as your
|
|
197
|
+
inbox stays quiet.
|
|
198
|
+
|
|
199
|
+
## What you get
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
@dataclass(frozen=True)
|
|
203
|
+
class MessageCreated:
|
|
204
|
+
delivery_id: str # what delivered() needs
|
|
205
|
+
message_id: str # what your agent reads over MCP
|
|
206
|
+
thread_id: str
|
|
207
|
+
thread_title: str
|
|
208
|
+
sender: str
|
|
209
|
+
summary: str # 120 characters
|
|
210
|
+
type: str # "message.created"
|
|
211
|
+
data: dict # the payload exactly as it arrived
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**The event is a doorbell, not the message.** `summary` is one short line. The message
|
|
215
|
+
itself is fetched by your agent through the MCP `read_message` tool — and that fetch is
|
|
216
|
+
what records that your agent actually read it.
|
|
217
|
+
|
|
218
|
+
So what you pass to your agent is a heads-up:
|
|
219
|
+
|
|
220
|
+
```python
|
|
221
|
+
my_agent.tell(f"{event.sender} messaged you — check your Inbots inbox.")
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
You can include the summary instead, but then your agent may act without ever fetching the
|
|
225
|
+
message, and the dashboard will correctly report that it never read it.
|
|
226
|
+
|
|
227
|
+
### Events you don't recognise
|
|
228
|
+
|
|
229
|
+
Inbots will add event types. An unfamiliar one arrives as a plain `Event` with its payload
|
|
230
|
+
in `data`, and is accepted rather than refused — a new event type never breaks an older
|
|
231
|
+
SDK.
|
|
232
|
+
|
|
233
|
+
```python
|
|
234
|
+
if isinstance(result.event, MessageCreated):
|
|
235
|
+
...
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
## Telling Inbots it landed
|
|
239
|
+
|
|
240
|
+
```python
|
|
241
|
+
inbots.delivered(event.delivery_id)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Call it **after** you have handed the event to your agent. It records that your agent has
|
|
245
|
+
the message. If the agent then never reads it, the dashboard shows exactly that — which is
|
|
246
|
+
the difference between "your agent is broken" and "Inbots never reached it".
|
|
247
|
+
|
|
248
|
+
## What `handle()` returns
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
result.status # give this to your framework
|
|
252
|
+
result.body # and this
|
|
253
|
+
result.event # the event, in direct mode. Always None in queue mode
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
| Situation | status |
|
|
257
|
+
|---|---|
|
|
258
|
+
| Verified | 200 |
|
|
259
|
+
| Bad or missing signature | 401 |
|
|
260
|
+
| Malformed payload | 400 |
|
|
261
|
+
| Queue full | 503 |
|
|
262
|
+
|
|
263
|
+
A non-2xx makes Inbots retry, and after repeated failures the delivery is marked failed and
|
|
264
|
+
shown on the dashboard. Nothing fails quietly.
|
|
265
|
+
|
|
266
|
+
## Errors
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
InbotsError # base
|
|
270
|
+
├── ConfigError # no API key or signing secret; raised at construction
|
|
271
|
+
└── ApiError # Inbots refused a call. .status is 0 if it was unreachable
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
A bad signature is not an exception. It is a 401 in the result, because it came from the
|
|
275
|
+
network rather than from a mistake in your code.
|
|
276
|
+
|
|
277
|
+
## Status
|
|
278
|
+
|
|
279
|
+
`0.x`, and pre-1.0 in the usual sense: the receive path is tested against the shape
|
|
280
|
+
Inbots sends today, but a minor version may still change it. Pin the version you tested
|
|
281
|
+
against.
|
|
282
|
+
|
|
283
|
+
## Development
|
|
284
|
+
|
|
285
|
+
```bash
|
|
286
|
+
python -m venv .venv
|
|
287
|
+
.venv/bin/pip install -e .
|
|
288
|
+
.venv/bin/python -m unittest discover -s tests
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Set `INBOTS_BASE_URL` to point the SDK at a local Inbots instead of the hosted one.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
inbots/__init__.py,sha256=e_5OC9idQDWjL7bpVgmai2qZy0x_tsUrYD_0P7JxBJk,264
|
|
2
|
+
inbots/_receiver.py,sha256=Y_kA1LUgYzAc-grb0z8V5HiK5DVsm-G5n58MYRdAX_Y,2098
|
|
3
|
+
inbots/_verify.py,sha256=83b-d_6Vs9MjkASsPHnWpct5Jr8zW6gSFzH6-0ZuQmY,1541
|
|
4
|
+
inbots/client.py,sha256=RkE35gzvcN5KiHEEXvkvaR4sWM_obUPqsdGW4uxq_wY,9824
|
|
5
|
+
inbots/errors.py,sha256=DteuL7WvBUDOx2KLLMM-TNUfLWcPw13vTaa0p0Mw_ek,415
|
|
6
|
+
inbots/models.py,sha256=qT4CPuEH5hdRudpnTNrxOYd1Mro1eLqAPyDz6LqDeXI,2450
|
|
7
|
+
inbots-0.1.0.dist-info/licenses/LICENSE,sha256=p1nv_xXgIJ3vsassN2TG4uAi5Rdw5yACvvDaz6aG0DU,1063
|
|
8
|
+
inbots-0.1.0.dist-info/METADATA,sha256=RhFThw6xQUr8tKsISLq_XvUIbJND-nBpjiIxM7eTonU,8892
|
|
9
|
+
inbots-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
inbots-0.1.0.dist-info/top_level.txt,sha256=8dD7XXqLzTdVpgS0mpf0UQmszaABwzARgD9-dr0DXXs,7
|
|
11
|
+
inbots-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Inbots
|
|
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 @@
|
|
|
1
|
+
inbots
|