diff --git a/setup.cfg b/setup.cfg index 72c4c8d..14d41e6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -23,7 +23,7 @@ install_requires = [options.entry_points] console_scripts = - smsbot = smsbot:main + smsbot = smsbot.cli:main [flake8] format = wemake diff --git a/smsbot/__init__.py b/smsbot/__init__.py index e5ee114..e69de29 100644 --- a/smsbot/__init__.py +++ b/smsbot/__init__.py @@ -1,184 +0,0 @@ -import logging -import os -import sys -from functools import wraps - -import pkg_resources -from flask import Flask, abort, current_app, request -from telegram.ext import CommandHandler, Updater -from twilio.request_validator import RequestValidator -from waitress import serve - -pkg_version = pkg_resources.require('smsbot')[0].version - - -class TelegramSmsBot(object): - - def __init__(self, token, owner=None, subscribers=None): - self.logger = logging.getLogger(self.__class__.__name__) - self.bot_token = token - self.owner_id = owner - self.subscriber_ids = subscribers or [] - - def start(self): - self.logger.info('Starting bot...') - self.updater = Updater(self.bot_token, use_context=True) - self.updater.dispatcher.add_handler(CommandHandler('help', self.help_handler)) - self.updater.dispatcher.add_handler(CommandHandler('start', self.help_handler)) - self.updater.dispatcher.add_handler(CommandHandler('subscribe', self.subscribe_handler)) - self.updater.dispatcher.add_handler(CommandHandler('unsubscribe', self.unsubscribe_handler)) - self.updater.dispatcher.add_error_handler(self.error_handler) - - self.updater.start_polling() - self.bot = self.updater.bot - self.logger.info('Bot Ready') - - def stop(self): - self.updater.stop() - - def help_handler(self, update, context): - """Send a message when the command /help is issued.""" - self.logger.info('/help command received in chat: %s', update.message.chat) - update.message.reply_markdown('Smsbot v{0}\n\n/help\n/subscribe\n/unsubscribe'.format(pkg_version)) - - def subscribe_handler(self, update, context): - self.logger.info('/subscribe command received') - if update.message.chat['id'] not in self.subscriber_ids: - self.logger.info('{0} subscribed'.format(update.message.chat['username'])) - self.subscriber_ids.append(update.message.chat['id']) - self.send_owner('{0} has subscribed'.format(update.message.chat['username'])) - update.message.reply_markdown('You have been subscribed to SMS notifications') - else: - update.message.reply_markdown('You are already subscribed to SMS notifications') - - def unsubscribe_handler(self, update, context): - self.logger.info('/unsubscribe command received') - if update.message.chat['id'] in self.subscriber_ids: - self.logger.info('{0} unsubscribed'.format(update.message.chat['username'])) - self.subscriber_ids.remove(update.message.chat['id']) - self.send_owner('{0} has unsubscribed'.format(update.message.chat['username'])) - update.message.reply_markdown('You have been unsubscribed to SMS notifications') - else: - update.message.reply_markdown('You are not subscribed to SMS notifications') - - def error_handler(self, update, context): - """Log Errors caused by Updates.""" - self.logger.warning('Update "%s" caused error "%s"', update, context.error) - self.send_owner('Update "%{0}" caused error "{1}"'.format(update, context.error)) - - def send_message(self, message, chat_id): - self.bot.sendMessage(text=message, chat_id=chat_id) - - def send_owner(self, message): - if self.owner_id: - self.send_message(message, self.owner_id) - - def send_subscribers(self, message): - for chat_id in self.subscriber_ids: - self.send_message(message, chat_id) - - def set_owner(self, chat_id): - self.owner_id = chat_id - - def add_subscriber(self, chat_id): - self.subscriber_ids.append(chat_id) - - -def validate_twilio_request(func): - """Validates that incoming requests genuinely originated from Twilio""" - @wraps(func) - def decorated_function(*args, **kwargs): # noqa: WPS430 - # Create an instance of the RequestValidator class - twilio_token = os.environ.get('SMSBOT_TWILIO_AUTH_TOKEN') - - if not twilio_token: - current_app.logger.warning('Twilio request validation skipped due to SMSBOT_TWILIO_AUTH_TOKEN missing') - return func(*args, **kwargs) - - validator = RequestValidator(twilio_token) - - # Validate the request using its URL, POST data, - # and X-TWILIO-SIGNATURE header - request_valid = validator.validate( - request.url, - request.form, - request.headers.get('X-TWILIO-SIGNATURE', ''), - ) - - # Continue processing the request if it's valid, return a 403 error if - # it's not - if request_valid or current_app.debug: - return func(*args, **kwargs) - return abort(403) - return decorated_function - - -class TwilioWebhookHandler(object): - - def __init__(self): - self.app = Flask(self.__class__.__name__) - self.app.add_url_rule('/', 'index', self.index, methods=['GET']) - self.app.add_url_rule('/health', 'health', self.health, methods=['GET']) - self.app.add_url_rule('/message', 'message', self.message, methods=['POST']) - self.app.add_url_rule('/call', 'call', self.call, methods=['POST']) - - def set_bot(self, bot): # noqa: WPS615 - self.bot = bot - - def index(self): - return '' - - def health(self): - return '

Smsbot v{0}

Owner: {1}

Subscribers: {2}

'.format(pkg_version, self.bot.owner_id, self.bot.subscriber_ids) - - @validate_twilio_request - def message(self): - - message = 'From: {From}\n\n{Body}'.format(**request.values.to_dict()) - - current_app.logger.info('Received SMS from {From}: {Body}'.format(**request.values.to_dict())) - self.bot.send_subscribers(message) - - return '' - - @validate_twilio_request - def call(self): - current_app.logger.info('Received Call from {From}'.format(**request.values.to_dict())) - self.bot.send_subscribers('Received Call from {From}, rejecting.'.format(**request.values.to_dict())) - # Always reject calls - return '' - - def serve(self, host='0.0.0.0', port=80, debug=False): - serve(self.app, host=host, port=port) - - -def main(): - logging.basicConfig(level=logging.INFO) - logging.info('smsbot v%s', pkg_version) - - listen_host = os.environ.get('SMSBOT_LISTEN_HOST') or '0.0.0.0' - listen_port = int(os.environ.get('SMSBOT_LISTEN_PORT') or '80') - - token = os.environ.get('SMSBOT_TELEGRAM_BOT_TOKEN') - if not token: - logging.error('Telegram Bot token missing') - sys.exit(1) - - # Start bot - telegram_bot = TelegramSmsBot(token) - - # Set the owner ID if configured - if 'SMSBOT_OWNER_ID' in os.environ: - telegram_bot.set_owner(os.environ.get('SMSBOT_OWNER_ID')) - - # Add default subscribers - if 'SMSBOT_DEFAULT_SUBSCRIBERS' in os.environ: - for chat_id in os.environ.get('SMSBOT_DEFAULT_SUBSCRIBERS').split(','): - telegram_bot.add_subscriber(chat_id) - - telegram_bot.start() - - # Start webhooks - webhooks = TwilioWebhookHandler() - webhooks.set_bot(telegram_bot) - webhooks.serve(host=listen_host, port=listen_port) diff --git a/smsbot/__main__.py b/smsbot/__main__.py index 941c879..75816b1 100644 --- a/smsbot/__main__.py +++ b/smsbot/__main__.py @@ -1,4 +1,4 @@ -from smsbot import main +from smsbot.cli import main if __name__ == '__main__': main() diff --git a/smsbot/cli.py b/smsbot/cli.py new file mode 100644 index 0000000..ae3ef1b --- /dev/null +++ b/smsbot/cli.py @@ -0,0 +1,42 @@ +import logging +import os +import sys + +import pkg_resources + +from smsbot.telegram import TelegramSmsBot +from smsbot.webhook_handler import TwilioWebhookHandler + +pkg_version = pkg_resources.require('smsbot')[0].version + + +def main(): + logging.basicConfig(level=logging.INFO) + logging.info('smsbot v%s', pkg_version) + + listen_host = os.environ.get('SMSBOT_LISTEN_HOST') or '0.0.0.0' + listen_port = int(os.environ.get('SMSBOT_LISTEN_PORT') or '80') + + token = os.environ.get('SMSBOT_TELEGRAM_BOT_TOKEN') + if not token: + logging.error('Telegram Bot token missing') + sys.exit(1) + + # Start bot + telegram_bot = TelegramSmsBot(token) + + # Set the owner ID if configured + if 'SMSBOT_OWNER_ID' in os.environ: + telegram_bot.set_owner(os.environ.get('SMSBOT_OWNER_ID')) + + # Add default subscribers + if 'SMSBOT_DEFAULT_SUBSCRIBERS' in os.environ: + for chat_id in os.environ.get('SMSBOT_DEFAULT_SUBSCRIBERS').split(','): + telegram_bot.add_subscriber(chat_id) + + telegram_bot.start() + + # Start webhooks + webhooks = TwilioWebhookHandler() + webhooks.set_bot(telegram_bot) + webhooks.serve(host=listen_host, port=listen_port) \ No newline at end of file diff --git a/smsbot/telegram.py b/smsbot/telegram.py new file mode 100644 index 0000000..d1aadf3 --- /dev/null +++ b/smsbot/telegram.py @@ -0,0 +1,77 @@ +import logging + +from telegram.ext import CommandHandler, Updater + +from smsbot.utils import get_smsbot_version + + +class TelegramSmsBot(object): + + def __init__(self, token, owner=None, subscribers=None): + self.logger = logging.getLogger(self.__class__.__name__) + self.bot_token = token + self.owner_id = owner + self.subscriber_ids = subscribers or [] + + def start(self): + self.logger.info('Starting bot...') + self.updater = Updater(self.bot_token, use_context=True) + self.updater.dispatcher.add_handler(CommandHandler('help', self.help_handler)) + self.updater.dispatcher.add_handler(CommandHandler('start', self.help_handler)) + self.updater.dispatcher.add_handler(CommandHandler('subscribe', self.subscribe_handler)) + self.updater.dispatcher.add_handler(CommandHandler('unsubscribe', self.unsubscribe_handler)) + self.updater.dispatcher.add_error_handler(self.error_handler) + + self.updater.start_polling() + self.bot = self.updater.bot + self.logger.info('Bot Ready') + + def stop(self): + self.updater.stop() + + def help_handler(self, update, context): + """Send a message when the command /help is issued.""" + self.logger.info('/help command received in chat: %s', update.message.chat) + update.message.reply_markdown('Smsbot v{0}\n\n/help\n/subscribe\n/unsubscribe'.format(get_smsbot_version())) + + def subscribe_handler(self, update, context): + self.logger.info('/subscribe command received') + if update.message.chat['id'] not in self.subscriber_ids: + self.logger.info('{0} subscribed'.format(update.message.chat['username'])) + self.subscriber_ids.append(update.message.chat['id']) + self.send_owner('{0} has subscribed'.format(update.message.chat['username'])) + update.message.reply_markdown('You have been subscribed to SMS notifications') + else: + update.message.reply_markdown('You are already subscribed to SMS notifications') + + def unsubscribe_handler(self, update, context): + self.logger.info('/unsubscribe command received') + if update.message.chat['id'] in self.subscriber_ids: + self.logger.info('{0} unsubscribed'.format(update.message.chat['username'])) + self.subscriber_ids.remove(update.message.chat['id']) + self.send_owner('{0} has unsubscribed'.format(update.message.chat['username'])) + update.message.reply_markdown('You have been unsubscribed to SMS notifications') + else: + update.message.reply_markdown('You are not subscribed to SMS notifications') + + def error_handler(self, update, context): + """Log Errors caused by Updates.""" + self.logger.warning('Update "%s" caused error "%s"', update, context.error) + self.send_owner('Update "%{0}" caused error "{1}"'.format(update, context.error)) + + def send_message(self, message, chat_id): + self.bot.sendMessage(text=message, chat_id=chat_id) + + def send_owner(self, message): + if self.owner_id: + self.send_message(message, self.owner_id) + + def send_subscribers(self, message): + for chat_id in self.subscriber_ids: + self.send_message(message, chat_id) + + def set_owner(self, chat_id): + self.owner_id = chat_id + + def add_subscriber(self, chat_id): + self.subscriber_ids.append(chat_id) diff --git a/smsbot/utils.py b/smsbot/utils.py new file mode 100644 index 0000000..7325bfb --- /dev/null +++ b/smsbot/utils.py @@ -0,0 +1,5 @@ +import pkg_resources + + +def get_smsbot_version(): + return pkg_resources.require('smsbot')[0].version diff --git a/smsbot/webhook_handler.py b/smsbot/webhook_handler.py new file mode 100644 index 0000000..c712bbd --- /dev/null +++ b/smsbot/webhook_handler.py @@ -0,0 +1,78 @@ + +import os +from functools import wraps + +from flask import Flask, abort, current_app, request +from twilio.request_validator import RequestValidator +from waitress import serve + +from smsbot.utils import get_smsbot_version + + +def validate_twilio_request(func): + """Validates that incoming requests genuinely originated from Twilio""" + @wraps(func) + def decorated_function(*args, **kwargs): # noqa: WPS430 + # Create an instance of the RequestValidator class + twilio_token = os.environ.get('SMSBOT_TWILIO_AUTH_TOKEN') + + if not twilio_token: + current_app.logger.warning('Twilio request validation skipped due to SMSBOT_TWILIO_AUTH_TOKEN missing') + return func(*args, **kwargs) + + validator = RequestValidator(twilio_token) + + # Validate the request using its URL, POST data, + # and X-TWILIO-SIGNATURE header + request_valid = validator.validate( + request.url, + request.form, + request.headers.get('X-TWILIO-SIGNATURE', ''), + ) + + # Continue processing the request if it's valid, return a 403 error if + # it's not + if request_valid or current_app.debug: + return func(*args, **kwargs) + return abort(403) + return decorated_function + + +class TwilioWebhookHandler(object): + + def __init__(self): + self.app = Flask(self.__class__.__name__) + self.app.add_url_rule('/', 'index', self.index, methods=['GET']) + self.app.add_url_rule('/health', 'health', self.health, methods=['GET']) + self.app.add_url_rule('/message', 'message', self.message, methods=['POST']) + self.app.add_url_rule('/call', 'call', self.call, methods=['POST']) + + def set_bot(self, bot): # noqa: WPS615 + self.bot = bot + + def index(self): + return '' + + def health(self): + return '

Smsbot v{0}

Owner: {1}

Subscribers: {2}

'.format(get_smsbot_version(), self.bot.owner_id, self.bot.subscriber_ids) + + @validate_twilio_request + def message(self): + current_app.logger.info('Received SMS from {From}: {Body}'.format(**request.values.to_dict())) + + message = 'From: {From}\n\n{Body}'.format(**request.values.to_dict()) + self.bot.send_subscribers(message) + + # Return a blank response + return '' + + @validate_twilio_request + def call(self): + current_app.logger.info('Received Call from {From}'.format(**request.values.to_dict())) + self.bot.send_subscribers('Received Call from {From}, rejecting.'.format(**request.values.to_dict())) + + # Always reject calls + return '' + + def serve(self, host='0.0.0.0', port=80, debug=False): + serve(self.app, host=host, port=port)