telnyx-mcp-server-fastmcp 0.1.3__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.
Files changed (40) hide show
  1. telnyx_mcp_server/__init__.py +0 -0
  2. telnyx_mcp_server/__main__.py +23 -0
  3. telnyx_mcp_server/config.py +148 -0
  4. telnyx_mcp_server/mcp.py +148 -0
  5. telnyx_mcp_server/server.py +497 -0
  6. telnyx_mcp_server/telnyx/__init__.py +1 -0
  7. telnyx_mcp_server/telnyx/client.py +363 -0
  8. telnyx_mcp_server/telnyx/services/__init__.py +0 -0
  9. telnyx_mcp_server/telnyx/services/assistants.py +155 -0
  10. telnyx_mcp_server/telnyx/services/call_control.py +217 -0
  11. telnyx_mcp_server/telnyx/services/cloud_storage.py +289 -0
  12. telnyx_mcp_server/telnyx/services/connections.py +92 -0
  13. telnyx_mcp_server/telnyx/services/embeddings.py +52 -0
  14. telnyx_mcp_server/telnyx/services/messaging.py +93 -0
  15. telnyx_mcp_server/telnyx/services/messaging_profiles.py +196 -0
  16. telnyx_mcp_server/telnyx/services/numbers.py +193 -0
  17. telnyx_mcp_server/telnyx/services/secrets.py +74 -0
  18. telnyx_mcp_server/tools/__init__.py +126 -0
  19. telnyx_mcp_server/tools/assistants.py +313 -0
  20. telnyx_mcp_server/tools/call_control.py +242 -0
  21. telnyx_mcp_server/tools/cloud_storage.py +183 -0
  22. telnyx_mcp_server/tools/connections.py +78 -0
  23. telnyx_mcp_server/tools/embeddings.py +80 -0
  24. telnyx_mcp_server/tools/messaging.py +57 -0
  25. telnyx_mcp_server/tools/messaging_profiles.py +123 -0
  26. telnyx_mcp_server/tools/phone_numbers.py +161 -0
  27. telnyx_mcp_server/tools/secrets.py +75 -0
  28. telnyx_mcp_server/tools/sms_conversations.py +455 -0
  29. telnyx_mcp_server/tools/webhooks.py +111 -0
  30. telnyx_mcp_server/utils/__init__.py +0 -0
  31. telnyx_mcp_server/utils/error_handler.py +30 -0
  32. telnyx_mcp_server/utils/logger.py +32 -0
  33. telnyx_mcp_server/utils/service.py +33 -0
  34. telnyx_mcp_server/webhook/__init__.py +25 -0
  35. telnyx_mcp_server/webhook/handler.py +596 -0
  36. telnyx_mcp_server/webhook/server.py +369 -0
  37. telnyx_mcp_server_fastmcp-0.1.3.dist-info/METADATA +430 -0
  38. telnyx_mcp_server_fastmcp-0.1.3.dist-info/RECORD +40 -0
  39. telnyx_mcp_server_fastmcp-0.1.3.dist-info/WHEEL +4 -0
  40. telnyx_mcp_server_fastmcp-0.1.3.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,32 @@
1
+ """Logging utilities."""
2
+
3
+ import logging
4
+ import sys
5
+ from typing import Optional
6
+
7
+ from ..config import settings
8
+
9
+
10
+ def get_logger(name: Optional[str] = None) -> logging.Logger:
11
+ """Get a logger.
12
+
13
+ Args:
14
+ name: Logger name
15
+
16
+ Returns:
17
+ logging.Logger: Logger
18
+ """
19
+ logger = logging.getLogger(name)
20
+ logger.setLevel(getattr(logging, settings.log_level))
21
+
22
+ # Add console handler if not already added
23
+ if not logger.handlers:
24
+ handler = logging.StreamHandler(sys.stderr)
25
+ handler.setLevel(getattr(logging, settings.log_level))
26
+ formatter = logging.Formatter(
27
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
28
+ )
29
+ handler.setFormatter(formatter)
30
+ logger.addHandler(handler)
31
+
32
+ return logger
@@ -0,0 +1,33 @@
1
+ """Service utilities."""
2
+
3
+ from typing import Any, Type
4
+
5
+ from .logger import get_logger
6
+
7
+ logger = get_logger(__name__)
8
+
9
+
10
+ def get_authenticated_service(service_cls: Type[Any]) -> Any:
11
+ """Get an authenticated service using the API key from environment.
12
+
13
+ Args:
14
+ service_cls: Service class to instantiate
15
+
16
+ Returns:
17
+ Service instance with authenticated client
18
+ """
19
+ logger.info(f"Getting authenticated service for {service_cls.__name__}")
20
+
21
+ # Use the client from mcp.py that's already initialized with API key
22
+ from ..mcp import telnyx_client
23
+
24
+ # Log masked API key at debug level
25
+ if hasattr(telnyx_client, "api_key") and telnyx_client.api_key:
26
+ masked_key = (
27
+ f"{telnyx_client.api_key[:5]}..."
28
+ if len(telnyx_client.api_key) > 5
29
+ else "[REDACTED]"
30
+ )
31
+ logger.debug(f"Using client with API key: {masked_key}")
32
+
33
+ return service_cls(telnyx_client)
@@ -0,0 +1,25 @@
1
+ """Webhook receiver module for Telnyx MCP server."""
2
+
3
+ from .handler import NgrokTunnelHandler
4
+ from .server import get_webhook_history
5
+
6
+ # Create a single shared instance
7
+ webhook_handler = NgrokTunnelHandler()
8
+
9
+
10
+ def start_webhook_handler() -> str:
11
+ """Start the webhook tunnel handler and return the public URL."""
12
+ return webhook_handler.start()
13
+
14
+
15
+ def stop_webhook_handler() -> None:
16
+ """Stop the webhook tunnel handler."""
17
+ webhook_handler.stop()
18
+
19
+
20
+ __all__ = [
21
+ "start_webhook_handler",
22
+ "stop_webhook_handler",
23
+ "get_webhook_history",
24
+ "webhook_handler",
25
+ ]
@@ -0,0 +1,596 @@
1
+ """Direct ngrok tunnel handler using Unix domain sockets."""
2
+
3
+ from datetime import datetime
4
+ import sys
5
+ import threading
6
+ import time
7
+ from typing import Optional
8
+
9
+ try:
10
+ import ngrok
11
+ except ImportError as e:
12
+ raise ImportError(
13
+ f"Failed to import ngrok. Please install it with 'pip install ngrok>=0.9.0'. Error: {e}"
14
+ )
15
+
16
+ from ..config import settings
17
+ from ..utils.logger import get_logger
18
+ from .server import (
19
+ start_webhook_server,
20
+ stop_webhook_server,
21
+ )
22
+
23
+ logger = get_logger(__name__)
24
+
25
+
26
+ class NgrokTunnelHandler:
27
+ """
28
+ Ngrok tunnel handler using the official Python SDK without binary dependencies.
29
+
30
+ This class creates an ngrok tunnel directly using the ngrok-python SDK
31
+ connecting to a Unix domain socket, avoiding port conflicts.
32
+ """
33
+
34
+ def __init__(self):
35
+ """Initialize the tunnel handler."""
36
+ self.listener = None
37
+ self.public_url = None
38
+ self._reconnect_thread = None
39
+ self._running = False
40
+ self.socket_path = None
41
+ self._tunnel_was_established = (
42
+ False # Track if a tunnel was ever successfully established
43
+ )
44
+
45
+ # Clean up internal SDK resources only (not processes)
46
+ self._cleanup_sdk_resources()
47
+
48
+ def _cleanup_sdk_resources(self):
49
+ """Thoroughly clean up ngrok SDK resources without affecting external processes."""
50
+ logger.info("Performing thorough ngrok SDK resources cleanup...")
51
+
52
+ try:
53
+ # Use the Python API to clean up resources
54
+ import ngrok
55
+
56
+ try:
57
+ # Multiple disconnect attempts with different approaches
58
+ try:
59
+ # First generic disconnect
60
+ ngrok.disconnect()
61
+ time.sleep(0.5)
62
+
63
+ # Then try to list and disconnect any tunnels specifically
64
+ if hasattr(ngrok, "list_tunnels"):
65
+ tunnels = ngrok.list_tunnels()
66
+ for tunnel in tunnels:
67
+ try:
68
+ if hasattr(tunnel, "url"):
69
+ ngrok.disconnect(tunnel.url())
70
+ elif hasattr(tunnel, "public_url"):
71
+ ngrok.disconnect(tunnel.public_url)
72
+ except Exception:
73
+ pass
74
+
75
+ logger.info("Reset all ngrok connections via SDK")
76
+ except Exception as e:
77
+ logger.warning(f"Failed to reset connections: {e}")
78
+
79
+ # Comprehensive module state cleanup
80
+ try:
81
+ # Clear anything that might store state
82
+ attrs_to_clear = [
83
+ "_tunnels",
84
+ "_listeners",
85
+ "_sessions",
86
+ "_endpoints",
87
+ "_configs",
88
+ "_connections",
89
+ ]
90
+
91
+ for attr in attrs_to_clear:
92
+ if hasattr(ngrok, attr):
93
+ if isinstance(getattr(ngrok, attr), dict):
94
+ setattr(ngrok, attr, {})
95
+ elif isinstance(getattr(ngrok, attr), list):
96
+ setattr(ngrok, attr, [])
97
+
98
+ # Reset API client if possible
99
+ if hasattr(ngrok, "api") and hasattr(ngrok.api, "_client"):
100
+ ngrok.api._client = None
101
+
102
+ logger.info("Reset all ngrok internal module state")
103
+ except Exception:
104
+ pass
105
+ except Exception as e:
106
+ logger.warning(f"Failed during ngrok reset: {e}")
107
+
108
+ except Exception as e:
109
+ logger.warning(f"Error during ngrok SDK cleanup: {e}")
110
+
111
+ # Immediately try to patch NGrok to ignore domain settings
112
+ try:
113
+ import sys
114
+
115
+ import ngrok
116
+
117
+ # Try to force dynamic domains by monkey patching
118
+ if hasattr(ngrok, "Config"):
119
+ original_init = ngrok.Config.__init__
120
+
121
+ # Define a patched init that ignores domain
122
+ def patched_init(self, *args, **kwargs):
123
+ # Force domain to None before calling original
124
+ if "domain" in kwargs:
125
+ kwargs["domain"] = None
126
+ return original_init(self, *args, **kwargs)
127
+
128
+ # Apply the patch
129
+ ngrok.Config.__init__ = patched_init
130
+ logger.info(
131
+ "Successfully patched NGrok Config to force dynamic domains"
132
+ )
133
+ else:
134
+ logger.info(
135
+ "NGrok Config not found, could not apply domain patch"
136
+ )
137
+
138
+ # Also try to delete any module-level domain setting
139
+ ngrok_modules = [m for m in sys.modules if m.startswith("ngrok")]
140
+ for module_name in ngrok_modules:
141
+ module = sys.modules[module_name]
142
+ if hasattr(module, "domain"):
143
+ setattr(module, "domain", None)
144
+ logger.info(f"Reset domain in module {module_name}")
145
+ except Exception as patch_err:
146
+ logger.warning(f"Error patching NGrok (non-fatal): {patch_err}")
147
+
148
+ def start(self) -> Optional[str]:
149
+ """
150
+ Start the ngrok tunnel.
151
+
152
+ Returns:
153
+ Optional[str]: The public URL for webhooks or None if startup fails
154
+
155
+ Raises:
156
+ SystemExit: If webhook_enabled is true but the tunnel cannot be created
157
+ """
158
+ if not settings.webhook_enabled:
159
+ logger.info("Webhook handler is disabled")
160
+ return None
161
+
162
+ if not settings.ngrok_enabled:
163
+ # If webhooks are enabled but ngrok is disabled, this is a fatal configuration error
164
+ logger.critical(
165
+ "FATAL ERROR: Webhook handler is enabled but NGrok is disabled"
166
+ )
167
+ logger.critical(
168
+ "Webhooks cannot function without NGrok - exiting process"
169
+ )
170
+ sys.exit(1) # Exit with error status code
171
+ return None # This will never execute
172
+
173
+ # Add authtoken if available
174
+ if settings.ngrok_authtoken:
175
+ # Set for all connections
176
+ try:
177
+ ngrok.set_auth_token(settings.ngrok_authtoken)
178
+ logger.info("Using ngrok authentication token")
179
+ except Exception as e:
180
+ logger.error(f"Failed to set ngrok authentication token: {e}")
181
+ return None
182
+ else:
183
+ logger.warning(
184
+ "No ngrok authentication token provided - tunnel may fail"
185
+ )
186
+
187
+ # First start the Unix socket server
188
+ self.socket_path = start_webhook_server()
189
+ if not self.socket_path:
190
+ logger.error(
191
+ "Failed to start webhook server. Cannot create ngrok tunnel."
192
+ )
193
+ return None
194
+
195
+ # CRITICAL SAFETY CHECK: Force use of dynamic NGrok URLs
196
+ # Ignore any custom domain settings to avoid TLS termination errors
197
+ # Directly set ngrok_url to None in settings object to override any value
198
+ if hasattr(settings, "ngrok_url") and settings.ngrok_url is not None:
199
+ logger.warning(f"Detected custom domain: {settings.ngrok_url}")
200
+ logger.warning("OVERRIDING to force dynamic NGrok URL")
201
+ # Hard override the setting at runtime
202
+ try:
203
+ # This is a hack but necessary to force dynamic domains
204
+ object.__setattr__(settings, "ngrok_url", None)
205
+ logger.info("Successfully forced ngrok_url to None")
206
+ except Exception as e:
207
+ logger.warning(f"Could not override settings.ngrok_url: {e}")
208
+
209
+ # Even if the above fails, still log that we're forcing dynamic domains
210
+ logger.info("Forcing dynamic domain assignment for NGrok")
211
+
212
+ try:
213
+ # Aggressive cleanup of any existing ngrok resources (our own only)
214
+ try:
215
+ logger.info(
216
+ "Aggressively cleaning up existing ngrok SDK resources..."
217
+ )
218
+
219
+ # Multiple disconnect attempts to be thorough
220
+ try:
221
+ ngrok.disconnect()
222
+ time.sleep(0.5)
223
+ # Try again to be sure
224
+ ngrok.disconnect()
225
+ time.sleep(0.5)
226
+ except Exception:
227
+ pass
228
+
229
+ # Clean up internal SDK state more thoroughly
230
+ try:
231
+ # Reset any module-level state
232
+ import importlib
233
+
234
+ ngrok_module = importlib.import_module("ngrok")
235
+
236
+ # Clear any cached tunnels
237
+ if hasattr(ngrok_module, "_tunnels"):
238
+ ngrok_module._tunnels = {}
239
+ if hasattr(ngrok_module, "_listeners"):
240
+ ngrok_module._listeners = {}
241
+ if hasattr(ngrok_module, "api"):
242
+ # Reset API client if possible
243
+ ngrok_module.api._client = None
244
+
245
+ logger.info("Reset ngrok module internal state completely")
246
+ except Exception as reset_err:
247
+ logger.warning(
248
+ f"Could not fully reset ngrok module state: {reset_err}"
249
+ )
250
+
251
+ # Wait a bit to let any previous sessions expire
252
+ time.sleep(1)
253
+ except Exception as cleanup_err:
254
+ logger.warning(
255
+ f"Failed during aggressive ngrok cleanup: {cleanup_err}"
256
+ )
257
+
258
+ # Prepare the Unix socket address
259
+ unix_sock_addr = f"unix:{self.socket_path}"
260
+ logger.info(f"Forwarding to Unix socket: {unix_sock_addr}")
261
+
262
+ # More aggressive options to force dynamic domain - direct approach
263
+ options = {
264
+ "authtoken_from_env": bool(settings.ngrok_authtoken),
265
+ "metadata": "Telnyx MCP Webhook Handler (Direct)",
266
+ "verify_webhook_provider": "", # Disable webhook verification
267
+ "app_protocol": "http1", # Explicitly use HTTP/1.1
268
+ "domain": None, # Explicitly set domain to None to force dynamic assignment
269
+ }
270
+
271
+ # Force override any custom domain settings from environment or cache
272
+ if hasattr(ngrok, "set_config"):
273
+ try:
274
+ ngrok.set_config(domain=None)
275
+ logger.info(
276
+ "Explicitly disabled custom domain in NGrok configuration"
277
+ )
278
+ except Exception as config_err:
279
+ logger.warning(
280
+ f"Could not override NGrok domain config: {config_err}"
281
+ )
282
+
283
+ # Create the tunnel - use the most direct approach possible
284
+ logger.info(
285
+ f"Creating ngrok tunnel with direct approach and options: {options}"
286
+ )
287
+
288
+ # Instead of using the Session API which is causing TLS termination issues,
289
+ # Use a simplified direct approach without any custom domains or TLS options
290
+ try:
291
+ # Generate an extremely simplified set of options - remove TLS-related options
292
+ simplified_options = {
293
+ "authtoken_from_env": bool(settings.ngrok_authtoken),
294
+ "metadata": "Telnyx MCP Webhook Handler (Simplified)",
295
+ }
296
+
297
+ # Skip the Session API approach entirely and use the basic forward function
298
+ logger.info(
299
+ f"Creating NGrok tunnel with simplified options: {simplified_options}"
300
+ )
301
+ self.listener = ngrok.forward(
302
+ unix_sock_addr, **simplified_options
303
+ )
304
+ logger.info(
305
+ "Created NGrok tunnel using simplified forward method"
306
+ )
307
+ except Exception as module_err:
308
+ # Final fallback with minimal options and different syntax
309
+ logger.warning(
310
+ f"First approach failed: {module_err}, trying minimal fallback"
311
+ )
312
+ # Try with the absolute minimum options possible
313
+ minimal_options = {
314
+ "authtoken_from_env": bool(settings.ngrok_authtoken),
315
+ }
316
+ logger.info(
317
+ f"Creating NGrok tunnel with minimal options: {minimal_options}"
318
+ )
319
+ self.listener = ngrok.forward(
320
+ unix_sock_addr, **minimal_options
321
+ )
322
+ logger.info(
323
+ "Created NGrok tunnel using minimal options approach"
324
+ )
325
+
326
+ # Get the public URL
327
+ self.public_url = self.listener.url()
328
+
329
+ # Mark that a tunnel was successfully established
330
+ self._tunnel_was_established = True
331
+
332
+ # Start reconnection monitor
333
+ self._running = True
334
+ self._start_reconnect_thread()
335
+
336
+ logger.info(
337
+ f"Ngrok tunnel established with dynamic URL: {self.public_url}"
338
+ )
339
+ logger.info(
340
+ f"Webhook endpoint available at: {self.public_url}{settings.webhook_path}"
341
+ )
342
+
343
+ # Add a webhook to history to show it's working
344
+ self._add_to_history(
345
+ {
346
+ "event_type": "ngrok.tunnel.started",
347
+ "timestamp": datetime.now().isoformat(),
348
+ "url": self.public_url,
349
+ "webhook_endpoint": f"{self.public_url}{settings.webhook_path}",
350
+ "dynamic_url": True,
351
+ }
352
+ )
353
+
354
+ return self.public_url
355
+ except Exception as e:
356
+ error_message = str(e)
357
+ logger.error(f"Failed to start ngrok tunnel: {error_message}")
358
+
359
+ # If session limit error, provide guidance but don't kill processes
360
+ if "ERR_NGROK_108" in error_message:
361
+ logger.error(
362
+ "Detected NGrok session limit error - cannot create a new tunnel"
363
+ )
364
+ logger.error(
365
+ "There are other NGrok sessions running that need to be closed"
366
+ )
367
+ logger.error(
368
+ "Manual intervention required: check https://dashboard.ngrok.com/agents"
369
+ )
370
+
371
+ # Record the error in webhook history
372
+ self._add_to_history(
373
+ {
374
+ "event_type": "ngrok.error",
375
+ "timestamp": datetime.now().isoformat(),
376
+ "error": error_message,
377
+ "error_type": "tls_error"
378
+ if "tls" in error_message.lower()
379
+ or "missing key" in error_message.lower()
380
+ else "session_limit"
381
+ if "ERR_NGROK_108" in error_message
382
+ else "unknown",
383
+ }
384
+ )
385
+
386
+ # For any error, just log it and exit with a clear error message
387
+ # Check for the specific ERR_NGROK_108 error (session limit)
388
+ if "ERR_NGROK_108" in error_message:
389
+ logger.critical(
390
+ f"FATAL ERROR: NGrok session limit exceeded: {error_message}"
391
+ )
392
+ logger.critical(
393
+ "You currently have other NGrok sessions running. To fix this issue:"
394
+ )
395
+ logger.critical(
396
+ "1. Go to https://dashboard.ngrok.com/agents to manage existing sessions"
397
+ )
398
+ logger.critical(
399
+ "2. Or wait for other sessions to complete before restarting"
400
+ )
401
+ logger.critical(
402
+ "3. Consider upgrading NGrok plan for more simultaneous sessions"
403
+ )
404
+ else:
405
+ logger.critical(
406
+ f"FATAL ERROR: NGrok tunnel failed to start: {error_message}"
407
+ )
408
+
409
+ # Clean up and exit with a clear error message - webhooks are critical
410
+ stop_webhook_server()
411
+ logger.critical(
412
+ "FATAL ERROR: Cannot continue without NGrok tunnel for webhooks"
413
+ )
414
+ logger.critical(f"Specific error: {error_message}")
415
+ logger.critical(
416
+ "Please fix NGrok configuration issues before restarting"
417
+ )
418
+ sys.exit(1) # Exit with error code - fail decisively
419
+
420
+ def _start_reconnect_thread(self):
421
+ """Start a thread to monitor and reconnect the tunnel if needed."""
422
+ if self._reconnect_thread and self._reconnect_thread.is_alive():
423
+ return
424
+
425
+ self._reconnect_thread = threading.Thread(
426
+ target=self._reconnect_loop,
427
+ daemon=True,
428
+ )
429
+ self._reconnect_thread.start()
430
+
431
+ def _reconnect_loop(self):
432
+ """Background thread: monitor the tunnel and reconnect if needed."""
433
+ logger.info("Starting ngrok tunnel monitor thread")
434
+ reconnection_attempts = 0
435
+ max_reconnection_attempts = 3
436
+
437
+ while self._running:
438
+ time.sleep(30) # Check every 30 seconds
439
+
440
+ if not self._running:
441
+ break
442
+
443
+ try:
444
+ # If we have a listener and public URL, we're good
445
+ if self.listener and self.public_url:
446
+ reconnection_attempts = (
447
+ 0 # Reset counter on successful connection
448
+ )
449
+ continue
450
+ else:
451
+ # If the tunnel was previously established but is now gone, this is a critical error
452
+ if (
453
+ self._running
454
+ and hasattr(self, "_tunnel_was_established")
455
+ and self._tunnel_was_established
456
+ ):
457
+ logger.critical(
458
+ "FATAL ERROR: NGrok tunnel was previously established but has been lost"
459
+ )
460
+ logger.critical(
461
+ "Webhooks are required for MCP server operation"
462
+ )
463
+ logger.critical(
464
+ "The server will exit. Please restart when NGrok is available."
465
+ )
466
+ sys.exit(1) # Exit with error code - fail decisively
467
+
468
+ # Check if we've exceeded max reconnection attempts
469
+ if reconnection_attempts >= max_reconnection_attempts:
470
+ logger.critical(
471
+ f"FATAL ERROR: Exceeded {max_reconnection_attempts} NGrok tunnel reconnection attempts"
472
+ )
473
+ logger.critical(
474
+ "Webhooks are required for operation - exiting process immediately"
475
+ )
476
+
477
+ # Record the terminal error in webhook history
478
+ try:
479
+ self._add_to_history(
480
+ {
481
+ "event_type": "ngrok.error.terminal",
482
+ "timestamp": datetime.now().isoformat(),
483
+ "error": f"Exceeded {max_reconnection_attempts} reconnection attempts",
484
+ "error_type": "reconnection_failure",
485
+ }
486
+ )
487
+ except Exception:
488
+ pass # Ignore any errors in recording history at this point
489
+
490
+ # Exit with a clear error message - webhooks are critical
491
+ logger.critical(
492
+ "FATAL ERROR: Max reconnection attempts exceeded for NGrok tunnel"
493
+ )
494
+ logger.critical(
495
+ "Webhooks are required for MCP server operation"
496
+ )
497
+ sys.exit(1) # Exit with error code - fail decisively
498
+
499
+ # Increment attempt counter
500
+ reconnection_attempts += 1
501
+ logger.warning(
502
+ f"Tunnel not active, attempting to reconnect (attempt {reconnection_attempts}/{max_reconnection_attempts})..."
503
+ )
504
+
505
+ # Try to stop existing resources
506
+ try:
507
+ self.stop()
508
+ except Exception as stop_error:
509
+ logger.error(
510
+ f"Error stopping existing tunnel: {stop_error}"
511
+ )
512
+
513
+ time.sleep(2) # Brief delay before reconnecting
514
+
515
+ # Try to start a new tunnel
516
+ try:
517
+ new_url = self.start()
518
+ if new_url:
519
+ logger.info(
520
+ f"Successfully reconnected tunnel. New URL: {new_url}"
521
+ )
522
+ reconnection_attempts = 0 # Reset counter on success
523
+ else:
524
+ logger.error("Failed to reconnect tunnel")
525
+ except Exception as start_error:
526
+ logger.error(f"Error starting new tunnel: {start_error}")
527
+ except Exception as e:
528
+ logger.error(f"Error in tunnel monitor thread: {e}")
529
+
530
+ logger.info("Tunnel monitor thread stopped")
531
+
532
+ def _add_to_history(self, data):
533
+ """Add an event to the webhook history."""
534
+ from .server import webhook_history
535
+
536
+ webhook_history.appendleft(
537
+ {
538
+ "timestamp": datetime.now().isoformat(),
539
+ "event_type": data.get("event_type", "unknown"),
540
+ "payload": data,
541
+ }
542
+ )
543
+ logger.debug(f"Added event to history (total: {len(webhook_history)})")
544
+
545
+ def stop(self) -> None:
546
+ """Stop the tunnel and clean up resources."""
547
+ self._running = False
548
+
549
+ # First stop the reconnect thread to prevent race conditions
550
+ if self._reconnect_thread and self._reconnect_thread.is_alive():
551
+ logger.debug("Stopping reconnect thread...")
552
+ try:
553
+ self._reconnect_thread.join(timeout=5.0)
554
+ if self._reconnect_thread.is_alive():
555
+ logger.warning(
556
+ "Reconnect thread did not terminate in time"
557
+ )
558
+ except Exception as thread_err:
559
+ logger.error(f"Error stopping reconnect thread: {thread_err}")
560
+
561
+ # Then stop the tunnel
562
+ if self.listener:
563
+ try:
564
+ logger.info("Stopping ngrok tunnel...")
565
+
566
+ # Try all available methods to ensure the tunnel is stopped
567
+ try:
568
+ # Handle different ngrok SDK versions
569
+ if hasattr(self.listener, "close"):
570
+ # Newer SDK versions
571
+ self.listener.close()
572
+ except Exception as close_err:
573
+ logger.warning(f"Error closing listener: {close_err}")
574
+
575
+ try:
576
+ # Try disconnecting specifically for older SDK versions
577
+ if hasattr(ngrok, "disconnect"):
578
+ if self.public_url:
579
+ ngrok.disconnect(self.public_url)
580
+ else:
581
+ # Fallback - disconnect all
582
+ ngrok.disconnect()
583
+ except Exception as disconnect_err:
584
+ logger.warning(f"Error disconnecting: {disconnect_err}")
585
+
586
+ logger.info("Ngrok tunnel stopped successfully")
587
+ except Exception as e:
588
+ logger.error(f"Error stopping ngrok tunnel: {e}")
589
+
590
+ # Stop the webhook server
591
+ stop_webhook_server()
592
+
593
+ # Always reset these variables regardless of any errors
594
+ self.listener = None
595
+ self.public_url = None
596
+ self.socket_path = None