diff --git a/README.md b/README.md index 9feec0c..bb354f0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Telert is a lightweight utility for multi-channel notifications for alerting when terminal commands or Python code completes. It also extends this notification capability to easily monitor processes, log files, and HTTP endpoints uptime. The tool supports multiple notification channels: -- **Messaging Apps**: Telegram, Microsoft Teams, Slack, Discord +- **Messaging Apps**: Telegram, Microsoft Teams, Slack, Discord, XMPP - **Email**: SMTP email notifications - **Mobile Devices**: Pushover (Android & iOS) - **Local Notifications**: Desktop notifications, Audio alerts @@ -69,6 +69,7 @@ Use it as a CLI tool, Python library, or a notification API. Telert is available - [Slack](#slack-setup) - [Discord](#discord-setup) - [Email](#email-setup) + - [XMPP](#xmpp-setup) - [Pushover](#pushover-setup) - [Custom HTTP Endpoints](#custom-http-endpoint-setup) - [Audio Alerts](#audio-alerts-setup) @@ -265,6 +266,34 @@ telert config email \ [**Detailed Email Setup Guide**](https://github.com/navig-me/telert/blob/main/docs/EMAIL.md) +### XMPP Setup + +XMPP (Extensible Messaging and Presence Protocol) provides instant messaging to any XMPP-compatible service like Jabber, Prosody, ejabberd, or corporate XMPP servers. + +```bash +# Basic configuration +telert config xmpp --jid "your-account@xmpp-server.com" --password "your-password" --recipient-jid "recipient@xmpp-server.com" --set-default +telert status # Test your configuration + +# Configuration with custom server (if auto-discovery fails) +telert config xmpp \ + --jid "your-account@example.com" \ + --password "your-password" \ + --recipient-jid "recipient@example.com" \ + --server "xmpp.example.com" \ + --port 5222 \ + --set-default +``` + +**Requirements**: XMPP support requires the `slixmpp` library: `pip install slixmpp>=1.8.0` + +**Configuration Parameters**: +- `--jid`: Your XMPP Jabber ID (username@domain) +- `--password`: Your XMPP account password +- `--recipient-jid`: Target Jabber ID to send messages to +- `--server`: XMPP server address (optional, auto-discovered from JID domain) +- `--port`: XMPP server port (default: 5222) + ### Pushover Setup Pushover provides mobile notifications to Android and iOS devices. diff --git a/pyproject.toml b/pyproject.toml index 32ab0f9..1befcbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,8 @@ dependencies = [ "requests>=2.25", "beautifulsoup4>=4.9.0", "psutil>=5.9.0", - "ping3>=4.0.0" + "ping3>=4.0.0", + "slixmpp>=1.8.0" ] keywords = [ "telegram", @@ -22,6 +23,8 @@ keywords = [ "pushover", "slack", "discord", + "xmpp", + "jabber", "microsoft-teams", "desktop-notifications", "monitoring", diff --git a/telert/cli.py b/telert/cli.py index 0ccaa02..e0c657b 100644 --- a/telert/cli.py +++ b/telert/cli.py @@ -311,6 +311,30 @@ def do_config(a): except ValueError as e: sys.exit(f"❌ {str(e)}") + elif provider == "xmpp": + if not hasattr(a, "jid") or not hasattr(a, "password") or not hasattr(a, "recipient_jid"): + sys.exit("❌ XMPP configuration requires --jid, --password, and --recipient-jid") + + config_params = { + "jid": a.jid, + "password": a.password, + "recipient_jid": a.recipient_jid, + "port": a.port, + "set_default": a.set_default, + "add_to_defaults": a.add_to_defaults, + } + + if hasattr(a, "server") and a.server: + config_params["server"] = a.server + + try: + configure_provider(Provider.XMPP, **config_params) + print(f"✔ XMPP configuration saved for {a.jid} -> {a.recipient_jid}") + except ValueError as e: + sys.exit(f"❌ {str(e)}") + except ImportError as e: + sys.exit(f"❌ Failed to configure XMPP: {str(e)}") + else: sys.exit(f"❌ Unknown provider: {provider}") else: @@ -526,6 +550,26 @@ def do_status(a): to_str = ", ".join(to_addrs) if to_addrs else "unknown" print(f"- Email{default_marker}: server={server}:{port} → {to_str}") + # Check XMPP + xmpp_config = config.get_provider_config(Provider.XMPP) + if xmpp_config: + # Mark as default if in default providers list + if Provider.XMPP.value in default_provider_names: + # Show priority if multiple defaults + if len(default_provider_names) > 1: + priority = default_provider_names.index(Provider.XMPP.value) + 1 + default_marker = f" (default #{priority})" + else: + default_marker = " (default)" + else: + default_marker = "" + + jid = xmpp_config["jid"] + recipient_jid = xmpp_config["recipient_jid"] + server = xmpp_config.get("server", "auto") + port = xmpp_config.get("port", 5222) + print(f"- XMPP{default_marker}: {jid} → {recipient_jid} (server: {server}:{port})") + # If none configured, show warning if not ( telegram_config @@ -537,6 +581,7 @@ def do_status(a): or endpoint_config or discord_config or email_config + or xmpp_config ): print("No providers configured. Use `telert config` or `telert init` to set up a provider.") return @@ -1674,6 +1719,34 @@ def main(): help="add to existing default providers", ) + # XMPP configuration subparser + xmpp_parser = config_sp.add_parser( + "xmpp", help="configure XMPP messaging" + ) + xmpp_parser.add_argument( + "--jid", required=True, help="XMPP Jabber ID (username@domain)" + ) + xmpp_parser.add_argument( + "--password", required=True, help="XMPP account password" + ) + xmpp_parser.add_argument( + "--recipient-jid", required=True, help="recipient Jabber ID (username@domain)" + ) + xmpp_parser.add_argument( + "--server", help="XMPP server address (optional, can be derived from JID)" + ) + xmpp_parser.add_argument( + "--port", type=int, default=5222, help="XMPP server port (default: 5222)" + ) + xmpp_parser.add_argument( + "--set-default", action="store_true", help="set as the only default provider" + ) + xmpp_parser.add_argument( + "--add-to-defaults", + action="store_true", + help="add to existing default providers", + ) + c.set_defaults(func=do_config) # status diff --git a/telert/messaging.py b/telert/messaging.py index 698449e..22d35f2 100644 --- a/telert/messaging.py +++ b/telert/messaging.py @@ -13,6 +13,7 @@ from __future__ import annotations +import asyncio import enum import json import os @@ -32,6 +33,15 @@ import requests from bs4 import BeautifulSoup +try: + import slixmpp + from slixmpp import ClientXMPP + XMPP_AVAILABLE = True +except ImportError: + slixmpp = None + ClientXMPP = None + XMPP_AVAILABLE = False + # Config paths CONFIG_DIR = pathlib.Path(os.path.expanduser("~/.config/telert")) CONFIG_DIR.mkdir(parents=True, exist_ok=True) @@ -56,6 +66,7 @@ class Provider(enum.Enum): ENDPOINT = "endpoint" DISCORD = "discord" EMAIL = "email" + XMPP = "xmpp" @classmethod def from_string(cls, value: str) -> "Provider": @@ -209,6 +220,23 @@ def get_provider_config(self, provider: Union[Provider, str]) -> Dict[str, Any]: if icon_path: config["icon_path"] = icon_path return config or self._config.get(provider.value, {}) + elif provider == Provider.XMPP: + jid = os.environ.get("TELERT_XMPP_JID") + password = os.environ.get("TELERT_XMPP_PASSWORD") + recipient_jid = os.environ.get("TELERT_XMPP_RECIPIENT") + if jid and password and recipient_jid: + config = {"jid": jid, "password": password, "recipient_jid": recipient_jid} + # Optional parameters + server = os.environ.get("TELERT_XMPP_SERVER") + if server: + config["server"] = server + port = os.environ.get("TELERT_XMPP_PORT") + if port: + try: + config["port"] = int(port) + except ValueError: + pass + return config # Fall back to config file return self._config.get(provider.value, {}) @@ -1438,6 +1466,203 @@ def send(self, message: str, **kwargs) -> bool: print(f"❌ Failed to send email: {str(e)}") return False + +class XMPPProvider: + """Provider for XMPP messaging.""" + + def __init__( + self, + jid: Optional[str] = None, + password: Optional[str] = None, + recipient_jid: Optional[str] = None, + server: Optional[str] = None, + port: Optional[int] = None, + ): + """Initialize XMPP provider. + + Args: + jid: Jabber ID (username@domain) + password: XMPP account password + recipient_jid: Recipient Jabber ID + server: XMPP server address (optional, can be derived from JID) + port: XMPP server port (default: 5222) + """ + if not XMPP_AVAILABLE: + raise ImportError( + "slixmpp is required for XMPP support. Install with: pip install slixmpp>=1.8.0" + ) + + self.jid = jid + self.password = password + self.recipient_jid = recipient_jid + self.server = server + self.port = port or 5222 + + def configure_from_env(self) -> bool: + """Configure from environment variables.""" + self.jid = os.environ.get("TELERT_XMPP_JID", self.jid) + self.password = os.environ.get("TELERT_XMPP_PASSWORD", self.password) + self.recipient_jid = os.environ.get("TELERT_XMPP_RECIPIENT", self.recipient_jid) + self.server = os.environ.get("TELERT_XMPP_SERVER", self.server) + + port_env = os.environ.get("TELERT_XMPP_PORT") + if port_env: + try: + self.port = int(port_env) + except ValueError: + self.port = 5222 + + return bool(self.jid and self.password and self.recipient_jid) + + def configure_from_config(self, config: MessagingConfig) -> bool: + """Configure from stored configuration.""" + xmpp_config = config.get_provider_config(Provider.XMPP) + if xmpp_config: + self.jid = xmpp_config.get("jid", self.jid) + self.password = xmpp_config.get("password", self.password) + self.recipient_jid = xmpp_config.get("recipient_jid", self.recipient_jid) + self.server = xmpp_config.get("server", self.server) + self.port = xmpp_config.get("port", self.port or 5222) + return bool(self.jid and self.password and self.recipient_jid) + return False + + def save_config(self, config: MessagingConfig): + """Save configuration.""" + if self.jid and self.password and self.recipient_jid: + config_data = { + "jid": self.jid, + "password": self.password, + "recipient_jid": self.recipient_jid, + "port": self.port, + } + if self.server: + config_data["server"] = self.server + config.set_provider_config(Provider.XMPP, config_data) + + def send(self, message: str) -> bool: + """Send a message via XMPP. + + Args: + message: Message content to send + + Returns: + bool: True if successful + """ + if not XMPP_AVAILABLE: + raise ImportError( + "slixmpp is required for XMPP support. Install with: pip install slixmpp>=1.8.0" + ) + + if not (self.jid and self.password and self.recipient_jid): + raise ValueError("XMPP provider not configured properly") + + # Create a simple XMPP client for sending messages + class XMPPSender(ClientXMPP): + def __init__(self, jid, password, recipient, message_text): + super().__init__(jid, password) + self.recipient = recipient + self.message_to_send = message_text + self.success = False + self.error_msg = None + self.done_event = asyncio.Event() + + # Register event handlers + self.add_event_handler("session_start", self.on_session_start) + self.add_event_handler("failed_auth", self.on_failed_auth) + self.add_event_handler("connection_failed", self.on_connection_failed) + + async def on_session_start(self, event): + """Called when XMPP session starts successfully.""" + try: + # Send initial presence to appear online + self.send_presence() + + # Get roster (optional, but helps with delivery) + await self.get_roster() + + # Send the message + self.send_message( + mto=self.recipient, + mbody=self.message_to_send, + mtype='chat' + ) + + self.success = True + + except Exception as e: + self.error_msg = f"Error during message sending: {str(e)}" + finally: + # Always signal completion and disconnect + self.done_event.set() + self.disconnect() + + def on_failed_auth(self, event): + """Called when authentication fails.""" + self.error_msg = "XMPP authentication failed - check username and password" + self.done_event.set() + self.disconnect() + + def on_connection_failed(self, event): + """Called when connection fails.""" + self.error_msg = f"XMPP connection failed: {str(event)}" + self.done_event.set() + + async def _send_message_async(): + """Send XMPP message asynchronously.""" + client = XMPPSender(self.jid, self.password, self.recipient_jid, message) + + try: + # Attempt to connect + if self.server: + success = await client.connect(address=(self.server, self.port)) + else: + success = await client.connect() + + if not success: + raise RuntimeError("Failed to connect to XMPP server") + + # Wait for the operation to complete with timeout + try: + await asyncio.wait_for(client.done_event.wait(), timeout=30.0) + except asyncio.TimeoutError: + client.disconnect() + raise RuntimeError("XMPP operation timed out after 30 seconds") + + # Check results + if client.error_msg: + raise RuntimeError(client.error_msg) + + if not client.success: + raise RuntimeError("XMPP message sending failed for unknown reason") + + return True + + except Exception as e: + # Ensure client is disconnected + try: + client.disconnect() + except: + pass + raise e + + try: + # Run the async operation + return asyncio.run(_send_message_async()) + except Exception as e: + # Provide helpful error messages + error_str = str(e) + if "authentication failed" in error_str.lower(): + raise RuntimeError(f"XMPP authentication failed. Please check your JID and password.") + elif "connection failed" in error_str.lower() or "timed out" in error_str.lower(): + if self.server: + raise RuntimeError(f"Could not connect to XMPP server {self.server}:{self.port}. Please check server address and network connectivity.") + else: + domain = self.jid.split('@')[1] if '@' in self.jid else 'unknown' + raise RuntimeError(f"Could not connect to XMPP server for domain {domain}. You may need to specify --server explicitly.") + else: + raise RuntimeError(f"XMPP error: {error_str}") + + class EndpointProvider: """Provider for custom HTTP endpoint messaging.""" @@ -1630,6 +1855,7 @@ def get_provider( EndpointProvider, DiscordProvider, EmailProvider, + XMPPProvider, ]: """Get a configured messaging provider (single provider mode for backward compatibility).""" providers = get_providers(provider_name) @@ -1651,6 +1877,7 @@ def get_providers( EndpointProvider, DiscordProvider, EmailProvider, + XMPPProvider, ] ]: """Get a list of configured messaging providers. @@ -1711,6 +1938,8 @@ def get_providers( provider = DiscordProvider() elif provider_enum == Provider.EMAIL: provider = EmailProvider() + elif provider_enum == Provider.XMPP: + provider = XMPPProvider() else: continue # Skip unsupported providers @@ -1780,6 +2009,13 @@ def get_providers( if provider.configure_from_env(): env_providers.append(provider) + if (os.environ.get("TELERT_XMPP_JID", None) is not None and + os.environ.get("TELERT_XMPP_PASSWORD", None) is not None and + os.environ.get("TELERT_XMPP_RECIPIENT", None) is not None): + provider = XMPPProvider() + if provider.configure_from_env(): + env_providers.append(provider) + # If multiple providers are configured via env vars, check for preference order if env_providers: # If TELERT_DEFAULT_PROVIDER is set, reorder the providers accordingly @@ -1811,6 +2047,7 @@ def get_providers( Provider.ENDPOINT: EndpointProvider, Provider.DISCORD: DiscordProvider, Provider.EMAIL: EmailProvider, + Provider.XMPP: XMPPProvider, }[p_type], ): result_providers.append(provider) @@ -2214,6 +2451,51 @@ def configure_provider(provider: Union[Provider, str], **kwargs): subject_template=subject_template, use_html=use_html, ) + + elif provider == Provider.XMPP: + if not XMPP_AVAILABLE: + raise ImportError( + "slixmpp is required for XMPP support. Install with: pip install slixmpp>=1.8.0" + ) + + # Check required parameters + required_params = ["jid", "password", "recipient_jid"] + for param in required_params: + if param not in kwargs: + raise ValueError(f"XMPP provider requires '{param}'") + + jid = kwargs["jid"] + password = kwargs["password"] + recipient_jid = kwargs["recipient_jid"] + + # Basic validation + if not jid or not password or not recipient_jid: + raise ValueError("XMPP jid, password, and recipient_jid cannot be empty") + + # Validate JID format (basic check for @) + if "@" not in jid: + raise ValueError("JID must be in format 'username@domain'") + if "@" not in recipient_jid: + raise ValueError("Recipient JID must be in format 'username@domain'") + + # Optional parameters + server = kwargs.get("server") + port = kwargs.get("port", 5222) + + # Validate port if provided + try: + port = int(port) + except (ValueError, TypeError): + raise ValueError(f"Invalid port number: {port}") + + # Create provider instance + provider_instance = XMPPProvider( + jid=jid, + password=password, + recipient_jid=recipient_jid, + server=server, + port=port, + ) else: raise ValueError(f"Unsupported provider: {provider}")