commb-agent 0.3.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.
- commb_agent/__init__.py +23 -0
- commb_agent/client.py +677 -0
- commb_agent/py.typed +1 -0
- commb_agent-0.3.0.dist-info/METADATA +238 -0
- commb_agent-0.3.0.dist-info/RECORD +6 -0
- commb_agent-0.3.0.dist-info/WHEEL +4 -0
commb_agent/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .client import (
|
|
2
|
+
CommBClient,
|
|
3
|
+
AsyncCommBClient,
|
|
4
|
+
CommBConfigResponse,
|
|
5
|
+
BotConfig,
|
|
6
|
+
BotInfo,
|
|
7
|
+
KnowledgeDoc,
|
|
8
|
+
CatalogItem,
|
|
9
|
+
ChatMessage,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__version__ = "0.3.0"
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CommBClient",
|
|
16
|
+
"AsyncCommBClient",
|
|
17
|
+
"CommBConfigResponse",
|
|
18
|
+
"BotConfig",
|
|
19
|
+
"BotInfo",
|
|
20
|
+
"KnowledgeDoc",
|
|
21
|
+
"CatalogItem",
|
|
22
|
+
"ChatMessage",
|
|
23
|
+
]
|
commb_agent/client.py
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"""
|
|
2
|
+
commb-agent — Official Python SDK
|
|
3
|
+
v0.3.0 — Sannex Tech LTD <info@sannex.ng>
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import queue
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from pydantic import BaseModel, Field
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ----------------------------------------------------------
|
|
19
|
+
# Pydantic Response Models
|
|
20
|
+
# ----------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
class BotConfig(BaseModel):
|
|
23
|
+
system_prompt: str = ""
|
|
24
|
+
temperature: float = 0.7
|
|
25
|
+
model_name: str = "gemini-2.5-flash"
|
|
26
|
+
llm_provider: str = "gemini"
|
|
27
|
+
bot_mode: str = "hybrid"
|
|
28
|
+
catalog_source: str = "local"
|
|
29
|
+
max_tokens: int = 1024
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class KnowledgeDoc(BaseModel):
|
|
33
|
+
id: str
|
|
34
|
+
title: str
|
|
35
|
+
category: Optional[str] = None
|
|
36
|
+
content: str
|
|
37
|
+
tags: Optional[str] = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class CatalogItem(BaseModel):
|
|
41
|
+
id: str
|
|
42
|
+
title: str
|
|
43
|
+
description: Optional[str] = None
|
|
44
|
+
price: float
|
|
45
|
+
currency: str
|
|
46
|
+
image_url: Optional[str] = None
|
|
47
|
+
source: Optional[str] = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BotInfo(BaseModel):
|
|
51
|
+
id: str
|
|
52
|
+
name: str
|
|
53
|
+
slug: Optional[str] = None
|
|
54
|
+
reseller: Optional[str] = None
|
|
55
|
+
business_id: Optional[str] = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class AgentSummary(BaseModel):
|
|
59
|
+
id: str
|
|
60
|
+
name: str
|
|
61
|
+
slug: Optional[str] = None
|
|
62
|
+
description: Optional[str] = None
|
|
63
|
+
system_prompt: Optional[str] = None
|
|
64
|
+
model_name: Optional[str] = "gemini-2.5-flash"
|
|
65
|
+
temperature: Optional[float] = 0.7
|
|
66
|
+
max_tokens: Optional[int] = 1024
|
|
67
|
+
llm_provider: Optional[str] = "gemini"
|
|
68
|
+
is_active: bool = True
|
|
69
|
+
whatsapp_phone_number_id: Optional[str] = None
|
|
70
|
+
telegram_bot_token: Optional[str] = None
|
|
71
|
+
telegram_username: Optional[str] = None
|
|
72
|
+
widget_enabled: bool = True
|
|
73
|
+
access_tags: List[str] = Field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ReleaseNote(BaseModel):
|
|
77
|
+
version: str
|
|
78
|
+
title: str
|
|
79
|
+
description: Optional[str] = None
|
|
80
|
+
changelog: List[str] = Field(default_factory=list)
|
|
81
|
+
release_date: Optional[str] = None
|
|
82
|
+
is_critical: bool = False
|
|
83
|
+
download_url: Optional[str] = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class CommBConfigResponse(BaseModel):
|
|
87
|
+
status: str
|
|
88
|
+
tenant: Optional[BotInfo] = None
|
|
89
|
+
config: BotConfig = Field(default_factory=BotConfig)
|
|
90
|
+
knowledge_docs: List[KnowledgeDoc] = Field(default_factory=list)
|
|
91
|
+
catalog_items: List[CatalogItem] = Field(default_factory=list)
|
|
92
|
+
agents: List[AgentSummary] = Field(default_factory=list)
|
|
93
|
+
releases: List[ReleaseNote] = Field(default_factory=list)
|
|
94
|
+
support: Optional[Dict[str, Any]] = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class ChatMessage(BaseModel):
|
|
98
|
+
role: str # "user" | "assistant"
|
|
99
|
+
content: str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ----------------------------------------------------------
|
|
103
|
+
# CommBClient — Synchronous (thread-based background flush)
|
|
104
|
+
# ----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
class CommBClient:
|
|
107
|
+
"""Official Python Telemetry & Remote Config Client for Sannex AI Operations."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
api_key: str,
|
|
112
|
+
host: str = "https://commb.app",
|
|
113
|
+
flush_interval: float = 1.0,
|
|
114
|
+
max_queue_size: int = 1000,
|
|
115
|
+
agent_id: Optional[str] = None,
|
|
116
|
+
):
|
|
117
|
+
if not api_key:
|
|
118
|
+
raise ValueError("api_key must not be empty.")
|
|
119
|
+
self.api_key = api_key
|
|
120
|
+
self.agent_id = agent_id
|
|
121
|
+
self.host = host.rstrip("/")
|
|
122
|
+
api_prefix = "/api/v1" if not self.host.endswith("/api") and not self.host.endswith("/v1") else ""
|
|
123
|
+
self.events_endpoint = f"{self.host}{api_prefix}/events"
|
|
124
|
+
self.config_endpoint = f"{self.host}{api_prefix}/sync"
|
|
125
|
+
self.chat_endpoint = f"{self.host}{api_prefix}/chat"
|
|
126
|
+
self.releases_endpoint = f"{self.host}{api_prefix}/releases"
|
|
127
|
+
self.feedback_endpoint = f"{self.host}{api_prefix}/feedback"
|
|
128
|
+
self.flush_interval = flush_interval
|
|
129
|
+
self._queue: queue.Queue = queue.Queue(maxsize=max_queue_size)
|
|
130
|
+
self._running = True
|
|
131
|
+
self._client = httpx.Client(timeout=10.0)
|
|
132
|
+
|
|
133
|
+
self._worker_thread = threading.Thread(target=self._worker, daemon=True)
|
|
134
|
+
self._worker_thread.start()
|
|
135
|
+
|
|
136
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
137
|
+
return {
|
|
138
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
139
|
+
"Content-Type": "application/json",
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
def track(
|
|
143
|
+
self,
|
|
144
|
+
channel: str,
|
|
145
|
+
customer_id: str,
|
|
146
|
+
event: str,
|
|
147
|
+
status: str = "success",
|
|
148
|
+
amount: float = 0.0,
|
|
149
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
150
|
+
agent_id: Optional[str] = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Enqueue a telemetry event for async dispatch. Never blocks or raises."""
|
|
153
|
+
eff_agent_id = agent_id or self.agent_id
|
|
154
|
+
payload = {
|
|
155
|
+
"channel": channel,
|
|
156
|
+
"customer_id": str(customer_id),
|
|
157
|
+
"event": event,
|
|
158
|
+
"status": status,
|
|
159
|
+
"amount": float(amount),
|
|
160
|
+
"metadata": metadata or {},
|
|
161
|
+
"timestamp": time.time(),
|
|
162
|
+
}
|
|
163
|
+
if eff_agent_id:
|
|
164
|
+
payload["agent_id"] = str(eff_agent_id)
|
|
165
|
+
try:
|
|
166
|
+
self._queue.put_nowait(payload)
|
|
167
|
+
except queue.Full:
|
|
168
|
+
pass # Drop silently — telemetry must never degrade host performance
|
|
169
|
+
|
|
170
|
+
def get_config(self, agent_id: Optional[str] = None) -> CommBConfigResponse:
|
|
171
|
+
"""Pull bot/agent config, knowledge docs, catalog, and agent roster from CommB collector."""
|
|
172
|
+
eff_agent_id = agent_id or self.agent_id
|
|
173
|
+
params = {"agent_id": eff_agent_id} if eff_agent_id else None
|
|
174
|
+
try:
|
|
175
|
+
res = self._client.get(self.config_endpoint, headers=self._auth_headers(), params=params)
|
|
176
|
+
if res.status_code == 200:
|
|
177
|
+
return CommBConfigResponse.model_validate(res.json())
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
return CommBConfigResponse(status="error")
|
|
181
|
+
|
|
182
|
+
def get_bot(self, agent_id: Optional[str] = None) -> Optional[BotInfo]:
|
|
183
|
+
"""Retrieve basic bot identity (id, name, slug, reseller)."""
|
|
184
|
+
cfg = self.get_config(agent_id=agent_id)
|
|
185
|
+
return cfg.tenant
|
|
186
|
+
|
|
187
|
+
def get_agents(self) -> List[AgentSummary]:
|
|
188
|
+
"""Retrieve list of all agents under this business/instance."""
|
|
189
|
+
cfg = self.get_config()
|
|
190
|
+
return cfg.agents
|
|
191
|
+
|
|
192
|
+
def get_releases(
|
|
193
|
+
self,
|
|
194
|
+
app_name: str = "commb",
|
|
195
|
+
app_version: Optional[str] = None,
|
|
196
|
+
) -> List[ReleaseNote]:
|
|
197
|
+
"""Fetch CommB platform release notes and updates from CommB collector."""
|
|
198
|
+
headers = self._auth_headers()
|
|
199
|
+
headers["X-App-Name"] = str(app_name)
|
|
200
|
+
if app_version:
|
|
201
|
+
headers["X-App-Version"] = str(app_version)
|
|
202
|
+
try:
|
|
203
|
+
params: Dict[str, Any] = {"app": str(app_name)}
|
|
204
|
+
if app_version:
|
|
205
|
+
params["app_version"] = str(app_version)
|
|
206
|
+
res = self._client.get(
|
|
207
|
+
self.releases_endpoint,
|
|
208
|
+
headers=headers,
|
|
209
|
+
params=params,
|
|
210
|
+
timeout=10.0,
|
|
211
|
+
)
|
|
212
|
+
if res.is_success:
|
|
213
|
+
data = res.json()
|
|
214
|
+
items = data.get("releases", data) if isinstance(data, dict) else data
|
|
215
|
+
if isinstance(items, list):
|
|
216
|
+
return [ReleaseNote.model_validate(r) for r in items]
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
return []
|
|
220
|
+
|
|
221
|
+
def send_feedback(
|
|
222
|
+
self,
|
|
223
|
+
message: str,
|
|
224
|
+
category: str = "general",
|
|
225
|
+
contact_email: Optional[str] = None,
|
|
226
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
227
|
+
) -> bool:
|
|
228
|
+
"""Send feedback, bug reports, or feature suggestions to CommB collector."""
|
|
229
|
+
body = {
|
|
230
|
+
"app": "commb",
|
|
231
|
+
"category": category,
|
|
232
|
+
"message": str(message),
|
|
233
|
+
"contact_email": contact_email,
|
|
234
|
+
"metadata": metadata or {},
|
|
235
|
+
}
|
|
236
|
+
if self.agent_id:
|
|
237
|
+
body["metadata"]["agent_id"] = self.agent_id
|
|
238
|
+
try:
|
|
239
|
+
res = self._client.post(
|
|
240
|
+
self.feedback_endpoint,
|
|
241
|
+
headers=self._auth_headers(),
|
|
242
|
+
json=body,
|
|
243
|
+
timeout=5.0,
|
|
244
|
+
)
|
|
245
|
+
return res.is_success
|
|
246
|
+
except Exception:
|
|
247
|
+
return False
|
|
248
|
+
|
|
249
|
+
def sync_conversation(
|
|
250
|
+
self,
|
|
251
|
+
channel: str,
|
|
252
|
+
customer_id: str,
|
|
253
|
+
messages: List[Dict[str, Any]],
|
|
254
|
+
agent_id: Optional[str] = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Push a batched conversation transcript up to CommB collector."""
|
|
257
|
+
eff_agent_id = agent_id or self.agent_id
|
|
258
|
+
payload = {
|
|
259
|
+
"type": "chat_transcript",
|
|
260
|
+
"channel": channel,
|
|
261
|
+
"customer_id": str(customer_id),
|
|
262
|
+
"messages": messages,
|
|
263
|
+
"timestamp": time.time(),
|
|
264
|
+
}
|
|
265
|
+
if eff_agent_id:
|
|
266
|
+
payload["agent_id"] = str(eff_agent_id)
|
|
267
|
+
try:
|
|
268
|
+
self._queue.put_nowait(payload)
|
|
269
|
+
except queue.Full:
|
|
270
|
+
pass
|
|
271
|
+
|
|
272
|
+
def ping(self) -> bool:
|
|
273
|
+
"""Check if the CommB collector host is reachable. Returns True if healthy."""
|
|
274
|
+
try:
|
|
275
|
+
res = self._client.get(
|
|
276
|
+
f"{self.host}/api/health",
|
|
277
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
278
|
+
timeout=5.0,
|
|
279
|
+
)
|
|
280
|
+
return res.is_success
|
|
281
|
+
except Exception:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
def stream_chat(
|
|
285
|
+
self,
|
|
286
|
+
message: str,
|
|
287
|
+
user_id: str,
|
|
288
|
+
history: Optional[List[ChatMessage]] = None,
|
|
289
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
290
|
+
agent_id: Optional[str] = None,
|
|
291
|
+
) -> Iterator[str]:
|
|
292
|
+
"""
|
|
293
|
+
Stream a chat response from the CommB engine via SSE.
|
|
294
|
+
|
|
295
|
+
Usage::
|
|
296
|
+
|
|
297
|
+
for chunk in client.stream_chat("Hello!", "user_123"):
|
|
298
|
+
print(chunk, end="", flush=True)
|
|
299
|
+
"""
|
|
300
|
+
eff_agent_id = agent_id or self.agent_id
|
|
301
|
+
body: Dict[str, Any] = {
|
|
302
|
+
"message": message,
|
|
303
|
+
"user_id": user_id,
|
|
304
|
+
"history": [m.model_dump() for m in (history or [])],
|
|
305
|
+
"metadata": metadata or {},
|
|
306
|
+
}
|
|
307
|
+
if eff_agent_id:
|
|
308
|
+
body["agent_id"] = str(eff_agent_id)
|
|
309
|
+
|
|
310
|
+
with self._client.stream(
|
|
311
|
+
"POST",
|
|
312
|
+
self.chat_endpoint,
|
|
313
|
+
headers=self._auth_headers(),
|
|
314
|
+
json=body,
|
|
315
|
+
) as res:
|
|
316
|
+
res.raise_for_status()
|
|
317
|
+
buffer = ""
|
|
318
|
+
for chunk in res.iter_text():
|
|
319
|
+
buffer += chunk
|
|
320
|
+
while "\n" in buffer:
|
|
321
|
+
line, buffer = buffer.split("\n", 1)
|
|
322
|
+
line = line.strip()
|
|
323
|
+
if line.startswith("data: "):
|
|
324
|
+
data = line[6:].strip()
|
|
325
|
+
if data == "[DONE]":
|
|
326
|
+
return
|
|
327
|
+
try:
|
|
328
|
+
import json
|
|
329
|
+
parsed = json.loads(data)
|
|
330
|
+
text = (
|
|
331
|
+
parsed.get("choices", [{}])[0]
|
|
332
|
+
.get("delta", {})
|
|
333
|
+
.get("content")
|
|
334
|
+
or parsed.get("text")
|
|
335
|
+
or data
|
|
336
|
+
)
|
|
337
|
+
if text:
|
|
338
|
+
yield text
|
|
339
|
+
except Exception:
|
|
340
|
+
if data:
|
|
341
|
+
yield data
|
|
342
|
+
|
|
343
|
+
def _worker(self) -> None:
|
|
344
|
+
while self._running:
|
|
345
|
+
events: List[Dict[str, Any]] = []
|
|
346
|
+
while not self._queue.empty() and len(events) < 50:
|
|
347
|
+
try:
|
|
348
|
+
events.append(self._queue.get_nowait())
|
|
349
|
+
except queue.Empty:
|
|
350
|
+
break
|
|
351
|
+
if events:
|
|
352
|
+
self._flush(events)
|
|
353
|
+
time.sleep(self.flush_interval)
|
|
354
|
+
|
|
355
|
+
def _flush(self, events: List[Dict[str, Any]]) -> None:
|
|
356
|
+
try:
|
|
357
|
+
self._client.post(
|
|
358
|
+
self.events_endpoint,
|
|
359
|
+
json={"batch": events},
|
|
360
|
+
headers=self._auth_headers(),
|
|
361
|
+
)
|
|
362
|
+
except Exception:
|
|
363
|
+
pass # Never crash the host
|
|
364
|
+
|
|
365
|
+
def flush(self) -> None:
|
|
366
|
+
"""Manually flush all queued events immediately."""
|
|
367
|
+
events: List[Dict[str, Any]] = []
|
|
368
|
+
while not self._queue.empty():
|
|
369
|
+
try:
|
|
370
|
+
events.append(self._queue.get_nowait())
|
|
371
|
+
except queue.Empty:
|
|
372
|
+
break
|
|
373
|
+
if events:
|
|
374
|
+
self._flush(events)
|
|
375
|
+
|
|
376
|
+
def close(self) -> None:
|
|
377
|
+
self._running = False
|
|
378
|
+
self.flush()
|
|
379
|
+
try:
|
|
380
|
+
self._worker_thread.join(timeout=2.0)
|
|
381
|
+
self._client.close()
|
|
382
|
+
except Exception:
|
|
383
|
+
pass
|
|
384
|
+
|
|
385
|
+
def __enter__(self) -> "CommBClient":
|
|
386
|
+
return self
|
|
387
|
+
|
|
388
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
389
|
+
self.close()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ----------------------------------------------------------
|
|
393
|
+
# AsyncCommBClient — async-first (FastAPI, asyncio)
|
|
394
|
+
# ----------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
class AsyncCommBClient:
|
|
397
|
+
"""
|
|
398
|
+
Async-first Sannex client for FastAPI and asyncio environments.
|
|
399
|
+
|
|
400
|
+
Usage::
|
|
401
|
+
|
|
402
|
+
async with AsyncCommBClient(api_key="snx_bot_xxxx", host="http://localhost:3000") as client:
|
|
403
|
+
config = await client.get_config()
|
|
404
|
+
async for chunk in client.stream_chat("Hello!", "user_123"):
|
|
405
|
+
print(chunk, end="", flush=True)
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
def __init__(
|
|
409
|
+
self,
|
|
410
|
+
api_key: str,
|
|
411
|
+
host: str = "https://commb.app",
|
|
412
|
+
flush_interval: float = 2.0,
|
|
413
|
+
max_queue_size: int = 1000,
|
|
414
|
+
agent_id: Optional[str] = None,
|
|
415
|
+
):
|
|
416
|
+
if not api_key:
|
|
417
|
+
raise ValueError("api_key must not be empty.")
|
|
418
|
+
self.api_key = api_key
|
|
419
|
+
self.agent_id = agent_id
|
|
420
|
+
self.host = host.rstrip("/")
|
|
421
|
+
api_prefix = "/api/v1" if not self.host.endswith("/api") and not self.host.endswith("/v1") else ""
|
|
422
|
+
self.events_endpoint = f"{self.host}{api_prefix}/events"
|
|
423
|
+
self.config_endpoint = f"{self.host}{api_prefix}/sync"
|
|
424
|
+
self.chat_endpoint = f"{self.host}{api_prefix}/chat"
|
|
425
|
+
self.releases_endpoint = f"{self.host}{api_prefix}/releases"
|
|
426
|
+
self.feedback_endpoint = f"{self.host}{api_prefix}/feedback"
|
|
427
|
+
self.flush_interval = flush_interval
|
|
428
|
+
self._queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue_size)
|
|
429
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
430
|
+
self._flush_task: Optional[asyncio.Task] = None
|
|
431
|
+
|
|
432
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
433
|
+
if self._client is None or self._client.is_closed:
|
|
434
|
+
self._client = httpx.AsyncClient(timeout=10.0)
|
|
435
|
+
return self._client
|
|
436
|
+
|
|
437
|
+
def _auth_headers(self) -> Dict[str, str]:
|
|
438
|
+
return {
|
|
439
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
440
|
+
"Content-Type": "application/json",
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async def track(
|
|
444
|
+
self,
|
|
445
|
+
channel: str,
|
|
446
|
+
customer_id: str,
|
|
447
|
+
event: str,
|
|
448
|
+
status: str = "success",
|
|
449
|
+
amount: float = 0.0,
|
|
450
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
451
|
+
agent_id: Optional[str] = None,
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Enqueue a telemetry event. Non-blocking — never raises."""
|
|
454
|
+
eff_agent_id = agent_id or self.agent_id
|
|
455
|
+
payload = {
|
|
456
|
+
"channel": channel,
|
|
457
|
+
"customer_id": str(customer_id),
|
|
458
|
+
"event": event,
|
|
459
|
+
"status": status,
|
|
460
|
+
"amount": float(amount),
|
|
461
|
+
"metadata": metadata or {},
|
|
462
|
+
"timestamp": time.time(),
|
|
463
|
+
}
|
|
464
|
+
if eff_agent_id:
|
|
465
|
+
payload["agent_id"] = str(eff_agent_id)
|
|
466
|
+
try:
|
|
467
|
+
self._queue.put_nowait(payload)
|
|
468
|
+
except asyncio.QueueFull:
|
|
469
|
+
pass
|
|
470
|
+
|
|
471
|
+
async def get_config(self, agent_id: Optional[str] = None) -> CommBConfigResponse:
|
|
472
|
+
"""Pull bot/agent config, knowledge docs, catalog, and agent roster from CommB collector."""
|
|
473
|
+
eff_agent_id = agent_id or self.agent_id
|
|
474
|
+
params = {"agent_id": eff_agent_id} if eff_agent_id else None
|
|
475
|
+
try:
|
|
476
|
+
client = self._get_client()
|
|
477
|
+
res = await client.get(self.config_endpoint, headers=self._auth_headers(), params=params)
|
|
478
|
+
if res.is_success:
|
|
479
|
+
return CommBConfigResponse.model_validate(res.json())
|
|
480
|
+
except Exception:
|
|
481
|
+
pass
|
|
482
|
+
return CommBConfigResponse(status="error")
|
|
483
|
+
|
|
484
|
+
async def get_bot(self, agent_id: Optional[str] = None) -> Optional[BotInfo]:
|
|
485
|
+
"""Retrieve basic bot identity (id, name, slug, reseller)."""
|
|
486
|
+
cfg = await self.get_config(agent_id=agent_id)
|
|
487
|
+
return cfg.tenant
|
|
488
|
+
|
|
489
|
+
async def get_agents(self) -> List[AgentSummary]:
|
|
490
|
+
"""Retrieve list of all agents under this business/instance."""
|
|
491
|
+
cfg = await self.get_config()
|
|
492
|
+
return cfg.agents
|
|
493
|
+
|
|
494
|
+
async def get_releases(
|
|
495
|
+
self,
|
|
496
|
+
app_name: str = "commb",
|
|
497
|
+
app_version: Optional[str] = None,
|
|
498
|
+
) -> List[ReleaseNote]:
|
|
499
|
+
"""Fetch CommB platform release notes and updates from CommB collector."""
|
|
500
|
+
headers = self._auth_headers()
|
|
501
|
+
headers["X-App-Name"] = str(app_name)
|
|
502
|
+
if app_version:
|
|
503
|
+
headers["X-App-Version"] = str(app_version)
|
|
504
|
+
try:
|
|
505
|
+
params: Dict[str, Any] = {"app": str(app_name)}
|
|
506
|
+
if app_version:
|
|
507
|
+
params["app_version"] = str(app_version)
|
|
508
|
+
client = self._get_client()
|
|
509
|
+
res = await client.get(
|
|
510
|
+
self.releases_endpoint,
|
|
511
|
+
headers=headers,
|
|
512
|
+
params=params,
|
|
513
|
+
timeout=10.0,
|
|
514
|
+
)
|
|
515
|
+
if res.is_success:
|
|
516
|
+
data = res.json()
|
|
517
|
+
items = data.get("releases", data) if isinstance(data, dict) else data
|
|
518
|
+
if isinstance(items, list):
|
|
519
|
+
return [ReleaseNote.model_validate(r) for r in items]
|
|
520
|
+
except Exception:
|
|
521
|
+
pass
|
|
522
|
+
return []
|
|
523
|
+
|
|
524
|
+
async def send_feedback(
|
|
525
|
+
self,
|
|
526
|
+
message: str,
|
|
527
|
+
category: str = "general",
|
|
528
|
+
contact_email: Optional[str] = None,
|
|
529
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
530
|
+
) -> bool:
|
|
531
|
+
"""Send feedback, bug reports, or feature suggestions to CommB collector."""
|
|
532
|
+
body = {
|
|
533
|
+
"app": "commb",
|
|
534
|
+
"category": category,
|
|
535
|
+
"message": str(message),
|
|
536
|
+
"contact_email": contact_email,
|
|
537
|
+
"metadata": metadata or {},
|
|
538
|
+
}
|
|
539
|
+
if self.agent_id:
|
|
540
|
+
body["metadata"]["agent_id"] = self.agent_id
|
|
541
|
+
try:
|
|
542
|
+
client = self._get_client()
|
|
543
|
+
res = await client.post(
|
|
544
|
+
self.feedback_endpoint,
|
|
545
|
+
headers=self._auth_headers(),
|
|
546
|
+
json=body,
|
|
547
|
+
timeout=5.0,
|
|
548
|
+
)
|
|
549
|
+
return res.is_success
|
|
550
|
+
except Exception:
|
|
551
|
+
return False
|
|
552
|
+
|
|
553
|
+
async def sync_conversation(
|
|
554
|
+
self,
|
|
555
|
+
channel: str,
|
|
556
|
+
customer_id: str,
|
|
557
|
+
messages: List[Dict[str, Any]],
|
|
558
|
+
agent_id: Optional[str] = None,
|
|
559
|
+
) -> None:
|
|
560
|
+
"""Push a batched conversation transcript up to CommB collector."""
|
|
561
|
+
eff_agent_id = agent_id or self.agent_id
|
|
562
|
+
payload = {
|
|
563
|
+
"type": "chat_transcript",
|
|
564
|
+
"channel": channel,
|
|
565
|
+
"customer_id": str(customer_id),
|
|
566
|
+
"messages": messages,
|
|
567
|
+
"timestamp": time.time(),
|
|
568
|
+
}
|
|
569
|
+
if eff_agent_id:
|
|
570
|
+
payload["agent_id"] = str(eff_agent_id)
|
|
571
|
+
try:
|
|
572
|
+
self._queue.put_nowait(payload)
|
|
573
|
+
except asyncio.QueueFull:
|
|
574
|
+
pass
|
|
575
|
+
|
|
576
|
+
async def ping(self) -> bool:
|
|
577
|
+
"""Check if the CommB collector host is reachable."""
|
|
578
|
+
try:
|
|
579
|
+
client = self._get_client()
|
|
580
|
+
res = await client.get(
|
|
581
|
+
f"{self.host}/api/health",
|
|
582
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
583
|
+
timeout=5.0,
|
|
584
|
+
)
|
|
585
|
+
return res.is_success
|
|
586
|
+
except Exception:
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
async def stream_chat(
|
|
590
|
+
self,
|
|
591
|
+
message: str,
|
|
592
|
+
user_id: str,
|
|
593
|
+
history: Optional[List[ChatMessage]] = None,
|
|
594
|
+
metadata: Optional[Dict[str, Any]] = None,
|
|
595
|
+
agent_id: Optional[str] = None,
|
|
596
|
+
) -> AsyncIterator[str]:
|
|
597
|
+
"""
|
|
598
|
+
Async-stream a chat response from the CommB engine via SSE.
|
|
599
|
+
|
|
600
|
+
Usage::
|
|
601
|
+
|
|
602
|
+
async for chunk in client.stream_chat("Hello!", "user_123"):
|
|
603
|
+
print(chunk, end="", flush=True)
|
|
604
|
+
"""
|
|
605
|
+
eff_agent_id = agent_id or self.agent_id
|
|
606
|
+
body: Dict[str, Any] = {
|
|
607
|
+
"message": message,
|
|
608
|
+
"user_id": user_id,
|
|
609
|
+
"history": [m.model_dump() for m in (history or [])],
|
|
610
|
+
"metadata": metadata or {},
|
|
611
|
+
}
|
|
612
|
+
if eff_agent_id:
|
|
613
|
+
body["agent_id"] = str(eff_agent_id)
|
|
614
|
+
|
|
615
|
+
client = self._get_client()
|
|
616
|
+
async with client.stream(
|
|
617
|
+
"POST",
|
|
618
|
+
self.chat_endpoint,
|
|
619
|
+
headers=self._auth_headers(),
|
|
620
|
+
json=body,
|
|
621
|
+
) as res:
|
|
622
|
+
res.raise_for_status()
|
|
623
|
+
buffer = ""
|
|
624
|
+
async for chunk in res.aiter_text():
|
|
625
|
+
buffer += chunk
|
|
626
|
+
while "\n" in buffer:
|
|
627
|
+
line, buffer = buffer.split("\n", 1)
|
|
628
|
+
line = line.strip()
|
|
629
|
+
if line.startswith("data: "):
|
|
630
|
+
data = line[6:].strip()
|
|
631
|
+
if data == "[DONE]":
|
|
632
|
+
return
|
|
633
|
+
try:
|
|
634
|
+
import json
|
|
635
|
+
parsed = json.loads(data)
|
|
636
|
+
text = (
|
|
637
|
+
parsed.get("choices", [{}])[0]
|
|
638
|
+
.get("delta", {})
|
|
639
|
+
.get("content")
|
|
640
|
+
or parsed.get("text")
|
|
641
|
+
or data
|
|
642
|
+
)
|
|
643
|
+
if text:
|
|
644
|
+
yield text
|
|
645
|
+
except Exception:
|
|
646
|
+
if data:
|
|
647
|
+
yield data
|
|
648
|
+
|
|
649
|
+
async def flush(self) -> None:
|
|
650
|
+
"""Flush all queued telemetry events immediately."""
|
|
651
|
+
events: List[Dict[str, Any]] = []
|
|
652
|
+
while not self._queue.empty():
|
|
653
|
+
try:
|
|
654
|
+
events.append(self._queue.get_nowait())
|
|
655
|
+
except asyncio.QueueEmpty:
|
|
656
|
+
break
|
|
657
|
+
if events:
|
|
658
|
+
try:
|
|
659
|
+
client = self._get_client()
|
|
660
|
+
await client.post(
|
|
661
|
+
self.events_endpoint,
|
|
662
|
+
json={"batch": events},
|
|
663
|
+
headers=self._auth_headers(),
|
|
664
|
+
)
|
|
665
|
+
except Exception:
|
|
666
|
+
pass
|
|
667
|
+
|
|
668
|
+
async def close(self) -> None:
|
|
669
|
+
await self.flush()
|
|
670
|
+
if self._client and not self._client.is_closed:
|
|
671
|
+
await self._client.aclose()
|
|
672
|
+
|
|
673
|
+
async def __aenter__(self) -> "AsyncCommBClient":
|
|
674
|
+
return self
|
|
675
|
+
|
|
676
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
677
|
+
await self.close()
|
commb_agent/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: commb-agent
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Official Python telemetry & remote-config client for CommB (Commercial Bots)
|
|
5
|
+
Project-URL: Homepage, https://commb.app
|
|
6
|
+
Project-URL: Repository, https://github.com/sannex-01/commb-agent
|
|
7
|
+
Project-URL: Issues, https://github.com/sannex-01/commb-agent/issues
|
|
8
|
+
Author-email: Sannex Tech LTD <info@sannex.ng>
|
|
9
|
+
License: MIT
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: httpx>=0.24.0
|
|
12
|
+
Requires-Dist: pydantic>=2.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-mock>=3.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# commb-agent
|
|
20
|
+
|
|
21
|
+
Official Python SDK for the **CommB platform** — connects standalone CommB bot engines to the CommB collector dashboard via telemetry tracking and remote config sync.
|
|
22
|
+
|
|
23
|
+
[](https://pypi.org/project/commb-agent)
|
|
24
|
+
[](LICENSE)
|
|
25
|
+
|
|
26
|
+
## Installation
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install commb-agent
|
|
30
|
+
# or
|
|
31
|
+
uv add commb-agent
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
CommB collector Dashboard (Supabase)
|
|
38
|
+
▲▼ commb-agent SDK
|
|
39
|
+
CommB Engine (standalone FastAPI bot)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- **CommB** calls `get_config()` to pull system prompt, knowledge docs, and catalog from CommB collector.
|
|
43
|
+
- **CommB** calls `track()` after every message/order to push telemetry back.
|
|
44
|
+
- **CommB** calls `sync_conversation()` to log 48h active session chat history to CommB collector CRM.
|
|
45
|
+
- FastAPI streaming endpoints use `stream_chat()` for SSE responses to the Telegram Mini App.
|
|
46
|
+
|
|
47
|
+
## Quick Start (Sync — for scripts & workers)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from commb_agent import CommBClient
|
|
51
|
+
|
|
52
|
+
client = CommBClient(
|
|
53
|
+
api_key="snx_bot_xxxx",
|
|
54
|
+
host="https://commb.app",
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start (Async — for FastAPI)
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from commb_agent import AsyncCommBClient
|
|
62
|
+
|
|
63
|
+
client = AsyncCommBClient(
|
|
64
|
+
api_key="snx_bot_xxxx",
|
|
65
|
+
host="https://commb.app",
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## API Reference
|
|
70
|
+
|
|
71
|
+
### `get_config()` / `await client.get_config()` — Pull config from CommB collector
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
# Sync
|
|
75
|
+
config_resp = client.get_config()
|
|
76
|
+
|
|
77
|
+
# Async
|
|
78
|
+
config_resp = await client.get_config()
|
|
79
|
+
|
|
80
|
+
print(config_resp.config.system_prompt)
|
|
81
|
+
print(config_resp.config.model_name) # "gemini-2.5-flash"
|
|
82
|
+
print(len(config_resp.knowledge_docs)) # RAG docs
|
|
83
|
+
print(len(config_resp.catalog_items)) # Product catalog
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### `track()` / `await client.track()` — Push telemetry
|
|
87
|
+
|
|
88
|
+
Non-blocking. Batches and flushes in the background. Never raises.
|
|
89
|
+
|
|
90
|
+
```python
|
|
91
|
+
# Sync (thread-safe, fire-and-forget)
|
|
92
|
+
client.track(
|
|
93
|
+
channel="telegram",
|
|
94
|
+
customer_id="tg_123456",
|
|
95
|
+
event="order_created",
|
|
96
|
+
amount=45000.0,
|
|
97
|
+
metadata={"order_id": "ORD-001"},
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Async
|
|
101
|
+
await client.track(
|
|
102
|
+
channel="whatsapp",
|
|
103
|
+
customer_id="+2348012345678",
|
|
104
|
+
event="message_received",
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `sync_conversation()` / `await client.sync_conversation()` — 48h Chat Transcript Sync
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
# Sync
|
|
112
|
+
client.sync_conversation(
|
|
113
|
+
channel="whatsapp",
|
|
114
|
+
customer_id="+2348012345678",
|
|
115
|
+
messages=[
|
|
116
|
+
{"role": "user", "content": "How much is the blue dress?"},
|
|
117
|
+
{"role": "assistant", "content": "The blue dress is ₦15,000."}
|
|
118
|
+
]
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Async
|
|
122
|
+
await client.sync_conversation(
|
|
123
|
+
channel="telegram",
|
|
124
|
+
customer_id="tg_123456",
|
|
125
|
+
messages=[
|
|
126
|
+
{"role": "user", "content": "Is shipping free?"},
|
|
127
|
+
{"role": "assistant", "content": "Yes, on orders above ₦50,000."}
|
|
128
|
+
]
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
### `stream_chat()` — Stream AI responses (SSE)
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
# Sync
|
|
137
|
+
for chunk in client.stream_chat("What dresses do you have?", user_id="user_123"):
|
|
138
|
+
print(chunk, end="", flush=True)
|
|
139
|
+
|
|
140
|
+
# Async (FastAPI SSE endpoint)
|
|
141
|
+
async for chunk in client.stream_chat("What dresses do you have?", user_id="user_123"):
|
|
142
|
+
yield f"data: {chunk}\n\n"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### `ping()` / `await client.ping()` — Health check
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
is_up = client.ping() # sync
|
|
149
|
+
is_up = await client.ping() # async
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `get_bot()` / `await client.get_bot()` — Bot identity
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
bot = client.get_bot()
|
|
156
|
+
print(bot.name) # "Elena Luxe Bot"
|
|
157
|
+
print(bot.reseller) # "Sannex Digital Agency"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## FastAPI CommB Engine Integration
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
# commb_engine/main.py
|
|
164
|
+
import os
|
|
165
|
+
from contextlib import asynccontextmanager
|
|
166
|
+
from fastapi import FastAPI
|
|
167
|
+
from fastapi.responses import StreamingResponse
|
|
168
|
+
from commb_agent import AsyncCommBClient, ChatMessage
|
|
169
|
+
|
|
170
|
+
commb = AsyncCommBClient(
|
|
171
|
+
api_key=os.environ["BOT_API_KEY"],
|
|
172
|
+
host=os.environ.get("COMMB_COLLECTOR_URL", "https://commb.app"),
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
@asynccontextmanager
|
|
176
|
+
async def lifespan(app: FastAPI):
|
|
177
|
+
# Pull config from CommB collector on startup
|
|
178
|
+
config = await commb.get_config()
|
|
179
|
+
app.state.system_prompt = config.config.system_prompt
|
|
180
|
+
app.state.knowledge_docs = config.knowledge_docs
|
|
181
|
+
app.state.catalog_items = config.catalog_items
|
|
182
|
+
yield
|
|
183
|
+
await commb.close()
|
|
184
|
+
|
|
185
|
+
app = FastAPI(lifespan=lifespan)
|
|
186
|
+
|
|
187
|
+
@app.post("/v1/chat")
|
|
188
|
+
async def chat(message: str, user_id: str):
|
|
189
|
+
async def event_stream():
|
|
190
|
+
async for chunk in commb.stream_chat(message, user_id):
|
|
191
|
+
yield f"data: {chunk}\n\n"
|
|
192
|
+
yield "data: [DONE]\n\n"
|
|
193
|
+
|
|
194
|
+
# Track the conversation event
|
|
195
|
+
await commb.track(
|
|
196
|
+
channel="telegram",
|
|
197
|
+
customer_id=user_id,
|
|
198
|
+
event="message_received",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## Context Manager (Sync)
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
with CommBClient(api_key="snx_bot_xxxx") as client:
|
|
208
|
+
config = client.get_config()
|
|
209
|
+
# ... use client
|
|
210
|
+
# auto-flushes and closes on exit
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## Context Manager (Async)
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
async with AsyncCommBClient(api_key="snx_bot_xxxx") as client:
|
|
217
|
+
config = await client.get_config()
|
|
218
|
+
# ... use client
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Environment Variables
|
|
222
|
+
|
|
223
|
+
```env
|
|
224
|
+
BOT_API_KEY=snx_bot_xxxx # From CommB collector Bot Settings
|
|
225
|
+
COMMB_COLLECTOR_URL=https://commb.app # CommB collector host
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Response Models (Pydantic v2)
|
|
229
|
+
|
|
230
|
+
All responses are typed Pydantic models:
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
from commb_agent import CommBConfigResponse, BotConfig, KnowledgeDoc, CatalogItem, BotInfo
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## License
|
|
237
|
+
|
|
238
|
+
MIT — Sannex Tech LTD
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
commb_agent/__init__.py,sha256=JHYnsPXzcFXnkf9N7pnOdi5WFYM9r9kLpOnf2kjUclU,389
|
|
2
|
+
commb_agent/client.py,sha256=B2oLTGjA4EmcM2m8YJg2-Owmy4mw4uNTdaTl_yCuK6w,23649
|
|
3
|
+
commb_agent/py.typed,sha256=TBDV9m9vjfnp9vsgTeJmpoNseoJEfHwvZaBLvLMfzz8,27
|
|
4
|
+
commb_agent-0.3.0.dist-info/METADATA,sha256=jnALV4A2vOkyrKAxYRARQAsBuCXuxtbRGy2S3XjQMxE,6139
|
|
5
|
+
commb_agent-0.3.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
6
|
+
commb_agent-0.3.0.dist-info/RECORD,,
|