chatops-sdk 1.0.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,91 @@
1
+ """chatops-sdk: convierte cualquier app Python en un bot controlable desde el Control Center."""
2
+
3
+ from .agent import BOT_TOKEN_ENV, HUB_URL_ENV, BotAgent, BotConfigurationError, backoff_delay
4
+ from .decorators import (
5
+ CommandArgumentsError,
6
+ CommandContext,
7
+ CommandDefinitionError,
8
+ CommandRegistry,
9
+ CommandResult,
10
+ RegisteredCommand,
11
+ build_command,
12
+ execute_command,
13
+ normalize_blocks,
14
+ )
15
+ from .models import (
16
+ ActionButton,
17
+ ActionsBlock,
18
+ AnyEnvelope,
19
+ AnyUIBlock,
20
+ BotDeletedEnvelope,
21
+ BotMessageEnvelope,
22
+ BotPresenceChangedEnvelope,
23
+ BotPresencePayload,
24
+ BotRegisterEnvelope,
25
+ BotRegisterPayload,
26
+ CodeLogBlock,
27
+ CommandArgSchema,
28
+ CommandInvokeEnvelope,
29
+ CommandInvokePayload,
30
+ CommandManifest,
31
+ CommandSchema,
32
+ HeaderBlock,
33
+ MarkdownBlock,
34
+ MessageAckEnvelope,
35
+ MetricItem,
36
+ MetricsBlock,
37
+ ProgressBlock,
38
+ TableBlock,
39
+ UIBlockBase,
40
+ parse_block,
41
+ parse_envelope,
42
+ )
43
+ from .ui import BlockList, UIBlock
44
+
45
+ __version__ = "1.0.0"
46
+
47
+ __all__ = [
48
+ "BOT_TOKEN_ENV",
49
+ "HUB_URL_ENV",
50
+ "ActionButton",
51
+ "ActionsBlock",
52
+ "AnyEnvelope",
53
+ "AnyUIBlock",
54
+ "BlockList",
55
+ "BotAgent",
56
+ "BotConfigurationError",
57
+ "BotDeletedEnvelope",
58
+ "BotMessageEnvelope",
59
+ "BotPresenceChangedEnvelope",
60
+ "BotPresencePayload",
61
+ "BotRegisterEnvelope",
62
+ "BotRegisterPayload",
63
+ "CodeLogBlock",
64
+ "CommandArgSchema",
65
+ "CommandArgumentsError",
66
+ "CommandContext",
67
+ "CommandDefinitionError",
68
+ "CommandInvokeEnvelope",
69
+ "CommandInvokePayload",
70
+ "CommandManifest",
71
+ "CommandRegistry",
72
+ "CommandResult",
73
+ "CommandSchema",
74
+ "HeaderBlock",
75
+ "MarkdownBlock",
76
+ "MessageAckEnvelope",
77
+ "MetricItem",
78
+ "MetricsBlock",
79
+ "ProgressBlock",
80
+ "RegisteredCommand",
81
+ "TableBlock",
82
+ "UIBlock",
83
+ "UIBlockBase",
84
+ "__version__",
85
+ "backoff_delay",
86
+ "build_command",
87
+ "execute_command",
88
+ "normalize_blocks",
89
+ "parse_block",
90
+ "parse_envelope",
91
+ ]
chatops_sdk/agent.py ADDED
@@ -0,0 +1,461 @@
1
+ """``BotAgent``: conexión WebSocket saliente al Relay Hub con reconexión automática.
2
+
3
+ Ejemplo mínimo con FastAPI::
4
+
5
+ from fastapi import FastAPI
6
+ from chatops_sdk import BotAgent, UIBlock
7
+
8
+ bot = BotAgent(name="Mi-Bot")
9
+
10
+ @bot.command(name="ping", description="Comprueba que el bot responde")
11
+ async def ping():
12
+ return UIBlock.header("🏓 pong")
13
+
14
+ app = FastAPI(lifespan=bot.lifespan)
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import json
21
+ import logging
22
+ import os
23
+ from collections import deque
24
+ from collections.abc import AsyncIterator, Callable, Sequence
25
+ from contextlib import asynccontextmanager
26
+ from typing import Any, Literal
27
+
28
+ from pydantic import ValidationError
29
+ from websockets.asyncio.client import ClientConnection, connect
30
+ from websockets.exceptions import ConnectionClosed, InvalidStatus
31
+
32
+ from .decorators import CommandRegistry, RegisteredCommand, execute_command, normalize_blocks
33
+ from .models import (
34
+ ACCESS_DENIED_CLOSE_CODES,
35
+ WS_CLOSE_BOT_DELETED,
36
+ WS_CLOSE_BOT_DISABLED,
37
+ WS_CLOSE_TOKEN_INVALID,
38
+ BotMessageEnvelope,
39
+ BotRegisterEnvelope,
40
+ BotRegisterPayload,
41
+ CommandInvokeEnvelope,
42
+ CommandSchema,
43
+ UIBlockBase,
44
+ )
45
+ from .ui import BlockList, UIBlock
46
+
47
+ HUB_URL_ENV = "CHATOPS_HUB_URL"
48
+ BOT_TOKEN_ENV = "CHATOPS_BOT_TOKEN"
49
+
50
+ RECONNECT_INITIAL_DELAY = 1.0
51
+ RECONNECT_MAX_DELAY = 30.0
52
+ #: Backoff extendido cuando el Hub deniega el acceso (token inválido/rotado o bot
53
+ #: deshabilitado): reintentar cada segundo no arregla nada y solo genera ruido.
54
+ ACCESS_DENIED_INITIAL_DELAY = 30.0
55
+ ACCESS_DENIED_MAX_DELAY = 300.0
56
+ #: Margen tras enviar BOT_REGISTER para detectar un cierre inmediato del Hub.
57
+ REGISTER_GRACE_SECONDS = 0.15
58
+
59
+ _ACCESS_DENIED_REASONS = {
60
+ WS_CLOSE_TOKEN_INVALID: "el token es inválido o ha sido rotado",
61
+ WS_CLOSE_BOT_DISABLED: "el bot está deshabilitado (pausado) en el Hub",
62
+ WS_CLOSE_BOT_DELETED: "el bot ha sido eliminado del Hub",
63
+ }
64
+
65
+
66
+ class BotConfigurationError(RuntimeError):
67
+ """Falta la URL del Hub o el token del bot."""
68
+
69
+
70
+ def backoff_delay(
71
+ attempt: int,
72
+ initial: float = RECONNECT_INITIAL_DELAY,
73
+ maximum: float = RECONNECT_MAX_DELAY,
74
+ ) -> float:
75
+ """Exponential backoff: 1s, 2s, 4s, ... con tope en ``maximum`` (30s)."""
76
+ if attempt < 0:
77
+ attempt = 0
78
+ # El exponente se acota para no desbordar con muchos reintentos.
79
+ return float(min(maximum, initial * (2 ** min(attempt, 32))))
80
+
81
+
82
+ class BotAgent:
83
+ """Agente que registra el bot en el Hub, atiende comandos y emite alertas."""
84
+
85
+ def __init__(
86
+ self,
87
+ name: str,
88
+ *,
89
+ version: str = "1.0.0",
90
+ environment: str = "production",
91
+ project: str = "",
92
+ hub_url: str | None = None,
93
+ token: str | None = None,
94
+ reconnect_initial_delay: float = RECONNECT_INITIAL_DELAY,
95
+ reconnect_max_delay: float = RECONNECT_MAX_DELAY,
96
+ access_denied_initial_delay: float = ACCESS_DENIED_INITIAL_DELAY,
97
+ access_denied_max_delay: float = ACCESS_DENIED_MAX_DELAY,
98
+ register_grace: float = REGISTER_GRACE_SECONDS,
99
+ open_timeout: float = 10.0,
100
+ outbox_size: int = 200,
101
+ logger: logging.Logger | None = None,
102
+ ) -> None:
103
+ self.name = name
104
+ self.version = version
105
+ self.environment = environment
106
+ self.project = project
107
+ self._hub_url = hub_url
108
+ self._token = token
109
+ self.reconnect_initial_delay = reconnect_initial_delay
110
+ self.reconnect_max_delay = reconnect_max_delay
111
+ self.access_denied_initial_delay = access_denied_initial_delay
112
+ self.access_denied_max_delay = access_denied_max_delay
113
+ self.register_grace = register_grace
114
+ self.open_timeout = open_timeout
115
+ self.log = logger or logging.getLogger(f"chatops_sdk.{name}")
116
+
117
+ self.registry = CommandRegistry()
118
+ self._ws: ClientConnection | None = None
119
+ self._task: asyncio.Task[None] | None = None
120
+ self._command_tasks: set[asyncio.Task[None]] = set()
121
+ self._stopped = asyncio.Event()
122
+ self._connected = asyncio.Event()
123
+ self._send_lock = asyncio.Lock()
124
+ # Mensajes finales y alertas emitidos sin conexión: se envían al reconectar.
125
+ self._outbox: deque[BotMessageEnvelope] = deque(maxlen=outbox_size)
126
+ self.connection_attempts = 0
127
+ #: Veces seguidas que el Hub ha denegado el acceso (se reinicia al conectar).
128
+ self.access_denied_count = 0
129
+ #: Código de cierre WebSocket de la última conexión, si lo hubo.
130
+ self.last_close_code: int | None = None
131
+
132
+ # ------------------------------------------------------------------ #
133
+ # Configuración
134
+ # ------------------------------------------------------------------ #
135
+
136
+ @property
137
+ def hub_url(self) -> str | None:
138
+ """URL del endpoint ``/ws/bot``; por defecto ``CHATOPS_HUB_URL``."""
139
+ return self._hub_url or os.environ.get(HUB_URL_ENV) or None
140
+
141
+ @property
142
+ def token(self) -> str | None:
143
+ """Token ``bot_<id>:<secret>``; por defecto ``CHATOPS_BOT_TOKEN``."""
144
+ return self._token or os.environ.get(BOT_TOKEN_ENV) or None
145
+
146
+ @property
147
+ def bot_id(self) -> str | None:
148
+ """``bot_id`` derivado del token (la parte anterior a ``:``)."""
149
+ token = self.token
150
+ if not token or ":" not in token:
151
+ return None
152
+ return token.split(":", 1)[0]
153
+
154
+ @property
155
+ def is_connected(self) -> bool:
156
+ return self._connected.is_set()
157
+
158
+ @property
159
+ def is_running(self) -> bool:
160
+ return self._task is not None and not self._task.done()
161
+
162
+ # ------------------------------------------------------------------ #
163
+ # Registro de comandos
164
+ # ------------------------------------------------------------------ #
165
+
166
+ def command(
167
+ self,
168
+ name: str | Callable[..., Any] | None = None,
169
+ description: str | None = None,
170
+ ) -> Any:
171
+ """Decorador ``@bot.command(name="...", description="...")``."""
172
+ return self.registry.command(name, description)
173
+
174
+ def add_command(
175
+ self,
176
+ func: Callable[..., Any],
177
+ name: str | None = None,
178
+ description: str | None = None,
179
+ ) -> RegisteredCommand:
180
+ """Registro imperativo equivalente al decorador."""
181
+ return self.registry.register(func, name=name, description=description)
182
+
183
+ @property
184
+ def manifest(self) -> list[CommandSchema]:
185
+ return self.registry.manifest()
186
+
187
+ def build_register_envelope(self) -> BotRegisterEnvelope:
188
+ return BotRegisterEnvelope(
189
+ payload=BotRegisterPayload(
190
+ bot_name=self.name,
191
+ version=self.version,
192
+ environment=self.environment,
193
+ project=self.project,
194
+ commands=self.manifest,
195
+ )
196
+ )
197
+
198
+ # ------------------------------------------------------------------ #
199
+ # Ciclo de vida
200
+ # ------------------------------------------------------------------ #
201
+
202
+ async def start_background(self) -> asyncio.Task[None]:
203
+ """Lanza el bucle de conexión como ``asyncio.Task`` y vuelve de inmediato."""
204
+ if self.is_running:
205
+ assert self._task is not None
206
+ return self._task
207
+ if not self.hub_url:
208
+ raise BotConfigurationError(
209
+ f"Falta la URL del Hub: pásala como hub_url o define {HUB_URL_ENV}"
210
+ )
211
+ if not self.token:
212
+ raise BotConfigurationError(
213
+ f"Falta el token del bot: pásalo como token o define {BOT_TOKEN_ENV}"
214
+ )
215
+ self._stopped.clear()
216
+ self._task = asyncio.create_task(self.run_forever(), name=f"chatops-bot-{self.name}")
217
+ return self._task
218
+
219
+ async def stop(self) -> None:
220
+ """Detiene el bucle, cierra el socket y cancela comandos en curso."""
221
+ self._stopped.set()
222
+ ws = self._ws
223
+ if ws is not None:
224
+ try:
225
+ await ws.close()
226
+ except Exception: # noqa: BLE001 - el cierre es best-effort
227
+ self.log.debug("Error cerrando el WebSocket", exc_info=True)
228
+
229
+ pending = [task for task in self._command_tasks if not task.done()]
230
+ for task in pending:
231
+ task.cancel()
232
+ if pending:
233
+ await asyncio.gather(*pending, return_exceptions=True)
234
+
235
+ task, self._task = self._task, None
236
+ if task is not None:
237
+ if not task.done():
238
+ task.cancel()
239
+ await asyncio.gather(task, return_exceptions=True)
240
+ self._connected.clear()
241
+
242
+ @asynccontextmanager
243
+ async def lifespan(self, app: Any = None) -> AsyncIterator[None]:
244
+ """Lifespan listo para FastAPI: ``FastAPI(lifespan=bot.lifespan)``."""
245
+ await self.start_background()
246
+ try:
247
+ yield
248
+ finally:
249
+ await self.stop()
250
+
251
+ async def wait_until_connected(self, timeout: float | None = None) -> bool:
252
+ """Espera a que el bot esté registrado en el Hub. ``False`` si vence el timeout."""
253
+ try:
254
+ await asyncio.wait_for(self._connected.wait(), timeout)
255
+ except asyncio.TimeoutError:
256
+ return False
257
+ return True
258
+
259
+ async def run_forever(self) -> None:
260
+ """Bucle ``while not stopped``: conecta, registra, escucha y reconecta con backoff.
261
+
262
+ Las caídas normales se reintentan con backoff exponencial (1s, 2s, 4s… máx. 30s).
263
+ Si el Hub deniega el acceso (cierre ``4001`` token inválido/rotado, ``4003`` bot
264
+ deshabilitado, o rechazo HTTP 401/403 del handshake) se registra un log CRITICAL
265
+ y se aplica un backoff extendido: el bot volverá solo cuando lo rehabiliten.
266
+ """
267
+ attempt = 0
268
+ while not self._stopped.is_set():
269
+ hub_url, token = self.hub_url, self.token
270
+ if not hub_url or not token:
271
+ raise BotConfigurationError("El bot no tiene hub_url o token configurados")
272
+ self.connection_attempts += 1
273
+ close_code: int | None = None
274
+ denied = False
275
+ try:
276
+ async with connect(
277
+ hub_url,
278
+ additional_headers={"Authorization": f"Bearer {token}"},
279
+ open_timeout=self.open_timeout,
280
+ ) as ws:
281
+ await ws.send(self.build_register_envelope().model_dump_json())
282
+ if await self._closed_right_after_register(ws):
283
+ close_code = ws.close_code
284
+ else:
285
+ self._ws = ws
286
+ self._connected.set()
287
+ attempt = 0
288
+ self.access_denied_count = 0
289
+ self.log.info("Bot '%s' registrado en el Hub %s", self.name, hub_url)
290
+ await self._flush_outbox()
291
+ async for raw in ws:
292
+ self._on_frame(raw)
293
+ close_code = ws.close_code
294
+ except asyncio.CancelledError:
295
+ raise
296
+ except InvalidStatus as exc:
297
+ status = exc.response.status_code
298
+ denied = status in (401, 403)
299
+ if not denied:
300
+ self.log.error("El Hub rechazó la conexión (HTTP %s)", status)
301
+ except ConnectionClosed as exc:
302
+ close_code = exc.rcvd.code if exc.rcvd is not None else None
303
+ if close_code not in ACCESS_DENIED_CLOSE_CODES:
304
+ self.log.warning("Conexión con el Hub cerrada: %s", exc)
305
+ except (OSError, asyncio.TimeoutError) as exc:
306
+ self.log.warning("No se pudo conectar al Hub %s: %s", hub_url, exc)
307
+ except Exception: # noqa: BLE001 - el bucle debe sobrevivir a cualquier fallo
308
+ self.log.exception("Error inesperado en la conexión con el Hub")
309
+ finally:
310
+ self._ws = None
311
+ self._connected.clear()
312
+
313
+ self.last_close_code = close_code
314
+ if self._stopped.is_set():
315
+ break
316
+
317
+ if denied or close_code in ACCESS_DENIED_CLOSE_CODES:
318
+ reason = _ACCESS_DENIED_REASONS.get(
319
+ close_code or 0, "el Hub rechazó las credenciales"
320
+ )
321
+ delay = backoff_delay(
322
+ self.access_denied_count,
323
+ self.access_denied_initial_delay,
324
+ self.access_denied_max_delay,
325
+ )
326
+ self.access_denied_count += 1
327
+ self.log.critical(
328
+ "CRITICAL: Bot token invalid or bot disabled in Hub (%s; código %s). "
329
+ "Revisa %s o el estado del bot en el Control Center. Reintento en %.0fs",
330
+ reason,
331
+ close_code if close_code is not None else "HTTP 401/403",
332
+ BOT_TOKEN_ENV,
333
+ delay,
334
+ )
335
+ else:
336
+ delay = backoff_delay(
337
+ attempt, self.reconnect_initial_delay, self.reconnect_max_delay
338
+ )
339
+ attempt += 1
340
+ self.log.info("Reintentando conexión en %.1fs", delay)
341
+ try:
342
+ await asyncio.wait_for(self._stopped.wait(), timeout=delay)
343
+ except asyncio.TimeoutError:
344
+ pass
345
+
346
+ async def _closed_right_after_register(self, ws: ClientConnection) -> bool:
347
+ """``True`` si el Hub cierra el socket nada más abrirlo.
348
+
349
+ El Hub deniega el acceso aceptando el WebSocket y cerrándolo con un código
350
+ (4001/4003). Como el protocolo no tiene acuse de BOT_REGISTER, se espera un
351
+ instante antes de dar el bot por conectado.
352
+ """
353
+ if self.register_grace <= 0:
354
+ return False
355
+ try:
356
+ await asyncio.wait_for(ws.wait_closed(), timeout=self.register_grace)
357
+ except asyncio.TimeoutError:
358
+ return False
359
+ return True
360
+
361
+ # ------------------------------------------------------------------ #
362
+ # Envío
363
+ # ------------------------------------------------------------------ #
364
+
365
+ async def send_bot_message(self, message: BotMessageEnvelope) -> bool:
366
+ """Envía un ``BOT_MESSAGE``. Devuelve ``True`` si salió por el socket.
367
+
368
+ Sin conexión, los mensajes finales (``COMPLETED``/``ERROR``) y las alertas
369
+ se encolan y se entregan al reconectar; el progreso intermedio se descarta.
370
+ """
371
+ if message.source_bot_id is None:
372
+ message.source_bot_id = self.bot_id
373
+ ws = self._ws
374
+ if ws is not None:
375
+ try:
376
+ async with self._send_lock:
377
+ await ws.send(message.model_dump_json())
378
+ return True
379
+ except ConnectionClosed:
380
+ self.log.debug("Socket cerrado al enviar %s", message.message_id)
381
+ if message.is_final:
382
+ self._outbox.append(message)
383
+ return False
384
+
385
+ async def _flush_outbox(self) -> None:
386
+ while self._outbox and self._ws is not None:
387
+ message = self._outbox.popleft()
388
+ try:
389
+ async with self._send_lock:
390
+ await self._ws.send(message.model_dump_json())
391
+ except ConnectionClosed:
392
+ self._outbox.appendleft(message)
393
+ raise
394
+
395
+ async def send_alert(
396
+ self,
397
+ severity: Literal["info", "warning", "critical"],
398
+ title: str,
399
+ message: str,
400
+ blocks: Sequence[UIBlockBase] | BlockList | None = None,
401
+ ) -> BotMessageEnvelope:
402
+ """Envía una alerta proactiva: ``BOT_MESSAGE`` sin ``correlation_id``."""
403
+ envelope = BotMessageEnvelope(
404
+ correlation_id=None,
405
+ source_bot_id=self.bot_id,
406
+ status="COMPLETED",
407
+ severity=severity,
408
+ blocks=[
409
+ UIBlock.header(title),
410
+ UIBlock.markdown(message),
411
+ *normalize_blocks(blocks),
412
+ ],
413
+ )
414
+ await self.send_bot_message(envelope)
415
+ return envelope
416
+
417
+ # ------------------------------------------------------------------ #
418
+ # Recepción y ejecución de comandos
419
+ # ------------------------------------------------------------------ #
420
+
421
+ def _on_frame(self, raw: str | bytes) -> None:
422
+ try:
423
+ data = json.loads(raw)
424
+ except (TypeError, ValueError):
425
+ self.log.warning("Trama no JSON recibida del Hub; se ignora")
426
+ return
427
+ if not isinstance(data, dict):
428
+ self.log.warning("Trama inesperada recibida del Hub; se ignora")
429
+ return
430
+ frame_type = data.get("type")
431
+ if frame_type != "COMMAND_INVOKE":
432
+ self.log.debug("Trama '%s' ignorada por el bot", frame_type)
433
+ return
434
+ try:
435
+ envelope = CommandInvokeEnvelope.model_validate(data)
436
+ except ValidationError as exc:
437
+ self.log.warning("COMMAND_INVOKE inválido: %s", exc)
438
+ return
439
+ # Cada comando corre en su propia tarea para no bloquear la recepción.
440
+ task = asyncio.create_task(
441
+ self._process_invoke(envelope), name=f"chatops-cmd-{envelope.payload.command}"
442
+ )
443
+ self._command_tasks.add(task)
444
+ task.add_done_callback(self._command_tasks.discard)
445
+
446
+ async def _process_invoke(self, envelope: CommandInvokeEnvelope) -> None:
447
+ message = await self.handle_invoke(envelope)
448
+ await self.send_bot_message(message)
449
+
450
+ async def handle_invoke(self, envelope: CommandInvokeEnvelope) -> BotMessageEnvelope:
451
+ """Valida argumentos, ejecuta el comando y devuelve el ``BOT_MESSAGE`` final."""
452
+ return await execute_command(self.registry, envelope, self, bot_id=self.bot_id)
453
+
454
+
455
+ __all__ = [
456
+ "BOT_TOKEN_ENV",
457
+ "HUB_URL_ENV",
458
+ "BotAgent",
459
+ "BotConfigurationError",
460
+ "backoff_delay",
461
+ ]