mirror of
https://github.com/nikdoof/smsbot.git
synced 2025-12-13 18:12:15 +00:00
Compare commits
62 Commits
copilot/fi
...
renovate/p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7129632b4e | ||
|
|
44b5010432 | ||
|
|
933d2299fa | ||
|
|
6b9349e3c1 | ||
|
|
3c3533ce71 | ||
|
|
afe2d8d966 | ||
|
|
c39e06bd83 | ||
|
|
0c44f14cc8 | ||
|
|
e6201f09cc | ||
|
|
a85ee22d01 | ||
|
dc445bcc66
|
|||
|
d00a4048cd
|
|||
|
bb5472f092
|
|||
|
6ce86042fb
|
|||
|
0a2970c38f
|
|||
|
126713c84a
|
|||
|
bace0200ab
|
|||
|
e98b8e6b8c
|
|||
| 876b0363c0 | |||
|
d0c18a1d00
|
|||
|
b056d6328d
|
|||
|
40a263686d
|
|||
|
5f1e5508f0
|
|||
|
|
6f36caf6e1 | ||
|
40e23c32a8
|
|||
|
51f4a62738
|
|||
|
b0afb9b15d
|
|||
|
1e28526be7
|
|||
|
594f4ba8ef
|
|||
|
b4e833f440
|
|||
|
4de940be7c
|
|||
|
837b959b9b
|
|||
|
8ba995bc5f
|
|||
|
70282e3596
|
|||
|
facb9c4991
|
|||
|
fb5e1bffee
|
|||
|
34ba83ffb8
|
|||
|
23984da65a
|
|||
|
4206bb63f4
|
|||
|
f52d8ca81e
|
|||
|
1872a97088
|
|||
|
51d37a3e61
|
|||
|
8ff16ba9d3
|
|||
|
c4e586edb6
|
|||
|
64de3a0704
|
|||
|
67faa72f7f
|
|||
|
605a5633f9
|
|||
|
8e1ce5bd89
|
|||
|
78725e3d06
|
|||
|
7fc2616b4b
|
|||
|
f7b570d786
|
|||
|
c5be81b48e
|
|||
|
|
ce64b3693d | ||
|
|
7863c016c7 | ||
|
|
354f033744 | ||
|
|
418dd333b8 | ||
|
|
31aa0a39ed | ||
|
|
8abb5f97da | ||
|
|
3c348d9bd6 | ||
| 1d8edd8ed4 | |||
|
|
3050e0be45 | ||
|
|
740a0560e4 |
20
.devcontainer/devcontainer.json
Normal file
20
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
.github/workflows/build-container.yaml
vendored
11
.github/workflows/build-container.yaml
vendored
@@ -22,11 +22,18 @@ jobs:
|
|||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Build and push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
push: true
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
build-args: |
|
||||||
|
PYTHON_VERSION=${{ env.PYTHON_VERSION }}
|
||||||
tags: |
|
tags: |
|
||||||
ghcr.io/${{ github.repository_owner }}/smsbot:${{ github.ref_name }}
|
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||||
ghcr.io/${{ github.repository_owner }}/smsbot:latest
|
ghcr.io/${{ github.repository }}:latest
|
||||||
|
|||||||
18
.github/workflows/lint.yaml
vendored
18
.github/workflows/lint.yaml
vendored
@@ -1,33 +1,29 @@
|
|||||||
name: Lint
|
name: Run Lint
|
||||||
|
|
||||||
"on":
|
"on":
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- "main"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
python-version: ["3.9"]
|
python-version: ["3.13"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
uses: arduino/setup-task@v2
|
uses: arduino/setup-task@v2
|
||||||
with:
|
with:
|
||||||
version: 3.x
|
version: 3.x
|
||||||
- name: Setup Python
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v4
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
- name: Run Lint
|
||||||
version: "latest"
|
|
||||||
- name: Lint with ruff
|
|
||||||
run: |
|
run: |
|
||||||
task python:lint
|
task python:lint
|
||||||
|
|||||||
32
.github/workflows/release-chart.yaml
vendored
32
.github/workflows/release-chart.yaml
vendored
@@ -1,32 +0,0 @@
|
|||||||
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 }}"
|
|
||||||
16
.github/workflows/release.yaml
vendored
16
.github/workflows/release.yaml
vendored
@@ -1,5 +1,6 @@
|
|||||||
name: Release
|
name: Release
|
||||||
on:
|
|
||||||
|
"on":
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
- "[0-9]+.[0-9]+.[0-9]+"
|
||||||
@@ -9,21 +10,16 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
|
||||||
python-version: "3.9"
|
|
||||||
|
|
||||||
- name: Install Poetry
|
- name: Install uv
|
||||||
uses: snok/install-poetry@v1
|
uses: astral-sh/setup-uv@v6
|
||||||
with:
|
|
||||||
virtualenvs-create: true
|
|
||||||
virtualenvs-in-project: true
|
|
||||||
|
|
||||||
- name: Build Release
|
- name: Build Release
|
||||||
run: poetry build
|
run: uv build
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|||||||
29
.github/workflows/tests.yaml
vendored
Normal file
29
.github/workflows/tests.yaml
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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@v6
|
||||||
|
- name: Run Tests
|
||||||
|
run: |
|
||||||
|
task python:tests
|
||||||
122
.gitignore
vendored
122
.gitignore
vendored
@@ -1,6 +1,6 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[codz]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
@@ -20,6 +20,7 @@ parts/
|
|||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
@@ -38,14 +39,17 @@ pip-delete-this-directory.txt
|
|||||||
# Unit test / coverage reports
|
# Unit test / coverage reports
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
|
.nox/
|
||||||
.coverage
|
.coverage
|
||||||
.coverage.*
|
.coverage.*
|
||||||
.cache
|
.cache
|
||||||
nosetests.xml
|
nosetests.xml
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
|
*.py.cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
@@ -55,6 +59,7 @@ coverage.xml
|
|||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
@@ -67,22 +72,71 @@ instance/
|
|||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
# pyenv
|
# IPython
|
||||||
.python-version
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
# celery beat schedule file
|
# 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
|
||||||
|
|
||||||
|
# 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
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
# SageMath parsed files
|
# SageMath parsed files
|
||||||
*.sage.py
|
*.sage.py
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
.envrc
|
||||||
.venv
|
.venv
|
||||||
env/
|
env/
|
||||||
venv/
|
venv/
|
||||||
@@ -102,12 +156,62 @@ venv.bak/
|
|||||||
|
|
||||||
# mypy
|
# mypy
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
# Task completions
|
# Pyre type checker
|
||||||
completion/
|
.pyre/
|
||||||
|
|
||||||
# Build artifacts
|
# pytype static type analyzer
|
||||||
dist/
|
.pytype/
|
||||||
|
|
||||||
# uv cache
|
# 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:
|
||||||
.ruff_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
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.14
|
||||||
36
Dockerfile
36
Dockerfile
@@ -1,22 +1,22 @@
|
|||||||
FROM python:3.9-alpine AS base
|
ARG PYTHON_VERSION="3.13"
|
||||||
|
|
||||||
# Builder
|
FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim AS builder
|
||||||
FROM base 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
|
||||||
|
|
||||||
# Install uv
|
|
||||||
RUN pip install uv
|
|
||||||
|
|
||||||
WORKDIR /src
|
FROM python:${PYTHON_VERSION}-slim-bookworm
|
||||||
COPY uv.lock pyproject.toml README.md /src/
|
COPY --from=builder --chown=app:app /app /app
|
||||||
COPY smsbot /src/smsbot
|
COPY ./docs/examples/config-basic.ini /app/config.ini
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
# Create virtual environment and install dependencies
|
EXPOSE 5000/tcp
|
||||||
RUN uv sync --frozen --no-dev
|
WORKDIR /app
|
||||||
|
|
||||||
# Final container
|
|
||||||
FROM base AS runtime
|
|
||||||
|
|
||||||
COPY --from=builder /src/.venv /runtime
|
|
||||||
ENV PATH=/runtime/bin:$PATH
|
|
||||||
EXPOSE 80/tcp
|
|
||||||
CMD ["smsbot"]
|
CMD ["smsbot"]
|
||||||
63
README.md
63
README.md
@@ -12,26 +12,61 @@ The bot is designed to run within a Kubernetes environment, but can be operated
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
All configuration is provided via environment variables
|
SMSBot can be configured using either a configuration file or environment variables. Environment variables will override any values set in the configuration file.
|
||||||
|
|
||||||
| Variable | Required? | Description |
|
### Configuration File
|
||||||
| -------------------------- | --------- | --------------------------------------------------------------------------- |
|
|
||||||
| SMSBOT_DEFAULT_SUBSCRIBERS | No | A list of IDs, seperated by commas, to add to the subscribers list on start |
|
Create a configuration file (e.g., `config.ini`) based on the provided `config-example.ini`:
|
||||||
| 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` |
|
```ini
|
||||||
| SMSBOT_OWNER_ID | No | ID of the owner of this bot |
|
[logging]
|
||||||
| SMSBOT_TELEGRAM_BOT_TOKEN | Yes | Your Bot Token for Telegram |
|
level = INFO
|
||||||
| SMSBOT_TWILIO_AUTH_TOKEN | No | Twilio auth token, used to validate any incoming webhook calls |
|
|
||||||
|
[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` |
|
||||||
|
|
||||||
## Setup
|
## 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.
|
||||||
|
|
||||||
* Setup a number in the location you want.
|
1. Copy `config-example.ini` to `config.ini` and update the values, or set the appropriate environment variables.
|
||||||
* Under Phone Numbers -> Manage -> Active Numbers, click the number you want to setup.
|
2. Setup a number in the location you want.
|
||||||
* 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`
|
3. Under Phone Numbers -> Manage -> Active Numbers, click the number you want to setup.
|
||||||
* 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`
|
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`
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
**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.
|
**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
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
version: 3
|
version: 3
|
||||||
tasks:
|
tasks:
|
||||||
python:env:
|
default:
|
||||||
|
deps:
|
||||||
|
- python:tests
|
||||||
|
- python:lint
|
||||||
|
|
||||||
|
python:tests:
|
||||||
|
desc: Run Python tests
|
||||||
cmds:
|
cmds:
|
||||||
- uv sync --extra dev --extra github
|
- uv run --dev --group github pytest
|
||||||
|
|
||||||
python:lint:
|
python:lint:
|
||||||
desc: Lint Python files
|
desc: Lint Python files
|
||||||
deps:
|
|
||||||
- python:env
|
|
||||||
cmds:
|
cmds:
|
||||||
- uv run ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py39 .
|
- uv run --dev ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py39 .
|
||||||
- uv run ruff check --output-format=github --target-version=py39 .
|
- uv run --dev ruff check --output-format=github --target-version=py39 .
|
||||||
|
|
||||||
docker:build:
|
docker:build:
|
||||||
desc: Build the container using Docker
|
desc: Build the container using Docker
|
||||||
cmds:
|
cmds:
|
||||||
- docker build . -t smsbot:latest
|
- docker build . --build-arg PYTHON_VERSION=$(cat .python-version) -t smsbot:latest
|
||||||
|
|
||||||
|
smsbot:run:
|
||||||
|
desc: Run the SMSBot
|
||||||
|
cmds:
|
||||||
|
- uv run smsbot
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
env:
|
|
||||||
SMSBOT_TELEGRAM_BOT_TOKEN: xxxx
|
|
||||||
ingress:
|
|
||||||
main:
|
|
||||||
enabled: true
|
|
||||||
hosts:
|
|
||||||
- host: smsbot.domain.tld
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{ include "common.notes.defaultNotes" . }}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
{{ include "common.all" . }}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
#
|
|
||||||
# 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
|
|
||||||
5
docs/examples/README.md
Normal file
5
docs/examples/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Example deployments
|
||||||
|
|
||||||
|
Examples of how to deploy SMSBot.
|
||||||
|
|
||||||
|
* [Flux HelmRelease](flux-helmrelease.yaml) - An example Flux `HelmRelease` using a common chart for basic deployment.
|
||||||
2
docs/examples/config-basic.ini
Normal file
2
docs/examples/config-basic.ini
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[logging]
|
||||||
|
level = INFO
|
||||||
16
docs/examples/config-example.ini
Normal file
16
docs/examples/config-example.ini
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
[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
|
||||||
55
docs/examples/flux-helmrelease.yaml
Normal file
55
docs/examples/flux-helmrelease.yaml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
# 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
|
||||||
|
|
||||||
@@ -1,33 +1,35 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "smsbot"
|
name = "smsbot"
|
||||||
version = "0.0.6"
|
version = "0.2.2"
|
||||||
description = "A simple Telegram bot to receive SMS messages."
|
description = "A simple Telegram bot to receive SMS messages."
|
||||||
authors = [{ name = "Andrew Williams", email = "andy@tensixtyone.com" }]
|
authors = [{ name = "Andrew Williams", email = "andy@tensixtyone.com" }]
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9,<3.10"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask>=3.1.0,<4.0.0",
|
"flask[async]>=3.1.1",
|
||||||
"waitress>=3.0.2,<4.0.0",
|
"prometheus-async>=25.1.0",
|
||||||
"twilio>=9.4.6,<10.0.0",
|
"prometheus-client>=0.22.1",
|
||||||
"python-telegram-bot<20",
|
"python-telegram-bot>=22.3",
|
||||||
"prometheus-client>=0.21.1,<0.22.0",
|
"twilio>=9.7.0",
|
||||||
|
"uvicorn>=0.35.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
smsbot = "smsbot.cli:main"
|
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]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
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
|
||||||
|
|||||||
156
smsbot/cli.py
156
smsbot/cli.py
@@ -1,52 +1,156 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
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.telegram import TelegramSmsBot
|
||||||
from smsbot.utils import get_smsbot_version
|
from smsbot.utils import get_smsbot_version
|
||||||
from smsbot.webhook_handler import TwilioWebhookHandler
|
from smsbot.webhook import TwilioWebhookHandler
|
||||||
|
|
||||||
|
|
||||||
|
# Prefix of the environment variables to override config values
|
||||||
|
ENVIRONMENT_PREFIX = "SMSBOT_"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser("smsbot")
|
parser = argparse.ArgumentParser("smsbot")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--listen-host", default=os.environ.get("SMSBOT_LISTEN_HOST") or "0.0.0.0"
|
"-c",
|
||||||
|
"--config",
|
||||||
|
default=os.environ.get("SMSBOT_CONFIG_FILE", "config.ini"),
|
||||||
|
type=argparse.FileType("r"),
|
||||||
|
help="Path to the config file",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
||||||
"--listen-port", default=os.environ.get("SMSBOT_LISTEN_PORT") or "80"
|
parser.add_argument("--log-file", type=argparse.FileType("a"), help="Path to the log file", default=sys.stdout)
|
||||||
)
|
|
||||||
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
# TODO: Replace for Py >=3.11
|
if args.debug:
|
||||||
logging.basicConfig(level=logging.getLevelName(args.log_level))
|
level = logging.DEBUG
|
||||||
|
else:
|
||||||
|
level = logging.INFO
|
||||||
|
logging.basicConfig(level=level, stream=args.log_file)
|
||||||
logging.info("smsbot v%s", get_smsbot_version())
|
logging.info("smsbot v%s", get_smsbot_version())
|
||||||
logging.debug("Arguments: %s", args)
|
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
|
# Start bot
|
||||||
telegram_bot = TelegramSmsBot(args.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
|
# Set the owner ID if configured
|
||||||
if args.owner_id:
|
if config.has_option("telegram", "owner_id"):
|
||||||
telegram_bot.set_owner(args.owner_id)
|
telegram_bot.owners = [config.getint("telegram", "owner_id")]
|
||||||
else:
|
else:
|
||||||
logging.warning("No Owner ID is set, which is not a good idea...")
|
logging.warning("No Owner ID is set, which is not a good idea...")
|
||||||
|
|
||||||
# Add default subscribers
|
# Add default subscribers
|
||||||
if args.default_subscribers:
|
if config.has_option("telegram", "subscribers"):
|
||||||
for chat_id in args.default_subscribers.split(","):
|
for chat_id in config.get("telegram", "subscribers").split(","):
|
||||||
telegram_bot.add_subscriber(chat_id)
|
telegram_bot.subscribers.append(int(chat_id.strip()))
|
||||||
|
|
||||||
telegram_bot.start()
|
# 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)
|
||||||
|
|
||||||
# Start webhooks
|
# Build a uvicorn ASGI server
|
||||||
webhooks = TwilioWebhookHandler()
|
flask_app = uvicorn.Server(
|
||||||
webhooks.set_bot(telegram_bot)
|
config=uvicorn.Config(
|
||||||
webhooks.serve(host=args.listen_host, port=args.listen_port)
|
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()
|
||||||
|
|||||||
@@ -1,118 +1,129 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from prometheus_client import Counter, Summary
|
from prometheus_client import Counter, Summary
|
||||||
from telegram.ext import CommandHandler, Updater
|
from telegram import Update
|
||||||
|
from telegram.ext import (
|
||||||
|
Application,
|
||||||
|
ApplicationHandlerStop,
|
||||||
|
CommandHandler,
|
||||||
|
ContextTypes,
|
||||||
|
TypeHandler,
|
||||||
|
)
|
||||||
|
from twilio.rest import Client
|
||||||
|
|
||||||
from smsbot.utils import get_smsbot_version
|
from smsbot.utils import get_smsbot_version
|
||||||
|
|
||||||
REQUEST_TIME = Summary(
|
REQUEST_TIME = Summary("telegram_request_processing_seconds", "Time spent processing request")
|
||||||
"telegram_request_processing_seconds", "Time spent processing request"
|
|
||||||
)
|
|
||||||
COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands processed")
|
COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands processed")
|
||||||
|
|
||||||
|
|
||||||
class TelegramSmsBot(object):
|
class TelegramSmsBot:
|
||||||
def __init__(
|
def __init__(
|
||||||
self, telegram_token, allow_subscribing=False, owner=None, subscribers=None
|
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.logger = logging.getLogger(self.__class__.__name__)
|
||||||
self.bot_token = telegram_token
|
self.app = Application.builder().token(token).build()
|
||||||
self.subscriber_ids = subscribers or []
|
self.owners = owners
|
||||||
self.set_owner(owner)
|
self.subscribers = subscribers
|
||||||
|
self.twilio_client = twilio_client
|
||||||
|
self.twilio_from_number = twilio_from_number
|
||||||
|
|
||||||
self.updater = Updater(self.bot_token, use_context=True)
|
self.init_handlers()
|
||||||
self.updater.dispatcher.add_handler(CommandHandler("help", self.help_handler))
|
|
||||||
self.updater.dispatcher.add_handler(CommandHandler("start", self.help_handler))
|
|
||||||
|
|
||||||
if allow_subscribing:
|
def init_handlers(self):
|
||||||
self.updater.dispatcher.add_handler(
|
self.app.add_handler(TypeHandler(Update, self.callback), -1)
|
||||||
CommandHandler("subscribe", self.subscribe_handler)
|
self.app.add_handler(CommandHandler(["help", "start"], self.handler_help))
|
||||||
)
|
self.app.add_handler(CommandHandler("subscribe", self.handler_subscribe))
|
||||||
self.updater.dispatcher.add_handler(
|
self.app.add_handler(CommandHandler("unsubscribe", self.handler_unsubscribe))
|
||||||
CommandHandler("unsubscribe", self.unsubscribe_handler)
|
self.app.add_handler(CommandHandler("sms", self.handler_sms))
|
||||||
)
|
|
||||||
|
|
||||||
self.updater.dispatcher.add_error_handler(self.error_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
|
||||||
|
|
||||||
def start(self):
|
async def send_message(self, chat_id: int, text: str):
|
||||||
self.logger.info("Starting bot...")
|
"""Send a message to a specific chat"""
|
||||||
self.updater.start_polling()
|
self.logger.info(f"Sending message to chat {chat_id}: {text}")
|
||||||
self.bot = self.updater.bot
|
await self.app.bot.send_message(chat_id=chat_id, text=text, parse_mode="MarkdownV2")
|
||||||
self.logger.info("Bot Ready")
|
|
||||||
|
|
||||||
def stop(self):
|
async def send_subscribers(self, text: str):
|
||||||
self.updater.stop()
|
"""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)
|
||||||
|
|
||||||
@REQUEST_TIME.time()
|
@REQUEST_TIME.time()
|
||||||
def help_handler(self, update, context):
|
async def handler_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
"""Send a message when the command /help is issued."""
|
"""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 = []
|
commands = []
|
||||||
for command in self.updater.dispatcher.handlers[0]:
|
for command in self.app.handlers[0]:
|
||||||
commands.extend(["/{0}".format(cmd) for cmd in command.command])
|
if isinstance(command, CommandHandler):
|
||||||
|
commands.extend(["/{0}".format(cmd) for cmd in command.commands])
|
||||||
|
|
||||||
update.message.reply_markdown(
|
await update.message.reply_markdown("Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands)))
|
||||||
"Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands))
|
|
||||||
)
|
|
||||||
COMMAND_COUNT.inc()
|
COMMAND_COUNT.inc()
|
||||||
|
|
||||||
@REQUEST_TIME.time()
|
@REQUEST_TIME.time()
|
||||||
def subscribe_handler(self, update, context):
|
async def handler_subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
self.logger.info("/subscribe command received")
|
"""Handle subscription requests"""
|
||||||
if update.message.chat["id"] not in self.subscriber_ids:
|
if update.effective_user and update.message:
|
||||||
self.logger.info("{0} subscribed".format(update.message.chat["username"]))
|
user_id = update.effective_user.id
|
||||||
self.subscriber_ids.append(update.message.chat["id"])
|
if user_id not in self.subscribers:
|
||||||
self.send_owner(
|
self.subscribers.append(user_id)
|
||||||
"{0} has subscribed".format(update.message.chat["username"])
|
self.logger.info(f"User {user_id} subscribed.")
|
||||||
)
|
self.logger.info(f"Current subscribers: {self.subscribers}")
|
||||||
update.message.reply_markdown(
|
await update.message.reply_markdown("You have successfully subscribed to updates.")
|
||||||
"You have been subscribed to SMS notifications"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
update.message.reply_markdown(
|
self.logger.info(f"User {user_id} is already subscribed.")
|
||||||
"You are already subscribed to SMS notifications"
|
|
||||||
)
|
|
||||||
COMMAND_COUNT.inc()
|
|
||||||
|
|
||||||
@REQUEST_TIME.time()
|
@REQUEST_TIME.time()
|
||||||
def unsubscribe_handler(self, update, context):
|
async def handler_unsubscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
self.logger.info("/unsubscribe command received")
|
"""Handle unsubscription requests"""
|
||||||
if update.message.chat["id"] in self.subscriber_ids:
|
if update.effective_user and update.message:
|
||||||
self.logger.info("{0} unsubscribed".format(update.message.chat["username"]))
|
user_id = update.effective_user.id
|
||||||
self.subscriber_ids.remove(update.message.chat["id"])
|
if user_id in self.subscribers:
|
||||||
self.send_owner(
|
self.subscribers.remove(user_id)
|
||||||
"{0} has unsubscribed".format(update.message.chat["username"])
|
self.logger.info(f"User {user_id} unsubscribed.")
|
||||||
)
|
self.logger.info(f"Current subscribers: {self.subscribers}")
|
||||||
update.message.reply_markdown(
|
await update.message.reply_markdown("You have successfully unsubscribed from updates.")
|
||||||
"You have been unsubscribed to SMS notifications"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
update.message.reply_markdown("You are not subscribed to SMS notifications")
|
self.logger.info(f"User {user_id} is not subscribed.")
|
||||||
COMMAND_COUNT.inc()
|
|
||||||
|
|
||||||
def error_handler(self, update, context):
|
@REQUEST_TIME.time()
|
||||||
"""Log Errors caused by Updates."""
|
async def handler_sms(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
self.logger.warning('Update "%s" caused error "%s"', update, context.error)
|
"""Handle sending SMS requests"""
|
||||||
self.send_owner(
|
if update.effective_user and update.message:
|
||||||
'Update "%{0}" caused error "{1}"'.format(update, context.error)
|
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 send_message(self, message, chat_id):
|
try:
|
||||||
self.bot.sendMessage(text=message, chat_id=chat_id)
|
self.twilio_client.messages.create(body=message, to=to, from_=self.twilio_from_number)
|
||||||
|
except Exception:
|
||||||
def send_owner(self, message):
|
self.logger.exception("Failed to send SMS due to exception")
|
||||||
if self.owner_id:
|
await update.message.reply_markdown("Failed to send SMS")
|
||||||
self.send_message(message, self.owner_id)
|
pass
|
||||||
|
else:
|
||||||
def send_subscribers(self, message):
|
await update.message.reply_markdown("Twilio client is not configured, cannot send SMS.")
|
||||||
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)
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
|
||||||
def get_smsbot_version():
|
def get_smsbot_version() -> str:
|
||||||
return version("smsbot")
|
return version("smsbot")
|
||||||
79
smsbot/utils/twilio.py
Normal file
79
smsbot/utils/twilio.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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
|
||||||
109
smsbot/webhook.py
Normal file
109
smsbot/webhook.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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>'
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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)
|
|
||||||
3
tests/test_basic.py
Normal file
3
tests/test_basic.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
def test_import():
|
||||||
|
import smsbot.utils
|
||||||
|
assert smsbot.utils.get_smsbot_version()
|
||||||
69
tests/test_utils.py
Normal file
69
tests/test_utils.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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,
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user