mirror of
https://github.com/nikdoof/smsbot.git
synced 2025-12-13 10:02:15 +00:00
Compare commits
47 Commits
0.0.7
...
5e0f8a61f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e0f8a61f1 | ||
|
|
e14ad0041f | ||
|
|
e2ead09c2a | ||
|
|
06a07a727c | ||
|
|
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
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
.github/workflows/build-container.yaml
vendored
7
.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 }}:${{ github.ref_name }}
|
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||||
ghcr.io/${{ github.repository }}:latest
|
ghcr.io/${{ github.repository }}:latest
|
||||||
|
|||||||
6
.github/workflows/lint.yaml
vendored
6
.github/workflows/lint.yaml
vendored
@@ -11,15 +11,15 @@ jobs:
|
|||||||
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: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
|
|||||||
8
.github/workflows/release.yaml
vendored
8
.github/workflows/release.yaml
vendored
@@ -2,8 +2,6 @@ name: Release
|
|||||||
|
|
||||||
"on":
|
"on":
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
tags:
|
||||||
- "[0-9]+.[0-9]+.[0-9]+"
|
- "[0-9]+.[0-9]+.[0-9]+"
|
||||||
|
|
||||||
@@ -12,12 +10,10 @@ 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@v6
|
||||||
with:
|
|
||||||
python-version: "3.9"
|
|
||||||
|
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@v6
|
uses: astral-sh/setup-uv@v6
|
||||||
|
|||||||
4
.github/workflows/tests.yaml
vendored
4
.github/workflows/tests.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
|||||||
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@v5
|
- uses: actions/checkout@v5
|
||||||
- name: Install Task
|
- name: Install Task
|
||||||
@@ -19,7 +19,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 3.x
|
version: 3.x
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -212,3 +212,6 @@ __marimo__/
|
|||||||
|
|
||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
# smsbot config file
|
||||||
|
config.ini
|
||||||
@@ -1 +1 @@
|
|||||||
3.9
|
3.13
|
||||||
|
|||||||
10
Dockerfile
10
Dockerfile
@@ -1,4 +1,6 @@
|
|||||||
FROM ghcr.io/astral-sh/uv:python3.9-bookworm-slim AS builder
|
ARG PYTHON_VERSION="3.13"
|
||||||
|
|
||||||
|
FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim AS builder
|
||||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||||
ENV UV_PYTHON_DOWNLOADS=0
|
ENV UV_PYTHON_DOWNLOADS=0
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -11,8 +13,10 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
|
|
||||||
FROM python:3.9-slim-bookworm
|
FROM python:${PYTHON_VERSION}-slim-bookworm
|
||||||
COPY --from=builder --chown=app:app /app /app
|
COPY --from=builder --chown=app:app /app /app
|
||||||
|
COPY ./docs/examples/config-basic.ini /app/config.ini
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
EXPOSE 80/tcp
|
EXPOSE 5000/tcp
|
||||||
|
WORKDIR /app
|
||||||
CMD ["smsbot"]
|
CMD ["smsbot"]
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -12,27 +12,55 @@ 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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
version: 3
|
version: 3
|
||||||
tasks:
|
tasks:
|
||||||
|
default:
|
||||||
|
deps:
|
||||||
|
- python:tests
|
||||||
|
- python:lint
|
||||||
|
|
||||||
python:tests:
|
python:tests:
|
||||||
desc: Run Python tests
|
desc: Run Python tests
|
||||||
cmds:
|
cmds:
|
||||||
@@ -14,4 +19,9 @@ tasks:
|
|||||||
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
|
||||||
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,17 +1,18 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "smsbot"
|
name = "smsbot"
|
||||||
version = "0.0.7"
|
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.1",
|
"flask[async]>=3.1.1",
|
||||||
|
"prometheus-async>=25.1.0",
|
||||||
"prometheus-client>=0.22.1",
|
"prometheus-client>=0.22.1",
|
||||||
"python-telegram-bot<20",
|
"python-telegram-bot>=22.3",
|
||||||
"twilio>=9.7.0",
|
"twilio>=9.7.0",
|
||||||
"waitress>=3.0.2",
|
"uvicorn>=0.35.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -29,3 +30,6 @@ dev = [
|
|||||||
"ruff>=0.12.9",
|
"ruff>=0.12.9",
|
||||||
]
|
]
|
||||||
github = ["pytest-github-actions-annotate-failures>=0.3.0"]
|
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."""
|
||||||
self.logger.info("/help command received in chat: %s", update.message.chat)
|
if update.message:
|
||||||
|
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:
|
||||||
)
|
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()
|
||||||
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:
|
||||||
)
|
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()
|
|
||||||
|
|
||||||
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)
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
def test_import():
|
def test_import():
|
||||||
import smsbot
|
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