69 Commits

Author SHA1 Message Date
3d28716817 Bump chart app version 2025-02-27 17:35:14 +00:00
74dac7603e Fix lint and release 2025-02-27 17:32:33 +00:00
076e2464a0 Fix docker build 2025-02-27 17:27:03 +00:00
85c72462c4 Migrate to Poetry 2025-02-27 15:31:58 +00:00
41971aabdf Merge pull request #29 from nikdoof/renovate/python-3.x
Update python Docker tag to v3.12
2024-07-26 10:54:24 +01:00
befa3df48d Merge pull request #35 from nikdoof/renovate/softprops-action-gh-release-2.x
Update softprops/action-gh-release action to v2
2024-07-26 10:54:09 +01:00
d504dfb95d Merge pull request #34 from nikdoof/renovate/docker-build-push-action-6.x
Update docker/build-push-action action to v6
2024-07-26 10:53:57 +01:00
e51541c79a Merge pull request #30 from nikdoof/renovate/helm-chart-releaser-action-1.x
Update helm/chart-releaser-action action to v1.6.0
2024-07-26 10:53:36 +01:00
cd3699f185 Merge pull request #32 from nikdoof/renovate/azure-setup-helm-4.x
Update azure/setup-helm action to v4
2024-07-26 10:53:25 +01:00
7d7aa97c24 Merge pull request #31 from nikdoof/renovate/actions-setup-python-5.x
Update actions/setup-python action to v5
2024-07-26 10:53:13 +01:00
renovate[bot]
d51c6fbe73 Update softprops/action-gh-release action to v2 2024-07-26 09:53:03 +00:00
21276e1c24 Merge pull request #26 from nikdoof/renovate/docker-login-action-3.x
Update docker/login-action action to v3
2024-07-26 10:53:01 +01:00
renovate[bot]
4e15d4a8ed Update docker/build-push-action action to v6 2024-07-26 09:52:58 +00:00
b1bdcb456a Merge pull request #28 from nikdoof/renovate/docker-setup-qemu-action-3.x
Update docker/setup-qemu-action action to v3
2024-07-26 10:52:51 +01:00
f7a151235a Merge pull request #27 from nikdoof/renovate/docker-setup-buildx-action-3.x
Update docker/setup-buildx-action action to v3
2024-07-26 10:52:41 +01:00
1d8b177cb4 Merge pull request #24 from nikdoof/renovate/actions-checkout-4.x
Update actions/checkout action to v4
2024-07-26 10:52:30 +01:00
renovate[bot]
3389144484 Update azure/setup-helm action to v4 2024-03-06 23:02:35 +00:00
renovate[bot]
aa3ca58b58 Update actions/setup-python action to v5 2023-12-06 12:16:22 +00:00
renovate[bot]
2854626c27 Update helm/chart-releaser-action action to v1.6.0 2023-11-02 19:10:52 +00:00
renovate[bot]
dc0745ce76 Update actions/checkout action to v4 2023-10-19 11:25:47 +00:00
renovate[bot]
19f8183ddc Update python Docker tag to v3.12 2023-10-03 04:28:27 +00:00
renovate[bot]
f51928cb4b Update docker/setup-qemu-action action to v3 2023-09-12 14:01:27 +00:00
renovate[bot]
80be469a90 Update docker/setup-buildx-action action to v3 2023-09-12 14:01:22 +00:00
renovate[bot]
076216e089 Update docker/login-action action to v3 2023-09-12 10:13:06 +00:00
90fc864be4 Update chart to 0.0.4 2023-03-11 06:42:16 +00:00
3bf3823b77 Version 0.0.4 2023-03-11 06:38:29 +00:00
8c91dacc7e Pin telegram-bot to <14 2023-03-11 06:33:38 +00:00
4bdf3e0f63 Revert "Update python Docker tag to v3.11"
This reverts commit 692a7eab37.
2023-03-10 22:26:24 +00:00
24f72ade72 Merge pull request #19 from nikdoof/renovate/docker-build-push-action-4.x
Update docker/build-push-action action to v4
2023-02-22 16:31:42 +00:00
852a684d4e Merge pull request #18 from nikdoof/renovate/helm-chart-releaser-action-1.x
Update helm/chart-releaser-action action to v1.5.0
2023-02-22 16:31:33 +00:00
41bc52d046 Merge pull request #16 from nikdoof/renovate/python-3.x
Update python Docker tag to v3.11
2023-02-22 16:31:22 +00:00
renovate[bot]
90b5fa0621 Update docker/build-push-action action to v4 2023-01-30 21:25:02 +00:00
renovate[bot]
f2fec6b5b6 Update helm/chart-releaser-action action to v1.5.0 2023-01-06 18:02:55 +00:00
c7f7ef3fda Merge pull request #17 from nikdoof/renovate/wemake-services-wemake-python-styleguide-0.x
Update wemake-services/wemake-python-styleguide action to v0.17.0
2023-01-04 13:38:16 +00:00
cdff8dab7b Merge pull request #15 from nikdoof/renovate/helm-chart-releaser-action-1.x
Update helm/chart-releaser-action action to v1.4.1
2023-01-04 13:37:14 +00:00
renovate[bot]
98aacb8093 Update wemake-services/wemake-python-styleguide action to v0.17.0 2023-01-04 13:37:13 +00:00
b30fa38e41 Merge pull request #14 from nikdoof/renovate/azure-setup-helm-3.x
Update azure/setup-helm action to v3
2023-01-04 13:37:03 +00:00
c51ba0e0ae Merge pull request #13 from nikdoof/renovate/common-4.x
Update Helm release common to v4.5.2
2023-01-04 13:36:50 +00:00
renovate[bot]
692a7eab37 Update python Docker tag to v3.11 2022-11-20 21:36:25 +00:00
renovate[bot]
5d7db991e6 Update helm/chart-releaser-action action to v1.4.1 2022-11-20 21:36:18 +00:00
renovate[bot]
30835e4a6f Update azure/setup-helm action to v3 2022-09-25 20:20:42 +00:00
renovate[bot]
3b55f71db6 Update Helm release common to v4.5.2 2022-08-02 19:29:01 +00:00
d918d95c7f Bump helm chart version 2022-06-14 10:12:28 +01:00
70c71ff731 Merge pull request #9 from nikdoof/renovate/common-4.x
Update Helm release common to v4.4.2
2022-06-14 10:11:25 +01:00
b9dcd8a880 Merge pull request #10 from nikdoof/renovate/helm-chart-releaser-action-1.x
Update helm/chart-releaser-action action to v1.4.0
2022-06-14 10:11:15 +01:00
6e569e16c7 Update README.md 2022-06-14 10:10:53 +01:00
Renovate Bot
d1b1f94dc7 Update helm/chart-releaser-action action to v1.4.0 2022-06-09 14:07:47 +00:00
Renovate Bot
e762f99168 Update Helm release common to v4.4.2 2022-06-09 14:07:44 +00:00
2c35051b54 Clean up lint issues 2022-06-09 12:46:05 +01:00
dea3ae84bd Switch to use argparse 2022-06-09 12:43:45 +01:00
5d4f83bdc3 Sort imports 2022-06-09 12:12:32 +01:00
7f481c47ed Add Prometheus metrics 2022-06-09 12:11:48 +01:00
db04de242a Cleanup init and owner setup 2022-06-09 11:45:46 +01:00
5a61444535 Build command list for help from handlers 2022-06-09 11:36:40 +01:00
a89c560145 Fix release workflow 2022-06-09 10:21:16 +01:00
9adaf6c663 Add wheel requirements for dev 2022-06-09 10:17:32 +01:00
ddba5cb011 Version 0.0.3 2022-06-09 10:16:44 +01:00
c70b2485f5 Remove chart lock 2022-06-09 10:12:47 +01:00
e8e41bbfef Add helm chart 2022-06-09 10:11:17 +01:00
6fd8a2e8bd Fix linting issues 2022-06-09 09:57:53 +01:00
5c89056a9e Breakout into sub modules 2022-06-09 09:52:26 +01:00
0c260c9a08 Version 0.0.2 2022-06-08 20:02:16 +01:00
a2692fecc9 Fix linting issues 2022-06-08 19:56:16 +01:00
8f5218c2bb Fix owner env var 2022-06-08 19:27:05 +01:00
237db7fa7d Add health endpoint 2022-06-08 19:04:17 +01:00
f87f2f68a4 Fix syntax issues 2022-06-08 19:00:32 +01:00
c6a6ffbc9f Update README 2022-06-08 19:00:03 +01:00
509c6f7c43 Notify the owner of subscriptions 2022-06-08 18:59:57 +01:00
d54c860b16 Add owner ID and default subscriber settings 2022-06-08 18:57:02 +01:00
23 changed files with 1946 additions and 239 deletions

View File

@@ -12,11 +12,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v2
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
@@ -24,7 +24,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v3
uses: docker/build-push-action@v6
with:
push: true
tags: |

View File

@@ -1,6 +1,6 @@
name: Lint
'on':
"on":
push:
branches:
- main
@@ -9,13 +9,22 @@ name: Lint
- main
jobs:
run-linters:
name: Run linters
lint:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9"]
steps:
- name: Check out Git repository
uses: actions/checkout@v3
- name: wemake-python-styleguide
uses: wemake-services/wemake-python-styleguide@0.16.1
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Lint with ruff
run: |
make lint

32
.github/workflows/release-chart.yaml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Release Helm Chart
"on":
push:
branches:
- main
paths:
- "charts/**/Chart.yaml"
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.6.3
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.6.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
CR_RELEASE_NAME_TEMPLATE: "smsbot-helm-chart-{{ .Version }}"

View File

@@ -1,4 +1,3 @@
---
name: Release
on:
push:
@@ -10,18 +9,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: actions/setup-python@v4
- run: pip install -r requirements-dev.txt
- name: Setup Python
uses: actions/setup-python@v5
- name: Build Assets
run: python setup.py sdist bdist_wheel
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Build Release
run: poetry build
- name: Release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
name: "Version ${{ github.ref_name }}"
generate_release_notes: true
files: |
dist/*

View File

@@ -1,5 +1,34 @@
FROM python:3.10-alpine
WORKDIR /app
FROM python:3.9-alpine AS base
FROM base AS builder
ENV PYTHONFAULTHANDLER=1 \
PYTHONUNBUFFERED=1 \
PYTHONHASHSEED=random \
PIP_NO_CACHE_DIR=off \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=100 \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
PATH="$PATH:/runtime/bin" \
PYTHONPATH="$PYTHONPATH:/runtime/lib/python3.9/site-packages" \
# Versions:
POETRY_VERSION=2.1.1
# System deps:
RUN apk add build-base unzip wget python3-dev libffi-dev rust cargo openssl-dev
RUN pip install "poetry==$POETRY_VERSION" "poetry-plugin-export"
WORKDIR /src
# Generate requirements and install *all* dependencies.
COPY pyproject.toml poetry.lock /src/
RUN poetry export --without-hashes --no-interaction --no-ansi -f requirements.txt -o requirements.txt
RUN pip install --prefix=/runtime --force-reinstall -r requirements.txt
FROM base AS runtime
COPY --from=builder /runtime /usr/local
COPY . /app
RUN pip install .
ENTRYPOINT smsbot
WORKDIR /app
EXPOSE 8000/tcp
CMD ["/usr/local/bin/smsbot"]

7
Makefile Normal file
View File

@@ -0,0 +1,7 @@
.venv:
python3 -m pip install poetry
python3 -m poetry install --with github
lint: .venv
python3 -m poetry run ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py37 .
python3 -m poetry run ruff check --output-format=github --target-version=py37 .

View File

@@ -9,3 +9,29 @@ Forked from [FiveBoroughs/Twilio2Telegram](https://github.com/FiveBoroughs/Twili
This simple tool acts as a webhook receiver for the Twilio API, taking messages sent over and posting them on Telegram to a target chat or channel. This is useful for forwarding on 2FA messages, notification text messages for services that don't support international numbers (*cough* Disney, of all people).
The bot is designed to run within a Kubernetes environment, but can be operated as a individual container, or as a hand ran service.
## Configuration
All configuration is provided via environment variables
| Variable | Required? | Description |
| -------------------------- | --------- | --------------------------------------------------------------------------- |
| SMSBOT_DEFAULT_SUBSCRIBERS | No | A list of IDs, seperated by commas, to add to the subscribers list on start |
| SMSBOT_LISTEN_HOST | No | The host for the webhooks to listen on, defaults to `0.0.0.0` |
| SMSBOT_LISTEN_PORT | No | The port to listen to, defaults to `80` |
| SMSBOT_OWNER_ID | No | ID of the owner of this bot |
| SMSBOT_TELEGRAM_BOT_TOKEN | Yes | Your Bot Token for Telegram |
| SMSBOT_TWILIO_AUTH_TOKEN | No | Twilio auth token, used to validate any incoming webhook calls |
## Setup
To configure SMSBot, you'll need a Twilio account, either paid or trial is fine.
* Setup a number in the location you want.
* Under Phone Numbers -> Manage -> Active Numbers, click the number you want to setup.
* In the "Voice & Fax" section, update the "A Call Comes In" to the URL of your SMSBot instance, with the endpoint being `/call`, e.g. `http://mymachine.test.com/call`
* In the "Messaging" section, update the "A Message Comes In" to the URL of your SMSBot instance, with the endpoint being `/message`, e.g. `http://mymachine.test.com/message`
Your bot should now receive messages, on Telegram you need to start a chat or invite it into any channels you want, then update the `SMSBOT_DEFAULT_SUBSCRIBERS` values with their IDs.
**Note**: You cannot send test messages from your Twilio account to your Twilio numbers, they'll be silently dropped or fail with an "Invalid Number" error.

18
charts/smsbot/Chart.yaml Normal file
View File

@@ -0,0 +1,18 @@
apiVersion: v2
appVersion: 0.0.5
description: A simple Telegram bot to receive SMS messages.
name: smsbot
version: 0.0.6
kubeVersion: ">=1.19.0-0"
keywords:
- smsbot
home: https://github.com/nikdoof/smsbot/tree/main/charts/smsbot
sources:
- https://github.com/nikdoof/smsbot
maintainers:
- name: nikdoof
email: nikdoof@users.noreply.github.com
dependencies:
- name: common
repository: https://library-charts.k8s-at-home.com
version: 4.5.2

View File

@@ -0,0 +1,10 @@
env:
SMSBOT_TELEGRAM_BOT_TOKEN: xxxx
ingress:
main:
enabled: true
hosts:
- host: smsbot.domain.tld
paths:
- path: /
pathType: Prefix

View File

@@ -0,0 +1 @@
{{ include "common.notes.defaultNotes" . }}

View File

@@ -0,0 +1 @@
{{ include "common.all" . }}

35
charts/smsbot/values.yaml Normal file
View File

@@ -0,0 +1,35 @@
#
# IMPORTANT NOTE
#
# This chart inherits from the k8s@home library chart. You can check the default values/options here:
# https://github.com/k8s-at-home/library-charts/tree/main/charts/stable/common/values.yaml
#
image:
# -- image repository
repository: ghcr.io/nikdoof/smsbot
# -- image pull policy
pullPolicy: IfNotPresent
# -- image tag
tag: 0.0.5
# -- environment variables.
# @default -- See below
env:
# -- Set the container timezone
TZ: UTC
# SMSBOT_TELEGRAM_BOT_TOKEN:
# -- Configures service settings for the chart.
# @default -- See values.yaml
service:
main:
ports:
http:
port: 80
ingress:
# -- Enable and configure ingress settings for the chart under this key.
# @default -- See values.yaml
main:
enabled: false

1466
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
pyproject.toml Normal file
View File

@@ -0,0 +1,36 @@
[project]
name = "smsbot"
version = "0.0.5"
description = "A simple Telegram bot to receive SMS messages."
authors = [
{name = "Andrew Williams",email = "andy@tensixtyone.com"}
]
license = {text = "MIT"}
readme = "README.md"
requires-python = ">=3.9,<3.10"
dependencies = [
"flask (>=3.1.0,<4.0.0)",
"waitress (>=3.0.2,<4.0.0)",
"twilio (>=9.4.6,<10.0.0)",
"python-telegram-bot (<20)",
"prometheus-client (>=0.21.1,<0.22.0)"
]
[tool.poetry.scripts]
smsbot = "smsbot.cli:main"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
ruff = "^0.9.0"
pytest-mock = "^3.12.0"
pytest-flask = "^1.3.0"
[tool.poetry.group.github]
optional = true
[tool.poetry.group.github.dependencies]
pytest-github-actions-annotate-failures = "^0.3.0"

View File

@@ -1,4 +0,0 @@
flask
waitress
twilio
python-telegram-bot

View File

@@ -1,35 +0,0 @@
[metadata]
name = smsbot
version = 0.0.1
description = A simple Telegram bot to receive SMS messages.
long_description = file: README.md, LICENSE
license = MIT
license_file = LICENSE
classifiers =
Framework :: Flask
License :: OSI Approved :: MIT License
Programming Language :: Python :: 3
Programming Language :: Python :: 3.9
[options]
zip_safe = False
include_package_data = True
packages = smsbot
install_requires =
flask
waitress
twilio
python-telegram-bot
[options.entry_points]
console_scripts =
smsbot = smsbot:main
[flake8]
format = wemake
ignore = E501,D,WPS226,WPS110, WPS210,WPS231,WPS202
max-line-length = 120
exclude = setup.py
[darglint]
docstring_style=sphinx

View File

@@ -1,3 +0,0 @@
from setuptools import setup
setup()

View File

@@ -1,168 +0,0 @@
import logging
import os
import sys
from functools import wraps
from flask import Flask, abort, current_app, request
from telegram.ext import CommandHandler, Updater
from twilio.request_validator import RequestValidator
from waitress import serve
import pkg_resources
pkg_version = pkg_resources.require("smsbot")[0].version
class TelegramSmsBot(object):
owner_id = None
subscriber_ids = []
def __init__(self, token):
self.logger = logging.getLogger(self.__class__.__name__)
self.bot_token = token
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 not update.message.chat['id'] in self.subscriber_ids:
self.logger.info('{0} subscribed'.format(update.message.chat['username']))
self.subscriber_ids.append(update.message.chat['id'])
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'])
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)
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(f):
"""Validates that incoming requests genuinely originated from Twilio"""
@wraps(f)
def decorated_function(*args, **kwargs):
# 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 f(*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 f(*args, **kwargs)
else:
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('/message', 'message', self.message, methods=['POST'])
self.app.add_url_rule('/call', 'call', self.call, methods=['POST'])
def set_bot(self, bot):
self.bot = bot
def index(self):
return 'Smsbot v{0} - {1}'.format(pkg_version, request.values.to_dict())
@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 '<response></response>'
@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 '<Response><Reject/></Response>'
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)
telegram_bot.start()
# Start webhooks
webhooks = TwilioWebhookHandler()
webhooks.set_bot(telegram_bot)
webhooks.serve(host=listen_host, port=listen_port)

View File

@@ -1,4 +1,4 @@
from . import main
from smsbot.cli import main
if __name__ == '__main__':
main()

43
smsbot/cli.py Normal file
View File

@@ -0,0 +1,43 @@
import argparse
import logging
import os
from smsbot.telegram import TelegramSmsBot
from smsbot.webhook_handler import TwilioWebhookHandler
from smsbot.utils import get_smsbot_version
def main():
parser = argparse.ArgumentParser('smsbot')
parser.add_argument('--listen-host', default=os.environ.get('SMSBOT_LISTEN_HOST') or '0.0.0.0')
parser.add_argument('--listen-port', default=os.environ.get('SMSBOT_LISTEN_PORT') or '80')
parser.add_argument('--telegram-bot-token', default=os.environ.get('SMSBOT_TELEGRAM_BOT_TOKEN'))
parser.add_argument('--owner-id', default=os.environ.get('SMSBOT_OWNER_ID'))
parser.add_argument('--default-subscribers', default=os.environ.get('SMSBOT_DEFAULT_SUBSCRIBERS'))
parser.add_argument('--log-level', default='INFO')
args = parser.parse_args()
logging.basicConfig(level=logging.getLevelName(args.log_level))
logging.info('smsbot v%s', get_smsbot_version())
logging.debug('Arguments: %s', args)
# Start bot
telegram_bot = TelegramSmsBot(args.telegram_bot_token)
# Set the owner ID if configured
if args.owner_id:
telegram_bot.set_owner(args.owner_id)
else:
logging.warning('No Owner ID is set, which is not a good idea...')
# Add default subscribers
if args.default_subscribers:
for chat_id in args.default_subscribers.split(','):
telegram_bot.add_subscriber(chat_id)
telegram_bot.start()
# Start webhooks
webhooks = TwilioWebhookHandler()
webhooks.set_bot(telegram_bot)
webhooks.serve(host=args.listen_host, port=args.listen_port)

97
smsbot/telegram.py Normal file
View File

@@ -0,0 +1,97 @@
import logging
from prometheus_client import Counter, Summary
from telegram.ext import CommandHandler, Updater
from smsbot.utils import get_smsbot_version
REQUEST_TIME = Summary('telegram_request_processing_seconds', 'Time spent processing request')
COMMAND_COUNT = Counter('telegram_command_count', 'Total number of commands processed')
class TelegramSmsBot(object):
def __init__(self, telegram_token, allow_subscribing=False, owner=None, subscribers=None):
self.logger = logging.getLogger(self.__class__.__name__)
self.bot_token = telegram_token
self.subscriber_ids = subscribers or []
self.set_owner(owner)
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))
if allow_subscribing:
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)
def start(self):
self.logger.info('Starting bot...')
self.updater.start_polling()
self.bot = self.updater.bot
self.logger.info('Bot Ready')
def stop(self):
self.updater.stop()
@REQUEST_TIME.time()
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)
commands = []
for command in self.updater.dispatcher.handlers[0]:
commands.extend(['/{0}'.format(cmd) for cmd in command.command])
update.message.reply_markdown('Smsbot v{0}\n\n{1}'.format(get_smsbot_version(), '\n'.join(commands)))
COMMAND_COUNT.inc()
@REQUEST_TIME.time()
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')
COMMAND_COUNT.inc()
@REQUEST_TIME.time()
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')
COMMAND_COUNT.inc()
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
if self.owner_id and self.owner_id not in self.subscriber_ids:
self.subscriber_ids.append(self.owner_id)
def add_subscriber(self, chat_id):
self.subscriber_ids.append(chat_id)

5
smsbot/utils.py Normal file
View File

@@ -0,0 +1,5 @@
from importlib.metadata import version
def get_smsbot_version():
return version("smsbot")

98
smsbot/webhook_handler.py Normal file
View File

@@ -0,0 +1,98 @@
import os
from functools import wraps
from flask import Flask, abort, current_app, request
from prometheus_client import Counter, Summary, make_wsgi_app
from twilio.request_validator import RequestValidator
from waitress import serve
from werkzeug.middleware.dispatcher import DispatcherMiddleware
from smsbot.utils import get_smsbot_version
REQUEST_TIME = Summary('webhook_request_processing_seconds', 'Time spent processing request')
MESSAGE_COUNT = Counter('webhook_message_count', 'Total number of messages processed')
CALL_COUNT = Counter('webhook_call_count', 'Total number of calls processed')
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'])
# Add prometheus wsgi middleware to route /metrics requests
self.app.wsgi_app = DispatcherMiddleware(self.app.wsgi_app, {
'/metrics': make_wsgi_app(),
})
def set_bot(self, bot): # noqa: WPS615
self.bot = bot
def index(self):
return ''
@REQUEST_TIME.time()
def health(self):
return {
'version': get_smsbot_version(),
'owner': self.bot.owner_id,
'subscribers': self.bot.subscriber_ids,
}
@REQUEST_TIME.time()
@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
MESSAGE_COUNT.inc()
return '<response></response>'
@REQUEST_TIME.time()
@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
CALL_COUNT.inc()
return '<Response><Reject/></Response>'
def serve(self, host='0.0.0.0', port=80, debug=False):
serve(self.app, host=host, port=port)