diff --git a/smsbot/cli.py b/smsbot/cli.py index 038828a..93cf3b9 100644 --- a/smsbot/cli.py +++ b/smsbot/cli.py @@ -5,6 +5,7 @@ import os import sys from configparser import ConfigParser from signal import SIGINT, SIGTERM +from twilio.rest import Client import uvicorn from asgiref.wsgi import WsgiToAsgi @@ -50,15 +51,37 @@ def main(): # Validate configuration if not config.has_section("telegram") or not config.get("telegram", "bot_token"): - logging.error("Telegram bot token is required, define a token either in the config file or as an environment variable.") + logging.error( + "Telegram bot token is required, define a token either in the config file or as an environment variable." + ) + return + + if config.has_section("twilio") and not (config.get("twilio", "account_sid") and config.get("twilio", "auth_token") and config.get("twilio", "from_number")): + logging.error( + "Twilio account SID, auth token, and from number are required for outbound SMS functionality, define them in the config file or as environment variables." + ) return # Now the config is loaded, set the logger level level = getattr(logging, config.get("logging", "level", fallback="INFO").upper(), logging.INFO) logging.getLogger().setLevel(level) + # Configure Twilio client if we have credentials + if config.has_section("twilio") and config.get("twilio", "account_sid") and config.get("twilio", "auth_token"): + twilio_client = Client( + config.get("twilio", "account_sid"), + config.get("twilio", "auth_token"), + ) + else: + twilio_client = None + logging.warning("No Twilio credentials found, outbound SMS functionality will be disabled.") + # Start bot - telegram_bot = TelegramSmsBot(token=config.get("telegram", "bot_token")) + telegram_bot = TelegramSmsBot( + token=config.get("telegram", "bot_token"), + twilio_client=twilio_client, + twilio_from_number=config.get("twilio", "from_number", fallback=None), + ) # Set the owner ID if configured if config.has_option("telegram", "owner_id"): @@ -76,7 +99,7 @@ def main(): account_sid=config.get("twilio", "account_sid", fallback=None), auth_token=config.get("twilio", "auth_token", fallback=None), ) - webhooks.set_bot(telegram_bot) + webhooks.set_telegram_application(telegram_bot) # Build a uvicorn ASGI server flask_app = uvicorn.Server( diff --git a/smsbot/telegram.py b/smsbot/telegram.py index 7489685..fb20197 100644 --- a/smsbot/telegram.py +++ b/smsbot/telegram.py @@ -9,6 +9,7 @@ from telegram.ext import ( ContextTypes, TypeHandler, ) +from twilio.rest import Client from smsbot.utils import get_smsbot_version @@ -17,11 +18,20 @@ COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands proc class TelegramSmsBot: - def __init__(self, token: str, owners: list[int] = [], subscribers: list[int] = []): + def __init__( + self, + token: str, + twilio_client: Client | None = None, + twilio_from_number: str | None = None, + owners: list[int] = [], + subscribers: list[int] = [], + ): self.logger = logging.getLogger(self.__class__.__name__) self.app = Application.builder().token(token).build() self.owners = owners self.subscribers = subscribers + self.twilio_client = twilio_client + self.twilio_from_number = twilio_from_number self.init_handlers() @@ -30,6 +40,7 @@ class TelegramSmsBot: self.app.add_handler(CommandHandler(["help", "start"], self.handler_help)) self.app.add_handler(CommandHandler("subscribe", self.handler_subscribe)) self.app.add_handler(CommandHandler("unsubscribe", self.handler_unsubscribe)) + self.app.add_handler(CommandHandler("sms", self.handler_sms)) async def callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE): """Handle the update""" @@ -97,3 +108,22 @@ class TelegramSmsBot: await update.message.reply_markdown("You have successfully unsubscribed from updates.") else: self.logger.info(f"User {user_id} is not subscribed.") + + @REQUEST_TIME.time() + async def handler_sms(self, update: Update, context: ContextTypes.DEFAULT_TYPE): + """Handle sending SMS requests""" + if update.effective_user and update.message: + user_id = update.effective_user.id + if self.twilio_client and self.twilio_from_number: + to = context.args[0] if context.args else "No recipient provided" + message = context.args[1] if context.args else "No message provided" + self.logger.info(f"Sending SMS from user {user_id} -> {to}: {message}") + + try: + self.twilio_client.messages.create(body=message, to=to, from_=self.twilio_from_number) + except Exception as e: + self.logger.exception("Failed to send SMS due to exception") + await update.message.reply_markdown("Failed to send SMS") + pass + else: + await update.message.reply_markdown("Twilio client is not configured, cannot send SMS.") diff --git a/smsbot/utils/__init__.py b/smsbot/utils/__init__.py new file mode 100644 index 0000000..cef531b --- /dev/null +++ b/smsbot/utils/__init__.py @@ -0,0 +1,5 @@ +from importlib.metadata import version + + +def get_smsbot_version() -> str: + return version("smsbot") diff --git a/smsbot/utils.py b/smsbot/utils/twilio.py similarity index 96% rename from smsbot/utils.py rename to smsbot/utils/twilio.py index 7d4bf2b..ce24b84 100644 --- a/smsbot/utils.py +++ b/smsbot/utils/twilio.py @@ -1,10 +1,3 @@ -from importlib.metadata import version - - -def get_smsbot_version() -> str: - return version("smsbot") - - class TwilioWebhookPayload: @staticmethod def parse(data: dict[str, str]) -> "TwilioCall | TwilioMessage | None": diff --git a/smsbot/webhook.py b/smsbot/webhook.py index 427238d..45a3668 100644 --- a/smsbot/webhook.py +++ b/smsbot/webhook.py @@ -6,7 +6,8 @@ from prometheus_client import Counter, Summary, make_wsgi_app from twilio.request_validator import RequestValidator from werkzeug.middleware.dispatcher import DispatcherMiddleware -from smsbot.utils import TwilioWebhookPayload, get_smsbot_version +from smsbot.utils import get_smsbot_version +from smsbot.utils.twilio import TwilioWebhookPayload REQUEST_TIME = Summary("webhook_request_processing_seconds", "Time spent processing request") MESSAGE_COUNT = Counter("webhook_message_count", "Total number of messages processed") @@ -68,8 +69,9 @@ class TwilioWebhookHandler(object): return decorated_function - def set_bot(self, bot): - self.bot = bot + def set_telegram_application(self, app): + """Set the Telegram application instance to use for any webhook calls""" + self.telegram_app = app async def index(self) -> str: return f'smsbot v{get_smsbot_version()} - GitHub' @@ -78,8 +80,8 @@ class TwilioWebhookHandler(object): """Return basic health information""" return { "version": get_smsbot_version(), - "owners": self.bot.owners, - "subscribers": len(self.bot.subscribers), + "owners": self.telegram_app.owners, + "subscribers": len(self.telegram_app.subscribers), } @time(REQUEST_TIME) @@ -88,7 +90,7 @@ class TwilioWebhookHandler(object): current_app.logger.info("Received SMS from {From}: {Body}".format(**request.values.to_dict())) hook_data = TwilioWebhookPayload.parse(request.values.to_dict()) if hook_data: - await self.bot.send_subscribers(hook_data.to_markdownv2()) + await self.telegram_app.send_subscribers(hook_data.to_markdownv2()) # Return a blank response MESSAGE_COUNT.inc() @@ -100,7 +102,7 @@ class TwilioWebhookHandler(object): current_app.logger.info("Received Call from {From}".format(**request.values.to_dict())) hook_data = TwilioWebhookPayload.parse(request.values.to_dict()) if hook_data: - await self.bot.send_subscribers(hook_data.to_markdownv2()) + await self.telegram_app.send_subscribers(hook_data.to_markdownv2()) # Always reject calls CALL_COUNT.inc()