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.
- chatops_sdk/__init__.py +91 -0
- chatops_sdk/agent.py +461 -0
- chatops_sdk/decorators.py +590 -0
- chatops_sdk/models.py +360 -0
- chatops_sdk/py.typed +0 -0
- chatops_sdk/ui.py +191 -0
- chatops_sdk-1.0.0.dist-info/METADATA +79 -0
- chatops_sdk-1.0.0.dist-info/RECORD +11 -0
- chatops_sdk-1.0.0.dist-info/WHEEL +5 -0
- chatops_sdk-1.0.0.dist-info/licenses/LICENSE +21 -0
- chatops_sdk-1.0.0.dist-info/top_level.txt +1 -0
chatops_sdk/__init__.py
ADDED
|
@@ -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
|
+
]
|