4 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
816ef7fd06 Revert LICENSE and README.md changes, update Dockerfile to use multi-stage build with uv
Co-authored-by: nikdoof <170514+nikdoof@users.noreply.github.com>
2025-07-17 17:20:13 +00:00
copilot-swe-agent[bot]
59aadd22c1 Clean up temporary files and update .gitignore for uv migration
Co-authored-by: nikdoof <170514+nikdoof@users.noreply.github.com>
2025-07-16 23:25:41 +00:00
copilot-swe-agent[bot]
88cb3388af Migrate from Poetry to uv: update pyproject.toml, Taskfile, GitHub Actions, and Dockerfile
Co-authored-by: nikdoof <170514+nikdoof@users.noreply.github.com>
2025-07-16 23:24:01 +00:00
copilot-swe-agent[bot]
5556a0f650 Initial plan 2025-07-16 23:14:24 +00:00
30 changed files with 749 additions and 1336 deletions

View File

@@ -1,20 +0,0 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/python
{
"name": "Development",
"image": "mcr.microsoft.com/devcontainers/python:1-3.13-bookworm",
"features": {
"ghcr.io/eitsupi/devcontainer-features/go-task:1": {},
"ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}
},
"forwardPorts": [
5000
],
"portsAttributes": {
"5000": {
"label": "Application",
"protocol": "http",
"public": true
}
}
}

View File

@@ -22,18 +22,11 @@ jobs:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v5
- name: Set build args
run: |
echo "PYTHON_VERSION=$(cat .python-version)" >> $GITHUB_ENV
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64,linux/arm64
build-args: |
PYTHON_VERSION=${{ env.PYTHON_VERSION }}
tags: |
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
ghcr.io/${{ github.repository }}:latest
ghcr.io/${{ github.repository_owner }}/smsbot:${{ github.ref_name }}
ghcr.io/${{ github.repository_owner }}/smsbot:latest

View File

@@ -1,29 +1,33 @@
name: Run Lint
name: Lint
"on":
push:
branches:
- "main"
- main
pull_request:
branches:
- main
jobs:
lint:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.13"]
python-version: ["3.9"]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v4
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
- name: Set up Python ${{ matrix.python-version }}
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Run Lint
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Lint with ruff
run: |
task python: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.7.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
CR_RELEASE_NAME_TEMPLATE: "smsbot-helm-chart-{{ .Version }}"

View File

@@ -1,6 +1,5 @@
name: Release
"on":
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+"
@@ -10,16 +9,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.9"
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Build Release
run: uv build
run: poetry build
- name: Release
uses: softprops/action-gh-release@v2

View File

@@ -1,29 +0,0 @@
name: Run Tests
"on":
push:
branches:
- "main"
pull_request:
jobs:
pytest:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.13"]
steps:
- uses: actions/checkout@v5
- name: Install Task
uses: arduino/setup-task@v2
with:
version: 3.x
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Run Tests
run: |
task python:tests

120
.gitignore vendored
View File

@@ -1,6 +1,6 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*.py[cod]
*$py.class
# C extensions
@@ -20,7 +20,6 @@ parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
@@ -39,17 +38,14 @@ pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
@@ -59,7 +55,6 @@ cover/
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
@@ -72,71 +67,22 @@ instance/
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
# celery beat schedule file
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
@@ -156,62 +102,12 @@ venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# Task completions
completion/
# pytype static type analyzer
.pytype/
# Build artifacts
dist/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
# uv cache
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
!*.code-workspace
# Built Visual Studio Code Extensions
*.vsix
# smsbot config file
config.ini

View File

@@ -1 +0,0 @@
3.13

View File

@@ -1,22 +1,22 @@
ARG PYTHON_VERSION="3.13"
FROM python:3.9-alpine AS base
FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim AS builder
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
ENV UV_PYTHON_DOWNLOADS=0
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# Builder
FROM base AS builder
# Install uv
RUN pip install uv
FROM python:${PYTHON_VERSION}-slim-bookworm
COPY --from=builder --chown=app:app /app /app
COPY ./docs/examples/config-basic.ini /app/config.ini
ENV PATH="/app/.venv/bin:$PATH"
EXPOSE 5000/tcp
WORKDIR /app
CMD ["smsbot"]
WORKDIR /src
COPY uv.lock pyproject.toml README.md /src/
COPY smsbot /src/smsbot
# Create virtual environment and install dependencies
RUN uv sync --frozen --no-dev
# Final container
FROM base AS runtime
COPY --from=builder /src/.venv /runtime
ENV PATH=/runtime/bin:$PATH
EXPOSE 80/tcp
CMD ["smsbot"]

View File

@@ -12,61 +12,26 @@ The bot is designed to run within a Kubernetes environment, but can be operated
## Configuration
SMSBot can be configured using either a configuration file or environment variables. Environment variables will override any values set in the configuration file.
All configuration is provided via environment variables
### Configuration File
Create a configuration file (e.g., `config.ini`) based on the provided `config-example.ini`:
```ini
[logging]
level = INFO
[webhook]
host = 127.0.0.1
port = 80
[telegram]
owner_id = OWNER_USER_ID
bot_token = BOT_TOKEN
[twilio]
account_sid = TWILIO_ACCOUNT_SID
auth_token = TWILIO_AUTH_TOKEN
```
### Environment Variables
All configuration options can be overridden using environment variables:
| Environment Variable | Config Section | Config Key | Required? | Description |
| --------------------------- | -------------- | ----------- | --------- | --------------------------------------------------------------------------- |
| SMSBOT_LOGGING_LEVEL | logging | level | No | The log level to output to the console, defaults to `INFO` |
| SMSBOT_TELEGRAM_BOT_TOKEN | telegram | bot_token | Yes | Your Bot Token for Telegram |
| SMSBOT_TELEGRAM_OWNER_ID | telegram | owner_id | No | ID of the owner of this bot |
| SMSBOT_TELEGRAM_SUBSCRIBERS | telegram | subscribers | No | A list of IDs, separated by commas, to add to the subscribers list on start |
| SMSBOT_TWILIO_ACCOUNT_SID | twilio | account_sid | No | Twilio account SID |
| SMSBOT_TWILIO_AUTH_TOKEN | twilio | auth_token | No | Twilio auth token, used to validate any incoming webhook calls |
| SMSBOT_WEBHOOK_HOST | webhook | host | No | The host for the webhooks to listen on, defaults to `127.0.0.1` |
| SMSBOT_WEBHOOK_PORT | webhook | port | No | The port to listen to, defaults to `80` |
| 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.
To configure SMSBot, you'll need a Twilio account, either paid or trial is fine.
1. Copy `config-example.ini` to `config.ini` and update the values, or set the appropriate environment variables.
2. Setup a number in the location you want.
3. Under Phone Numbers -> Manage -> Active Numbers, click the number you want to setup.
4. 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`
5. 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`
* 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_TELEGRAM_SUBSCRIBERS` values with their IDs.
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.
## Helm Chart
Previously this repository had a Helm chart to configure smsbot for Kubernetes clusters. It was removed as the chart itself didn't offer any additional functionality outside of creating the deployment by hand. If you want to use Helm then I suggest using one of the following generic charts that can be used to deploy applications:
* https://github.com/stakater/application
* https://github.com/nikdoof/helm-charts/tree/master/charts/common-chart

View File

@@ -1,27 +1,17 @@
version: 3
tasks:
default:
deps:
- python:tests
- python:lint
python:tests:
desc: Run Python tests
python:env:
cmds:
- uv run --dev --group github pytest
- uv sync --extra dev --extra github
python:lint:
desc: Lint Python files
deps:
- python:env
cmds:
- uv run --dev ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py39 .
- uv run --dev ruff check --output-format=github --target-version=py39 .
- uv run ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py39 .
- uv run ruff check --output-format=github --target-version=py39 .
docker:build:
desc: Build the container using Docker
cmds:
- docker build . --build-arg PYTHON_VERSION=$(cat .python-version) -t smsbot:latest
smsbot:run:
desc: Run the SMSBot
cmds:
- uv run smsbot
- docker build . -t smsbot:latest

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

View File

@@ -1,5 +0,0 @@
# Example deployments
Examples of how to deploy SMSBot.
* [Flux HelmRelease](flux-helmrelease.yaml) - An example Flux `HelmRelease` using a common chart for basic deployment.

View File

@@ -1,2 +0,0 @@
[logging]
level = INFO

View File

@@ -1,16 +0,0 @@
[logging]
level = INFO
[webhook]
host = 127.0.0.1
port = 80
[telegram]
owner_id = OWNER_USER_ID
subscribers = 1111,2222,3333
bot_token = BOT_TOKEN
[twilio]
account_sid = TWILIO_ACCOUNT_SID
auth_token = TWILIO_AUTH_TOKEN
from_number = +12345678901

View File

@@ -1,55 +0,0 @@
---
# yaml-language-server: $schema=https://nikdoof.github.io/flux-gitops/schemas/source.toolkit.fluxcd.io/helmrepository_v1.json
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: nikdoof
namespace: flux-system
spec:
interval: 4h
url: https://nikdoof.github.io/helm-charts/
---
# yaml-language-server: $schema=https://nikdoof.github.io/flux-gitops/schemas/helm.toolkit.fluxcd.io/helmrelease_v2.json
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: smsbot
spec:
interval: 12h
chart:
spec:
chart: common-chart
version: 1.2.3
sourceRef:
kind: HelmRepository
name: nikdoof
namespace: flux-system
interval: 12h
values:
global:
nameOverride: smsbot
image:
repository: ghcr.io/nikdoof/smsbot
tag: 0.2.0
imagePullPolicy: IfNotPresent
controller:
strategy: Recreate
annotations:
secret.reloader.stakater.com/reload: "smsbot-secrets"
envFrom:
- secretRef:
name: smsbot-secrets
service:
main:
ports:
http:
port: 5000
ingress:
main:
enabled: true
hosts:
- host: smsbot-webhooks.example.com
paths:
- path: /
pathType: Prefix

View File

@@ -1,35 +1,33 @@
[project]
name = "smsbot"
version = "0.2.2"
version = "0.0.6"
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.13"
requires-python = ">=3.9,<3.10"
dependencies = [
"flask[async]>=3.1.1",
"prometheus-async>=25.1.0",
"prometheus-client>=0.22.1",
"python-telegram-bot>=22.3",
"twilio>=9.7.0",
"uvicorn>=0.35.0",
"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",
]
[project.scripts]
smsbot = "smsbot.cli:main"
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"ruff>=0.12.0",
"pytest-mock>=3.12.0",
"pytest-flask>=1.3.0",
]
github = [
"pytest-github-actions-annotate-failures>=0.3.0",
]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pytest>=8.4.1",
"pytest-flask>=1.3.0",
"pytest-mock>=3.14.1",
"ruff>=0.12.9",
]
github = ["pytest-github-actions-annotate-failures>=0.3.0"]
[tool.ruff]
line-length = 120

View File

@@ -1,156 +1,52 @@
import argparse
import asyncio
import logging
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
from smsbot.telegram import TelegramSmsBot
from smsbot.utils import get_smsbot_version
from smsbot.webhook import TwilioWebhookHandler
# Prefix of the environment variables to override config values
ENVIRONMENT_PREFIX = "SMSBOT_"
from smsbot.webhook_handler import TwilioWebhookHandler
def main():
parser = argparse.ArgumentParser("smsbot")
parser.add_argument(
"-c",
"--config",
default=os.environ.get("SMSBOT_CONFIG_FILE", "config.ini"),
type=argparse.FileType("r"),
help="Path to the config file",
"--listen-host", default=os.environ.get("SMSBOT_LISTEN_HOST") or "0.0.0.0"
)
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
parser.add_argument("--log-file", type=argparse.FileType("a"), help="Path to the log file", default=sys.stdout)
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()
if args.debug:
level = logging.DEBUG
else:
level = logging.INFO
logging.basicConfig(level=level, stream=args.log_file)
# TODO: Replace for Py >=3.11
logging.basicConfig(level=logging.getLevelName(args.log_level))
logging.info("smsbot v%s", get_smsbot_version())
logging.debug("Arguments: %s", args)
# Load configuration ini file if provided
config = ConfigParser()
if args.config:
logging.info("Loading configuration from %s", args.config.name)
config.read_file(args.config)
# Override with environment variables, named SMSBOT_<SECTION>_<VALUE>
for key, value in os.environ.items():
if key.startswith(ENVIRONMENT_PREFIX):
try:
section, option = key[7:].lower().split("_", 1)
except ValueError:
logging.debug("Invalid environment variable format: %s", key)
continue
logging.debug("Overriding config %s/%s = %s", section, option, value)
if not config.has_section(section):
config.add_section(section)
config[section][option] = value
# 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."
)
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"),
twilio_client=twilio_client,
twilio_from_number=config.get("twilio", "from_number", fallback=None),
)
telegram_bot = TelegramSmsBot(args.telegram_bot_token)
# Set the owner ID if configured
if config.has_option("telegram", "owner_id"):
telegram_bot.owners = [config.getint("telegram", "owner_id")]
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 config.has_option("telegram", "subscribers"):
for chat_id in config.get("telegram", "subscribers").split(","):
telegram_bot.subscribers.append(int(chat_id.strip()))
if args.default_subscribers:
for chat_id in args.default_subscribers.split(","):
telegram_bot.add_subscriber(chat_id)
# Init the webhook handler
webhooks = TwilioWebhookHandler(
account_sid=config.get("twilio", "account_sid", fallback=None),
auth_token=config.get("twilio", "auth_token", fallback=None),
)
webhooks.set_telegram_application(telegram_bot)
telegram_bot.start()
# Build a uvicorn ASGI server
flask_app = uvicorn.Server(
config=uvicorn.Config(
app=WsgiToAsgi(webhooks.app),
port=config.getint("webhook", "port", fallback=5000),
use_colors=False,
host=config.get("webhook", "host", fallback="127.0.0.1"),
)
)
# Loop until exit
loop = asyncio.get_event_loop()
main_task = asyncio.ensure_future(run_bot(telegram_bot, flask_app))
for signal in [SIGINT, SIGTERM]:
loop.add_signal_handler(signal, main_task.cancel)
try:
loop.run_until_complete(main_task)
# Catch graceful shutdowns
except asyncio.CancelledError:
pass
finally:
loop.close()
async def run_bot(telegram_bot: TelegramSmsBot, flask_app):
# Start async Telegram bot
try:
# Start the bot
await telegram_bot.app.initialize()
await telegram_bot.app.start()
await telegram_bot.app.updater.start_polling()
# Startup uvicorn/flask
await flask_app.serve()
# Run the bot idle loop
await telegram_bot.app.updater.idle()
finally:
# Shutdown in reverse order
await flask_app.shutdown()
await telegram_bot.app.updater.stop()
await telegram_bot.app.stop()
await telegram_bot.app.shutdown()
# Start webhooks
webhooks = TwilioWebhookHandler()
webhooks.set_bot(telegram_bot)
webhooks.serve(host=args.listen_host, port=args.listen_port)

View File

@@ -1,129 +1,118 @@
import logging
from prometheus_client import Counter, Summary
from telegram import Update
from telegram.ext import (
Application,
ApplicationHandlerStop,
CommandHandler,
ContextTypes,
TypeHandler,
)
from twilio.rest import Client
from telegram.ext import CommandHandler, Updater
from smsbot.utils import get_smsbot_version
REQUEST_TIME = Summary("telegram_request_processing_seconds", "Time spent processing request")
REQUEST_TIME = Summary(
"telegram_request_processing_seconds", "Time spent processing request"
)
COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands processed")
class TelegramSmsBot:
class TelegramSmsBot(object):
def __init__(
self,
token: str,
twilio_client: Client | None = None,
twilio_from_number: str | None = None,
owners: list[int] = [],
subscribers: list[int] = [],
self, telegram_token, allow_subscribing=False, owner=None, subscribers=None
):
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.bot_token = telegram_token
self.subscriber_ids = subscribers or []
self.set_owner(owner)
self.init_handlers()
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))
def init_handlers(self):
self.app.add_handler(TypeHandler(Update, self.callback), -1)
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))
if allow_subscribing:
self.updater.dispatcher.add_handler(
CommandHandler("subscribe", self.subscribe_handler)
)
self.updater.dispatcher.add_handler(
CommandHandler("unsubscribe", self.unsubscribe_handler)
)
async def callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle the update"""
if update.effective_user and update.message:
if update.effective_user.id in self.owners:
self.logger.info(f"{update.effective_user.username} sent {update.message.text}")
COMMAND_COUNT.inc()
else:
self.logger.debug(f"Ignoring message from user {update.effective_user.username}")
raise ApplicationHandlerStop
self.updater.dispatcher.add_error_handler(self.error_handler)
async def send_message(self, chat_id: int, text: str):
"""Send a message to a specific chat"""
self.logger.info(f"Sending message to chat {chat_id}: {text}")
await self.app.bot.send_message(chat_id=chat_id, text=text, parse_mode="MarkdownV2")
def start(self):
self.logger.info("Starting bot...")
self.updater.start_polling()
self.bot = self.updater.bot
self.logger.info("Bot Ready")
async def send_subscribers(self, text: str):
"""Send a message to all subscribers"""
for subscriber in self.subscribers:
self.logger.info(f"Sending message to subscriber {subscriber}")
await self.send_message(subscriber, text)
async def send_owners(self, text: str):
"""Send a message to all owners"""
for owner in self.owners:
self.logger.info(f"Sending message to owner {owner}")
await self.send_message(owner, text)
def stop(self):
self.updater.stop()
@REQUEST_TIME.time()
async def handler_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
def help_handler(self, update, context):
"""Send a message when the command /help is issued."""
if update.message:
self.logger.info("/help command received in chat: %s", update.message.chat)
self.logger.info("/help command received in chat: %s", update.message.chat)
commands = []
for command in self.app.handlers[0]:
if isinstance(command, CommandHandler):
commands.extend(["/{0}".format(cmd) for cmd in command.commands])
commands = []
for command in self.updater.dispatcher.handlers[0]:
commands.extend(["/{0}".format(cmd) for cmd in command.command])
await update.message.reply_markdown("Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands)))
COMMAND_COUNT.inc()
update.message.reply_markdown(
"Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands))
)
COMMAND_COUNT.inc()
@REQUEST_TIME.time()
async def handler_subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle subscription requests"""
if update.effective_user and update.message:
user_id = update.effective_user.id
if user_id not in self.subscribers:
self.subscribers.append(user_id)
self.logger.info(f"User {user_id} subscribed.")
self.logger.info(f"Current subscribers: {self.subscribers}")
await update.message.reply_markdown("You have successfully subscribed to updates.")
else:
self.logger.info(f"User {user_id} is already subscribed.")
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()
async def handler_unsubscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle unsubscription requests"""
if update.effective_user and update.message:
user_id = update.effective_user.id
if user_id in self.subscribers:
self.subscribers.remove(user_id)
self.logger.info(f"User {user_id} unsubscribed.")
self.logger.info(f"Current subscribers: {self.subscribers}")
await update.message.reply_markdown("You have successfully unsubscribed from updates.")
else:
self.logger.info(f"User {user_id} is not subscribed.")
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()
@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}")
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)
)
try:
self.twilio_client.messages.create(body=message, to=to, from_=self.twilio_from_number)
except Exception:
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.")
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)

View File

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

View File

@@ -1,79 +0,0 @@
class TwilioWebhookPayload:
@staticmethod
def parse(data: dict[str, str]) -> "TwilioCall | TwilioMessage | None":
"""Return the correct class for the incoming Twilio webhook payload"""
if "SmsMessageSid" in data:
return TwilioMessage(data)
if "CallSid" in data:
return TwilioCall(data)
def _escape(self, text: str) -> str:
"""Escape text for MarkdownV2"""
characters = [
"_",
"*",
"[",
"]",
"(",
")",
"~",
"`",
">",
"#",
"+",
"-",
"=",
"|",
"{",
"}",
".",
"!",
]
for char in characters:
text = text.replace(char, rf"\{char}")
return text
class TwilioMessage(TwilioWebhookPayload):
"""Represents a Twilio SMS message"""
def __init__(self, data: dict) -> None:
self.from_number: str = data.get("From", "Unknown")
self.to_number: str = data.get("To", "Unknown")
self.body: str = data.get("Body", "")
self.media = []
for i in range(0, int(data.get("NumMedia", "0"))):
self.media.append(data.get(f"MediaUrl{i}"))
def __repr__(self) -> str:
return f"TwilioWebhookMessage(from={self.from_number}, to={self.to_number})"
def to_str(self) -> str:
media_str = "\n".join([f"<{url}>" for url in self.media]) if self.media else ""
msg = f"**From**: {self.from_number}\n**To**: {self.to_number}\n\n{self.body}\n\n{media_str}"
return msg
def to_markdownv2(self):
media_str = "\n".join([f"{self._escape(url)}" for url in self.media]) if self.media else ""
msg = f"**From**: {self._escape(self.from_number)}\n**To**: {self._escape(self.to_number)}\n\n{self._escape(self.body)}\n\n{media_str}"
return msg
class TwilioCall(TwilioWebhookPayload):
"""Represents a Twilio voice call"""
def __init__(self, data: dict) -> None:
self.from_number: str = data.get("From", "Unknown")
self.to_number: str = data.get("To", "Unknown")
def __repr__(self) -> str:
return f"TwilioCall(from={self.from_number}, to={self.to_number})"
def to_str(self) -> str:
msg = f"Call from {self.from_number}, rejected."
return msg
def to_markdownv2(self) -> str:
msg = f"Call from {self._escape(self.from_number)}, rejected\\."
return msg

View File

@@ -1,109 +0,0 @@
from functools import wraps
from flask import Flask, abort, current_app, request
from prometheus_async.aio import time
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 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")
CALL_COUNT = Counter("webhook_call_count", "Total number of calls processed")
class TwilioWebhookHandler(object):
"""
A wrapped Flask app handling webhooks received from Twilio
"""
def __init__(self, account_sid: str | None = None, auth_token: str | None = None):
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"])
# Twilio auth details
self.account_sid = account_sid
self.auth_token = auth_token
# Wrap validation around hook endpoints
self.message = self.validate_twilio_request(self.message)
self.call = self.validate_twilio_request(self.call)
# Add prometheus wsgi middleware to route /metrics requests
self.app.wsgi_app = DispatcherMiddleware(
self.app.wsgi_app,
{
"/metrics": make_wsgi_app(),
},
)
def validate_twilio_request(self, func):
"""Validates that incoming requests genuinely originated from Twilio"""
@wraps(func)
async def decorated_function(*args, **kwargs):
# Create an instance of the RequestValidator class
if not self.auth_token:
current_app.logger.warning("Twilio request validation skipped due to Twilio Auth Token missing")
return await func(*args, **kwargs)
validator = RequestValidator(self.auth_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 await func(*args, **kwargs)
return abort(403)
return decorated_function
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()} - <a href="https://github.com/nikdoof/smsbot">GitHub</a>'
async def health(self) -> dict[str, str | int]:
"""Return basic health information"""
return {
"version": get_smsbot_version(),
"owners": self.telegram_app.owners,
"subscribers": len(self.telegram_app.subscribers),
}
@time(REQUEST_TIME)
async def message(self) -> str:
"""Handle incoming SMS messages from Twilio"""
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.telegram_app.send_subscribers(hook_data.to_markdownv2())
# Return a blank response
MESSAGE_COUNT.inc()
return '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
@time(REQUEST_TIME)
async def call(self) -> str:
"""Handle incoming calls from Twilio"""
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.telegram_app.send_subscribers(hook_data.to_markdownv2())
# Always reject calls
CALL_COUNT.inc()
return '<?xml version="1.0" encoding="UTF-8"?><Response><Reject/></Response>'

109
smsbot/webhook_handler.py Normal file
View File

@@ -0,0 +1,109 @@
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)

View File

@@ -1,3 +0,0 @@
def test_import():
import smsbot.utils
assert smsbot.utils.get_smsbot_version()

View File

@@ -1,69 +0,0 @@
from smsbot.utils.twilio import TwilioMessage
def test_twiliomessage_normal():
instance = TwilioMessage(
{
"From": "+1234567890",
"To": "+0987654321",
"Body": "Hello, world!",
"NumMedia": "2",
"MediaUrl0": "http://example.com/media1.jpg",
"MediaUrl1": "http://example.com/media2.jpg",
}
)
assert instance.from_number == "+1234567890"
assert instance.to_number == "+0987654321"
assert instance.body == "Hello, world!"
assert instance.media == [
"http://example.com/media1.jpg",
"http://example.com/media2.jpg",
]
def test_twiliomessage_no_media():
instance = TwilioMessage(
{
"From": "+1234567890",
"To": "+0987654321",
"Body": "Hello, world!",
}
)
assert instance.media == []
def test_twiliomessage_invalid_media_count():
instance = TwilioMessage(
{
"From": "+1234567890",
"To": "+0987654321",
"Body": "Hello, world!",
"NumMedia": "0",
"MediaUrl0": "http://example.com/media1.jpg",
"MediaUrl1": "http://example.com/media2.jpg",
}
)
assert instance.media == []
def test_twiliomessage_invalid_media_count_extra():
instance = TwilioMessage(
{
"From": "+1234567890",
"To": "+0987654321",
"Body": "Hello, world!",
"NumMedia": "5",
"MediaUrl0": "http://example.com/media1.jpg",
"MediaUrl1": "http://example.com/media2.jpg",
}
)
assert instance.media == [
"http://example.com/media1.jpg",
"http://example.com/media2.jpg",
None,
None,
None,
]

812
uv.lock generated

File diff suppressed because it is too large Load Diff