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.
@@ -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
+ [![PyPI version](https://img.shields.io/pypi/v/commb-agent)](https://pypi.org/project/commb-agent)
24
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any