mirror of
https://github.com/nikdoof/smsbot.git
synced 2025-12-22 14:19:25 +00:00
Compare commits
4 Commits
e45d65ad1f
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
816ef7fd06 | ||
|
|
59aadd22c1 | ||
|
|
88cb3388af | ||
|
|
5556a0f650 |
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
.github/workflows/build-container.yaml
vendored
11
.github/workflows/build-container.yaml
vendored
@@ -22,18 +22,11 @@ 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 }}:${{ github.ref_name }}
|
ghcr.io/${{ github.repository_owner }}/smsbot:${{ github.ref_name }}
|
||||||
ghcr.io/${{ github.repository }}:latest
|
ghcr.io/${{ github.repository_owner }}/smsbot:latest
|
||||||
|
|||||||
18
.github/workflows/lint.yaml
vendored
18
.github/workflows/lint.yaml
vendored
@@ -1,29 +1,33 @@
|
|||||||
name: Run Lint
|
name: 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.13"]
|
python-version: ["3.9"]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v4
|
||||||
- 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: Set up Python ${{ matrix.python-version }}
|
- name: Setup Python
|
||||||
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@v6
|
uses: astral-sh/setup-uv@v4
|
||||||
- name: Run Lint
|
with:
|
||||||
|
version: "latest"
|
||||||
|
- name: Lint with ruff
|
||||||
run: |
|
run: |
|
||||||
task python:lint
|
task python:lint
|
||||||
|
|||||||
32
.github/workflows/release-chart.yaml
vendored
Normal file
32
.github/workflows/release-chart.yaml
vendored
Normal 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 }}"
|
||||||
16
.github/workflows/release.yaml
vendored
16
.github/workflows/release.yaml
vendored
@@ -1,6 +1,5 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
on:
|
||||||
"on":
|
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
- "[0-9]+.[0-9]+.[0-9]+"
|
||||||
@@ -10,16 +9,21 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.9"
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install Poetry
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: snok/install-poetry@v1
|
||||||
|
with:
|
||||||
|
virtualenvs-create: true
|
||||||
|
virtualenvs-in-project: true
|
||||||
|
|
||||||
- name: Build Release
|
- name: Build Release
|
||||||
run: uv build
|
run: poetry build
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
|
|||||||
29
.github/workflows/tests.yaml
vendored
29
.github/workflows/tests.yaml
vendored
@@ -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@v6
|
|
||||||
- name: Run Tests
|
|
||||||
run: |
|
|
||||||
task python:tests
|
|
||||||
120
.gitignore
vendored
120
.gitignore
vendored
@@ -1,6 +1,6 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[codz]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
@@ -20,7 +20,6 @@ parts/
|
|||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
*.egg
|
*.egg
|
||||||
@@ -39,17 +38,14 @@ 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
|
||||||
@@ -59,7 +55,6 @@ cover/
|
|||||||
*.log
|
*.log
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
# Flask stuff:
|
||||||
instance/
|
instance/
|
||||||
@@ -72,71 +67,22 @@ instance/
|
|||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
.pybuilder/
|
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
.python-version
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
# celery beat schedule file
|
||||||
# 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/
|
||||||
@@ -156,62 +102,12 @@ venv.bak/
|
|||||||
|
|
||||||
# mypy
|
# mypy
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
# Task completions
|
||||||
.pyre/
|
completion/
|
||||||
|
|
||||||
# pytype static type analyzer
|
# Build artifacts
|
||||||
.pytype/
|
dist/
|
||||||
|
|
||||||
# Cython debug symbols
|
# uv cache
|
||||||
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 +0,0 @@
|
|||||||
3.14
|
|
||||||
38
Dockerfile
38
Dockerfile
@@ -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
|
# Builder
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
FROM base AS builder
|
||||||
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
|
||||||
|
|
||||||
FROM python:${PYTHON_VERSION}-slim-bookworm
|
WORKDIR /src
|
||||||
COPY --from=builder --chown=app:app /app /app
|
COPY uv.lock pyproject.toml README.md /src/
|
||||||
COPY ./docs/examples/config-basic.ini /app/config.ini
|
COPY smsbot /src/smsbot
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
|
||||||
EXPOSE 5000/tcp
|
# Create virtual environment and install dependencies
|
||||||
WORKDIR /app
|
RUN uv sync --frozen --no-dev
|
||||||
CMD ["smsbot"]
|
|
||||||
|
# Final container
|
||||||
|
FROM base AS runtime
|
||||||
|
|
||||||
|
COPY --from=builder /src/.venv /runtime
|
||||||
|
ENV PATH=/runtime/bin:$PATH
|
||||||
|
EXPOSE 80/tcp
|
||||||
|
CMD ["smsbot"]
|
||||||
65
README.md
65
README.md
@@ -12,61 +12,26 @@ The bot is designed to run within a Kubernetes environment, but can be operated
|
|||||||
|
|
||||||
## Configuration
|
## 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
|
| Variable | Required? | Description |
|
||||||
|
| -------------------------- | --------- | --------------------------------------------------------------------------- |
|
||||||
Create a configuration file (e.g., `config.ini`) based on the provided `config-example.ini`:
|
| 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` |
|
||||||
```ini
|
| SMSBOT_LISTEN_PORT | No | The port to listen to, defaults to `80` |
|
||||||
[logging]
|
| SMSBOT_OWNER_ID | No | ID of the owner of this bot |
|
||||||
level = INFO
|
| 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 |
|
||||||
[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.
|
||||||
|
|
||||||
1. Copy `config-example.ini` to `config.ini` and update the values, or set the appropriate environment variables.
|
* Setup a number in the location you want.
|
||||||
2. Setup a number in the location you want.
|
* Under Phone Numbers -> Manage -> Active Numbers, click the number you want to setup.
|
||||||
3. 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`
|
||||||
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`
|
* 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`
|
||||||
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_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.
|
**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,27 +1,17 @@
|
|||||||
version: 3
|
version: 3
|
||||||
tasks:
|
tasks:
|
||||||
default:
|
python:env:
|
||||||
deps:
|
|
||||||
- python:tests
|
|
||||||
- python:lint
|
|
||||||
|
|
||||||
python:tests:
|
|
||||||
desc: Run Python tests
|
|
||||||
cmds:
|
cmds:
|
||||||
- uv run --dev --group github pytest
|
- uv sync --extra dev --extra github
|
||||||
|
|
||||||
python:lint:
|
python:lint:
|
||||||
desc: Lint Python files
|
desc: Lint Python files
|
||||||
|
deps:
|
||||||
|
- python:env
|
||||||
cmds:
|
cmds:
|
||||||
- uv run --dev ruff check --output-format=github --select=E9,F63,F7,F82 --target-version=py39 .
|
- uv run 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 --target-version=py39 .
|
||||||
|
|
||||||
docker:build:
|
docker:build:
|
||||||
desc: Build the container using Docker
|
desc: Build the container using Docker
|
||||||
cmds:
|
cmds:
|
||||||
- docker build . --build-arg PYTHON_VERSION=$(cat .python-version) -t smsbot:latest
|
- docker build . -t smsbot:latest
|
||||||
|
|
||||||
smsbot:run:
|
|
||||||
desc: Run the SMSBot
|
|
||||||
cmds:
|
|
||||||
- uv run smsbot
|
|
||||||
18
charts/smsbot/Chart.yaml
Normal file
18
charts/smsbot/Chart.yaml
Normal 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
|
||||||
10
charts/smsbot/ci/ct-values.yaml
Normal file
10
charts/smsbot/ci/ct-values.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
env:
|
||||||
|
SMSBOT_TELEGRAM_BOT_TOKEN: xxxx
|
||||||
|
ingress:
|
||||||
|
main:
|
||||||
|
enabled: true
|
||||||
|
hosts:
|
||||||
|
- host: smsbot.domain.tld
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
1
charts/smsbot/templates/NOTES.txt
Normal file
1
charts/smsbot/templates/NOTES.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ include "common.notes.defaultNotes" . }}
|
||||||
1
charts/smsbot/templates/common.yaml
Normal file
1
charts/smsbot/templates/common.yaml
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{{ include "common.all" . }}
|
||||||
35
charts/smsbot/values.yaml
Normal file
35
charts/smsbot/values.yaml
Normal 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
|
||||||
@@ -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.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[logging]
|
|
||||||
level = INFO
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
|
|
||||||
@@ -1,35 +1,33 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "smsbot"
|
name = "smsbot"
|
||||||
version = "0.2.2"
|
version = "0.0.6"
|
||||||
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.13"
|
requires-python = ">=3.9,<3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"flask[async]>=3.1.1",
|
"flask>=3.1.0,<4.0.0",
|
||||||
"prometheus-async>=25.1.0",
|
"waitress>=3.0.2,<4.0.0",
|
||||||
"prometheus-client>=0.22.1",
|
"twilio>=9.4.6,<10.0.0",
|
||||||
"python-telegram-bot>=22.3",
|
"python-telegram-bot<20",
|
||||||
"twilio>=9.7.0",
|
"prometheus-client>=0.21.1,<0.22.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,156 +1,52 @@
|
|||||||
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 import TwilioWebhookHandler
|
from smsbot.webhook_handler 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(
|
||||||
"-c",
|
"--listen-host", default=os.environ.get("SMSBOT_LISTEN_HOST") or "0.0.0.0"
|
||||||
"--config",
|
|
||||||
default=os.environ.get("SMSBOT_CONFIG_FILE", "config.ini"),
|
|
||||||
type=argparse.FileType("r"),
|
|
||||||
help="Path to the config file",
|
|
||||||
)
|
)
|
||||||
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
parser.add_argument(
|
||||||
parser.add_argument("--log-file", type=argparse.FileType("a"), help="Path to the log file", default=sys.stdout)
|
"--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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.debug:
|
# TODO: Replace for Py >=3.11
|
||||||
level = logging.DEBUG
|
logging.basicConfig(level=logging.getLevelName(args.log_level))
|
||||||
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(
|
telegram_bot = TelegramSmsBot(args.telegram_bot_token)
|
||||||
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 config.has_option("telegram", "owner_id"):
|
if args.owner_id:
|
||||||
telegram_bot.owners = [config.getint("telegram", "owner_id")]
|
telegram_bot.set_owner(args.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 config.has_option("telegram", "subscribers"):
|
if args.default_subscribers:
|
||||||
for chat_id in config.get("telegram", "subscribers").split(","):
|
for chat_id in args.default_subscribers.split(","):
|
||||||
telegram_bot.subscribers.append(int(chat_id.strip()))
|
telegram_bot.add_subscriber(chat_id)
|
||||||
|
|
||||||
# Init the webhook handler
|
telegram_bot.start()
|
||||||
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)
|
|
||||||
|
|
||||||
# Build a uvicorn ASGI server
|
# Start webhooks
|
||||||
flask_app = uvicorn.Server(
|
webhooks = TwilioWebhookHandler()
|
||||||
config=uvicorn.Config(
|
webhooks.set_bot(telegram_bot)
|
||||||
app=WsgiToAsgi(webhooks.app),
|
webhooks.serve(host=args.listen_host, port=args.listen_port)
|
||||||
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,129 +1,118 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from prometheus_client import Counter, Summary
|
from prometheus_client import Counter, Summary
|
||||||
from telegram import Update
|
from telegram.ext import CommandHandler, Updater
|
||||||
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("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")
|
COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands processed")
|
||||||
|
|
||||||
|
|
||||||
class TelegramSmsBot:
|
class TelegramSmsBot(object):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self, telegram_token, allow_subscribing=False, owner=None, subscribers=None
|
||||||
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.app = Application.builder().token(token).build()
|
self.bot_token = telegram_token
|
||||||
self.owners = owners
|
self.subscriber_ids = subscribers or []
|
||||||
self.subscribers = subscribers
|
self.set_owner(owner)
|
||||||
self.twilio_client = twilio_client
|
|
||||||
self.twilio_from_number = twilio_from_number
|
|
||||||
|
|
||||||
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):
|
if allow_subscribing:
|
||||||
self.app.add_handler(TypeHandler(Update, self.callback), -1)
|
self.updater.dispatcher.add_handler(
|
||||||
self.app.add_handler(CommandHandler(["help", "start"], self.handler_help))
|
CommandHandler("subscribe", self.subscribe_handler)
|
||||||
self.app.add_handler(CommandHandler("subscribe", self.handler_subscribe))
|
)
|
||||||
self.app.add_handler(CommandHandler("unsubscribe", self.handler_unsubscribe))
|
self.updater.dispatcher.add_handler(
|
||||||
self.app.add_handler(CommandHandler("sms", self.handler_sms))
|
CommandHandler("unsubscribe", self.unsubscribe_handler)
|
||||||
|
)
|
||||||
|
|
||||||
async def callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
self.updater.dispatcher.add_error_handler(self.error_handler)
|
||||||
"""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
|
|
||||||
|
|
||||||
async def send_message(self, chat_id: int, text: str):
|
def start(self):
|
||||||
"""Send a message to a specific chat"""
|
self.logger.info("Starting bot...")
|
||||||
self.logger.info(f"Sending message to chat {chat_id}: {text}")
|
self.updater.start_polling()
|
||||||
await self.app.bot.send_message(chat_id=chat_id, text=text, parse_mode="MarkdownV2")
|
self.bot = self.updater.bot
|
||||||
|
self.logger.info("Bot Ready")
|
||||||
|
|
||||||
async def send_subscribers(self, text: str):
|
def stop(self):
|
||||||
"""Send a message to all subscribers"""
|
self.updater.stop()
|
||||||
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()
|
||||||
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."""
|
"""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.app.handlers[0]:
|
for command in self.updater.dispatcher.handlers[0]:
|
||||||
if isinstance(command, CommandHandler):
|
commands.extend(["/{0}".format(cmd) for cmd in command.command])
|
||||||
commands.extend(["/{0}".format(cmd) for cmd in command.commands])
|
|
||||||
|
|
||||||
await update.message.reply_markdown("Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands)))
|
update.message.reply_markdown(
|
||||||
COMMAND_COUNT.inc()
|
"Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands))
|
||||||
|
)
|
||||||
|
COMMAND_COUNT.inc()
|
||||||
|
|
||||||
@REQUEST_TIME.time()
|
@REQUEST_TIME.time()
|
||||||
async def handler_subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
def subscribe_handler(self, update, context):
|
||||||
"""Handle subscription requests"""
|
self.logger.info("/subscribe command received")
|
||||||
if update.effective_user and update.message:
|
if update.message.chat["id"] not in self.subscriber_ids:
|
||||||
user_id = update.effective_user.id
|
self.logger.info("{0} subscribed".format(update.message.chat["username"]))
|
||||||
if user_id not in self.subscribers:
|
self.subscriber_ids.append(update.message.chat["id"])
|
||||||
self.subscribers.append(user_id)
|
self.send_owner(
|
||||||
self.logger.info(f"User {user_id} subscribed.")
|
"{0} has subscribed".format(update.message.chat["username"])
|
||||||
self.logger.info(f"Current subscribers: {self.subscribers}")
|
)
|
||||||
await update.message.reply_markdown("You have successfully subscribed to updates.")
|
update.message.reply_markdown(
|
||||||
else:
|
"You have been subscribed to SMS notifications"
|
||||||
self.logger.info(f"User {user_id} is already subscribed.")
|
)
|
||||||
|
else:
|
||||||
|
update.message.reply_markdown(
|
||||||
|
"You are already subscribed to SMS notifications"
|
||||||
|
)
|
||||||
|
COMMAND_COUNT.inc()
|
||||||
|
|
||||||
@REQUEST_TIME.time()
|
@REQUEST_TIME.time()
|
||||||
async def handler_unsubscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
def unsubscribe_handler(self, update, context):
|
||||||
"""Handle unsubscription requests"""
|
self.logger.info("/unsubscribe command received")
|
||||||
if update.effective_user and update.message:
|
if update.message.chat["id"] in self.subscriber_ids:
|
||||||
user_id = update.effective_user.id
|
self.logger.info("{0} unsubscribed".format(update.message.chat["username"]))
|
||||||
if user_id in self.subscribers:
|
self.subscriber_ids.remove(update.message.chat["id"])
|
||||||
self.subscribers.remove(user_id)
|
self.send_owner(
|
||||||
self.logger.info(f"User {user_id} unsubscribed.")
|
"{0} has unsubscribed".format(update.message.chat["username"])
|
||||||
self.logger.info(f"Current subscribers: {self.subscribers}")
|
)
|
||||||
await update.message.reply_markdown("You have successfully unsubscribed from updates.")
|
update.message.reply_markdown(
|
||||||
else:
|
"You have been unsubscribed to SMS notifications"
|
||||||
self.logger.info(f"User {user_id} is not subscribed.")
|
)
|
||||||
|
else:
|
||||||
|
update.message.reply_markdown("You are not subscribed to SMS notifications")
|
||||||
|
COMMAND_COUNT.inc()
|
||||||
|
|
||||||
@REQUEST_TIME.time()
|
def error_handler(self, update, context):
|
||||||
async def handler_sms(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
"""Log Errors caused by Updates."""
|
||||||
"""Handle sending SMS requests"""
|
self.logger.warning('Update "%s" caused error "%s"', update, context.error)
|
||||||
if update.effective_user and update.message:
|
self.send_owner(
|
||||||
user_id = update.effective_user.id
|
'Update "%{0}" caused error "{1}"'.format(update, context.error)
|
||||||
if self.twilio_client and self.twilio_from_number:
|
)
|
||||||
to = context.args[0] if context.args else "No recipient provided"
|
|
||||||
message = context.args[1] if context.args else "No message provided"
|
|
||||||
self.logger.info(f"Sending SMS from user {user_id} -> {to}: {message}")
|
|
||||||
|
|
||||||
try:
|
def send_message(self, message, chat_id):
|
||||||
self.twilio_client.messages.create(body=message, to=to, from_=self.twilio_from_number)
|
self.bot.sendMessage(text=message, chat_id=chat_id)
|
||||||
except Exception:
|
|
||||||
self.logger.exception("Failed to send SMS due to exception")
|
def send_owner(self, message):
|
||||||
await update.message.reply_markdown("Failed to send SMS")
|
if self.owner_id:
|
||||||
pass
|
self.send_message(message, self.owner_id)
|
||||||
else:
|
|
||||||
await update.message.reply_markdown("Twilio client is not configured, cannot send SMS.")
|
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)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
|
|
||||||
|
|
||||||
def get_smsbot_version() -> str:
|
def get_smsbot_version():
|
||||||
return version("smsbot")
|
return version("smsbot")
|
||||||
@@ -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
|
|
||||||
@@ -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
109
smsbot/webhook_handler.py
Normal 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)
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
def test_import():
|
|
||||||
import smsbot.utils
|
|
||||||
assert smsbot.utils.get_smsbot_version()
|
|
||||||
@@ -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,
|
|
||||||
]
|
|
||||||
Reference in New Issue
Block a user