mirror of
https://github.com/nikdoof/smsbot.git
synced 2025-12-28 13:09:03 +00:00
Compare commits
1 Commits
renovate/m
...
2ae7704b59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ae7704b59 |
@@ -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:3-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,18 +22,11 @@ jobs:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set build args
|
||||
run: |
|
||||
echo "PYTHON_VERSION=$(cat .python-version)" >> $GITHUB_ENV
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
build-args: |
|
||||
PYTHON_VERSION=${{ env.PYTHON_VERSION }}
|
||||
tags: |
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }}
|
||||
ghcr.io/${{ github.repository }}:latest
|
||||
|
||||
8
.github/workflows/lint.yaml
vendored
8
.github/workflows/lint.yaml
vendored
@@ -11,19 +11,19 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.13"]
|
||||
python-version: ["3.9"]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v2
|
||||
with:
|
||||
version: 3.x
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Run Lint
|
||||
run: |
|
||||
task python:lint
|
||||
|
||||
8
.github/workflows/release.yaml
vendored
8
.github/workflows/release.yaml
vendored
@@ -10,13 +10,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Build Release
|
||||
run: uv build
|
||||
|
||||
6
.github/workflows/tests.yaml
vendored
6
.github/workflows/tests.yaml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.13"]
|
||||
python-version: ["3.9"]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Install Task
|
||||
@@ -19,11 +19,11 @@ jobs:
|
||||
with:
|
||||
version: 3.x
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
uses: astral-sh/setup-uv@v6
|
||||
- name: Run Tests
|
||||
run: |
|
||||
task python:tests
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.14
|
||||
3.13
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,6 +1,4 @@
|
||||
ARG PYTHON_VERSION="3.13"
|
||||
|
||||
FROM ghcr.io/astral-sh/uv:python${PYTHON_VERSION}-bookworm-slim AS builder
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder
|
||||
ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
WORKDIR /app
|
||||
@@ -13,10 +11,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
|
||||
FROM python:${PYTHON_VERSION}-slim-bookworm
|
||||
FROM python:3.13-slim-bookworm
|
||||
COPY --from=builder --chown=app:app /app /app
|
||||
COPY ./docs/examples/config-basic.ini /app/config.ini
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
EXPOSE 5000/tcp
|
||||
WORKDIR /app
|
||||
EXPOSE 80/tcp
|
||||
CMD ["smsbot"]
|
||||
|
||||
56
README.md
56
README.md
@@ -12,55 +12,27 @@ The bot is designed to run within a Kubernetes environment, but can be operated
|
||||
|
||||
## Configuration
|
||||
|
||||
SMSBot can be configured using either a configuration file or environment variables. Environment variables will override any values set in the configuration file.
|
||||
All configuration is provided via environment variables
|
||||
|
||||
### Configuration File
|
||||
|
||||
Create a configuration file (e.g., `config.ini`) based on the provided `config-example.ini`:
|
||||
|
||||
```ini
|
||||
[logging]
|
||||
level = INFO
|
||||
|
||||
[webhook]
|
||||
host = 127.0.0.1
|
||||
port = 80
|
||||
|
||||
[telegram]
|
||||
owner_id = OWNER_USER_ID
|
||||
bot_token = BOT_TOKEN
|
||||
|
||||
[twilio]
|
||||
account_sid = TWILIO_ACCOUNT_SID
|
||||
auth_token = TWILIO_AUTH_TOKEN
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
All configuration options can be overridden using environment variables:
|
||||
|
||||
| Environment Variable | Config Section | Config Key | Required? | Description |
|
||||
| --------------------------- | -------------- | ----------- | --------- | --------------------------------------------------------------------------- |
|
||||
| SMSBOT_LOGGING_LEVEL | logging | level | No | The log level to output to the console, defaults to `INFO` |
|
||||
| SMSBOT_TELEGRAM_BOT_TOKEN | telegram | bot_token | Yes | Your Bot Token for Telegram |
|
||||
| SMSBOT_TELEGRAM_OWNER_ID | telegram | owner_id | No | ID of the owner of this bot |
|
||||
| SMSBOT_TELEGRAM_SUBSCRIBERS | telegram | subscribers | No | A list of IDs, separated by commas, to add to the subscribers list on start |
|
||||
| SMSBOT_TWILIO_ACCOUNT_SID | twilio | account_sid | No | Twilio account SID |
|
||||
| SMSBOT_TWILIO_AUTH_TOKEN | twilio | auth_token | No | Twilio auth token, used to validate any incoming webhook calls |
|
||||
| SMSBOT_WEBHOOK_HOST | webhook | host | No | The host for the webhooks to listen on, defaults to `127.0.0.1` |
|
||||
| SMSBOT_WEBHOOK_PORT | webhook | port | No | The port to listen to, defaults to `80` |
|
||||
| Variable | Required? | Description |
|
||||
| -------------------------- | --------- | --------------------------------------------------------------------------- |
|
||||
| SMSBOT_DEFAULT_SUBSCRIBERS | No | A list of IDs, seperated by commas, to add to the subscribers list on start |
|
||||
| SMSBOT_LISTEN_HOST | No | The host for the webhooks to listen on, defaults to `0.0.0.0` |
|
||||
| SMSBOT_LISTEN_PORT | No | The port to listen to, defaults to `80` |
|
||||
| SMSBOT_OWNER_ID | No | ID of the owner of this bot |
|
||||
| SMSBOT_TELEGRAM_BOT_TOKEN | Yes | Your Bot Token for Telegram |
|
||||
| SMSBOT_TWILIO_AUTH_TOKEN | No | Twilio auth token, used to validate any incoming webhook calls |
|
||||
|
||||
## Setup
|
||||
|
||||
To configure SMSBot, you'll need a Twilio account, either paid or trial is fine.
|
||||
|
||||
1. Copy `config-example.ini` to `config.ini` and update the values, or set the appropriate environment variables.
|
||||
2. Setup a number in the location you want.
|
||||
3. Under Phone Numbers -> Manage -> Active Numbers, click the number you want to setup.
|
||||
4. In the "Voice & Fax" section, update the "A Call Comes In" to the URL of your SMSBot instance, with the endpoint being `/call`, e.g. `http://mymachine.test.com/call`
|
||||
5. In the "Messaging" section, update the "A Message Comes In" to the URL of your SMSBot instance, with the endpoint being `/message`, e.g. `http://mymachine.test.com/message`
|
||||
* Setup a number in the location you want.
|
||||
* Under Phone Numbers -> Manage -> Active Numbers, click the number you want to setup.
|
||||
* In the "Voice & Fax" section, update the "A Call Comes In" to the URL of your SMSBot instance, with the endpoint being `/call`, e.g. `http://mymachine.test.com/call`
|
||||
* In the "Messaging" section, update the "A Message Comes In" to the URL of your SMSBot instance, with the endpoint being `/message`, e.g. `http://mymachine.test.com/message`
|
||||
|
||||
Your bot should now receive messages, on Telegram you need to start a chat or invite it into any channels you want, then update the `SMSBOT_TELEGRAM_SUBSCRIBERS` values with their IDs.
|
||||
Your bot should now receive messages, on Telegram you need to start a chat or invite it into any channels you want, then update the `SMSBOT_DEFAULT_SUBSCRIBERS` values with their IDs.
|
||||
|
||||
**Note**: You cannot send test messages from your Twilio account to your Twilio numbers, they'll be silently dropped or fail with an "Invalid Number" error.
|
||||
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
version: 3
|
||||
tasks:
|
||||
default:
|
||||
deps:
|
||||
- python:tests
|
||||
- python:lint
|
||||
|
||||
python:tests:
|
||||
desc: Run Python tests
|
||||
cmds:
|
||||
@@ -19,7 +14,7 @@ tasks:
|
||||
docker:build:
|
||||
desc: Build the container using Docker
|
||||
cmds:
|
||||
- docker build . --build-arg PYTHON_VERSION=$(cat .python-version) -t smsbot:latest
|
||||
- docker build . -t smsbot:latest
|
||||
|
||||
smsbot:run:
|
||||
desc: Run the SMSBot
|
||||
|
||||
@@ -7,10 +7,8 @@ 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,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,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,6 +1,6 @@
|
||||
[project]
|
||||
name = "smsbot"
|
||||
version = "0.2.2"
|
||||
version = "0.1.1"
|
||||
description = "A simple Telegram bot to receive SMS messages."
|
||||
authors = [{ name = "Andrew Williams", email = "andy@tensixtyone.com" }]
|
||||
license = { text = "MIT" }
|
||||
@@ -30,6 +30,3 @@ dev = [
|
||||
"ruff>=0.12.9",
|
||||
]
|
||||
github = ["pytest-github-actions-annotate-failures>=0.3.0"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -15,10 +14,6 @@ from smsbot.utils import get_smsbot_version
|
||||
from smsbot.webhook import TwilioWebhookHandler
|
||||
|
||||
|
||||
# Prefix of the environment variables to override config values
|
||||
ENVIRONMENT_PREFIX = "SMSBOT_"
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser("smsbot")
|
||||
parser.add_argument(
|
||||
@@ -29,14 +24,13 @@ def main():
|
||||
help="Path to the config file",
|
||||
)
|
||||
parser.add_argument("--debug", action="store_true", help="Enable debug mode")
|
||||
parser.add_argument("--log-file", type=argparse.FileType("a"), help="Path to the log file", default=sys.stdout)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.debug:
|
||||
level = logging.DEBUG
|
||||
else:
|
||||
level = logging.INFO
|
||||
logging.basicConfig(level=level, stream=args.log_file)
|
||||
logging.basicConfig(level=level, stream=sys.stdout)
|
||||
logging.info("smsbot v%s", get_smsbot_version())
|
||||
logging.debug("Arguments: %s", args)
|
||||
|
||||
@@ -48,50 +42,22 @@ def main():
|
||||
|
||||
# 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)
|
||||
if key.startswith("SMSBOT_"):
|
||||
logging.debug("Overriding config %s with value %s", key, value)
|
||||
section, option = key[7:].split("_", 1)
|
||||
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."
|
||||
)
|
||||
logging.error("Telegram bot token is required")
|
||||
return
|
||||
|
||||
# Now the config is loaded, set the logger level
|
||||
level = getattr(logging, config.get("logging", "level", fallback="INFO").upper(), logging.INFO)
|
||||
logging.getLogger().setLevel(level)
|
||||
|
||||
# Configure Twilio client if we have credentials
|
||||
if config.has_section("twilio") and config.get("twilio", "account_sid") and config.get("twilio", "auth_token"):
|
||||
twilio_client = Client(
|
||||
config.get("twilio", "account_sid"),
|
||||
config.get("twilio", "auth_token"),
|
||||
)
|
||||
else:
|
||||
twilio_client = None
|
||||
logging.warning("No Twilio credentials found, outbound SMS functionality will be disabled.")
|
||||
|
||||
# Start bot
|
||||
telegram_bot = TelegramSmsBot(
|
||||
token=config.get("telegram", "bot_token"),
|
||||
twilio_client=twilio_client,
|
||||
twilio_from_number=config.get("twilio", "from_number", fallback=None),
|
||||
)
|
||||
telegram_bot = TelegramSmsBot(token=config.get("telegram", "bot_token"))
|
||||
|
||||
# Set the owner ID if configured
|
||||
if config.has_option("telegram", "owner_id"):
|
||||
@@ -100,8 +66,8 @@ def main():
|
||||
logging.warning("No Owner ID is set, which is not a good idea...")
|
||||
|
||||
# Add default subscribers
|
||||
if config.has_option("telegram", "subscribers"):
|
||||
for chat_id in config.get("telegram", "subscribers").split(","):
|
||||
if config.has_option("telegram", "default_subscribers"):
|
||||
for chat_id in config.get("telegram", "default_subscribers").split(","):
|
||||
telegram_bot.subscribers.append(int(chat_id.strip()))
|
||||
|
||||
# Init the webhook handler
|
||||
@@ -109,7 +75,7 @@ def main():
|
||||
account_sid=config.get("twilio", "account_sid", fallback=None),
|
||||
auth_token=config.get("twilio", "auth_token", fallback=None),
|
||||
)
|
||||
webhooks.set_telegram_application(telegram_bot)
|
||||
webhooks.set_bot(telegram_bot)
|
||||
|
||||
# Build a uvicorn ASGI server
|
||||
flask_app = uvicorn.Server(
|
||||
|
||||
@@ -9,29 +9,21 @@ from telegram.ext import (
|
||||
ContextTypes,
|
||||
TypeHandler,
|
||||
)
|
||||
from twilio.rest import Client
|
||||
|
||||
from smsbot.utils import get_smsbot_version
|
||||
|
||||
REQUEST_TIME = Summary("telegram_request_processing_seconds", "Time spent processing request")
|
||||
REQUEST_TIME = Summary(
|
||||
"telegram_request_processing_seconds", "Time spent processing request"
|
||||
)
|
||||
COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands processed")
|
||||
|
||||
|
||||
class TelegramSmsBot:
|
||||
def __init__(
|
||||
self,
|
||||
token: str,
|
||||
twilio_client: Client | None = None,
|
||||
twilio_from_number: str | None = None,
|
||||
owners: list[int] = [],
|
||||
subscribers: list[int] = [],
|
||||
):
|
||||
def __init__(self, token: str, owners: list[int] = [], subscribers: list[int] = []):
|
||||
self.logger = logging.getLogger(self.__class__.__name__)
|
||||
self.app = Application.builder().token(token).build()
|
||||
self.owners = owners
|
||||
self.subscribers = subscribers
|
||||
self.twilio_client = twilio_client
|
||||
self.twilio_from_number = twilio_from_number
|
||||
|
||||
self.init_handlers()
|
||||
|
||||
@@ -40,22 +32,24 @@ class TelegramSmsBot:
|
||||
self.app.add_handler(CommandHandler(["help", "start"], self.handler_help))
|
||||
self.app.add_handler(CommandHandler("subscribe", self.handler_subscribe))
|
||||
self.app.add_handler(CommandHandler("unsubscribe", self.handler_unsubscribe))
|
||||
self.app.add_handler(CommandHandler("sms", self.handler_sms))
|
||||
|
||||
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
|
||||
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.id}")
|
||||
raise ApplicationHandlerStop
|
||||
|
||||
async def send_message(self, chat_id: int, text: str):
|
||||
"""Send a message to a specific chat"""
|
||||
self.logger.info(f"Sending message to chat {chat_id}: {text}")
|
||||
await self.app.bot.send_message(chat_id=chat_id, text=text, parse_mode="MarkdownV2")
|
||||
await self.app.bot.send_message(
|
||||
chat_id=chat_id, text=text, parse_mode="MarkdownV2"
|
||||
)
|
||||
|
||||
async def send_subscribers(self, text: str):
|
||||
"""Send a message to all subscribers"""
|
||||
@@ -70,60 +64,48 @@ class TelegramSmsBot:
|
||||
await self.send_message(owner, text)
|
||||
|
||||
@REQUEST_TIME.time()
|
||||
async def handler_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
async def handler_help(self, update, context):
|
||||
"""Send a message when the command /help is issued."""
|
||||
if update.message:
|
||||
self.logger.info("/help command received in chat: %s", update.message.chat)
|
||||
self.logger.info("/help command received in chat: %s", update.message.chat)
|
||||
|
||||
commands = []
|
||||
for command in self.app.handlers[0]:
|
||||
if isinstance(command, CommandHandler):
|
||||
commands.extend(["/{0}".format(cmd) for cmd in command.commands])
|
||||
commands = []
|
||||
for command in self.app.handlers[0]:
|
||||
if isinstance(command, CommandHandler):
|
||||
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)))
|
||||
COMMAND_COUNT.inc()
|
||||
await update.message.reply_markdown(
|
||||
"Smsbot v{0}\n\n{1}".format(get_smsbot_version(), "\n".join(commands))
|
||||
)
|
||||
COMMAND_COUNT.inc()
|
||||
|
||||
@REQUEST_TIME.time()
|
||||
async def handler_subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
async def handler_subscribe(
|
||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
):
|
||||
"""Handle subscription requests"""
|
||||
if update.effective_user and update.message:
|
||||
user_id = update.effective_user.id
|
||||
if user_id not in self.subscribers:
|
||||
self.subscribers.append(user_id)
|
||||
self.logger.info(f"User {user_id} subscribed.")
|
||||
self.logger.info(f"Current subscribers: {self.subscribers}")
|
||||
await update.message.reply_markdown("You have successfully subscribed to updates.")
|
||||
else:
|
||||
self.logger.info(f"User {user_id} is already subscribed.")
|
||||
user_id = update.effective_user.id
|
||||
if user_id not in self.subscribers:
|
||||
self.subscribers.append(user_id)
|
||||
self.logger.info(f"User {user_id} subscribed.")
|
||||
self.logger.info(f"Current subscribers: {self.subscribers}")
|
||||
await update.message.reply_markdown(
|
||||
"You have successfully subscribed to updates."
|
||||
)
|
||||
else:
|
||||
self.logger.info(f"User {user_id} is already subscribed.")
|
||||
|
||||
@REQUEST_TIME.time()
|
||||
async def handler_unsubscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
async def handler_unsubscribe(
|
||||
self, update: Update, context: ContextTypes.DEFAULT_TYPE
|
||||
):
|
||||
"""Handle unsubscription requests"""
|
||||
if update.effective_user and update.message:
|
||||
user_id = update.effective_user.id
|
||||
if user_id in self.subscribers:
|
||||
self.subscribers.remove(user_id)
|
||||
self.logger.info(f"User {user_id} unsubscribed.")
|
||||
self.logger.info(f"Current subscribers: {self.subscribers}")
|
||||
await update.message.reply_markdown("You have successfully unsubscribed from updates.")
|
||||
else:
|
||||
self.logger.info(f"User {user_id} is not subscribed.")
|
||||
|
||||
@REQUEST_TIME.time()
|
||||
async def handler_sms(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle sending SMS requests"""
|
||||
if update.effective_user and update.message:
|
||||
user_id = update.effective_user.id
|
||||
if self.twilio_client and self.twilio_from_number:
|
||||
to = context.args[0] if context.args else "No recipient provided"
|
||||
message = context.args[1] if context.args else "No message provided"
|
||||
self.logger.info(f"Sending SMS from user {user_id} -> {to}: {message}")
|
||||
|
||||
try:
|
||||
self.twilio_client.messages.create(body=message, to=to, from_=self.twilio_from_number)
|
||||
except Exception:
|
||||
self.logger.exception("Failed to send SMS due to exception")
|
||||
await update.message.reply_markdown("Failed to send SMS")
|
||||
pass
|
||||
else:
|
||||
await update.message.reply_markdown("Twilio client is not configured, cannot send SMS.")
|
||||
user_id = update.effective_user.id
|
||||
if user_id in self.subscribers:
|
||||
self.subscribers.remove(user_id)
|
||||
self.logger.info(f"User {user_id} unsubscribed.")
|
||||
self.logger.info(f"Current subscribers: {self.subscribers}")
|
||||
await update.message.reply_markdown(
|
||||
"You have successfully unsubscribed from updates."
|
||||
)
|
||||
else:
|
||||
self.logger.info(f"User {user_id} is not subscribed.")
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from importlib.metadata import version
|
||||
|
||||
|
||||
def get_smsbot_version():
|
||||
return version("smsbot")
|
||||
|
||||
|
||||
class TwilioWebhookPayload:
|
||||
@staticmethod
|
||||
def parse(data: dict[str, str]) -> "TwilioCall | TwilioMessage | None":
|
||||
def parse(data: dict):
|
||||
"""Return the correct class for the incoming Twilio webhook payload"""
|
||||
if "SmsMessageSid" in data:
|
||||
return TwilioMessage(data)
|
||||
@@ -55,7 +62,11 @@ class TwilioMessage(TwilioWebhookPayload):
|
||||
return msg
|
||||
|
||||
def to_markdownv2(self):
|
||||
media_str = "\n".join([f"{self._escape(url)}" for url in self.media]) if self.media else ""
|
||||
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
|
||||
|
||||
@@ -74,6 +85,6 @@ class TwilioCall(TwilioWebhookPayload):
|
||||
msg = f"Call from {self.from_number}, rejected."
|
||||
return msg
|
||||
|
||||
def to_markdownv2(self) -> str:
|
||||
def to_markdownv2(self):
|
||||
msg = f"Call from {self._escape(self.from_number)}, rejected\\."
|
||||
return msg
|
||||
@@ -1,5 +0,0 @@
|
||||
from importlib.metadata import version
|
||||
|
||||
|
||||
def get_smsbot_version() -> str:
|
||||
return version("smsbot")
|
||||
@@ -6,19 +6,17 @@ 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
|
||||
from smsbot.utils import TwilioWebhookPayload, get_smsbot_version
|
||||
|
||||
REQUEST_TIME = Summary("webhook_request_processing_seconds", "Time spent processing request")
|
||||
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
|
||||
"""
|
||||
|
||||
class TwilioWebhookHandler(object):
|
||||
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"])
|
||||
@@ -26,7 +24,6 @@ class TwilioWebhookHandler(object):
|
||||
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
|
||||
|
||||
@@ -49,7 +46,9 @@ class TwilioWebhookHandler(object):
|
||||
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")
|
||||
current_app.logger.warning(
|
||||
"Twilio request validation skipped due to Twilio Auth Token missing"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
validator = RequestValidator(self.auth_token)
|
||||
|
||||
@@ -69,40 +68,44 @@ class TwilioWebhookHandler(object):
|
||||
|
||||
return decorated_function
|
||||
|
||||
def set_telegram_application(self, app):
|
||||
"""Set the Telegram application instance to use for any webhook calls"""
|
||||
self.telegram_app = app
|
||||
def set_bot(self, bot):
|
||||
self.bot = bot
|
||||
|
||||
async def index(self) -> str:
|
||||
async def index(self):
|
||||
return f'smsbot v{get_smsbot_version()} - <a href="https://github.com/nikdoof/smsbot">GitHub</a>'
|
||||
|
||||
async def health(self) -> dict[str, str | int]:
|
||||
async def health(self):
|
||||
"""Return basic health information"""
|
||||
return {
|
||||
"version": get_smsbot_version(),
|
||||
"owners": self.telegram_app.owners,
|
||||
"subscribers": len(self.telegram_app.subscribers),
|
||||
"owners": self.bot.owners,
|
||||
"subscribers": self.bot.subscribers,
|
||||
}
|
||||
|
||||
@time(REQUEST_TIME)
|
||||
async def message(self) -> str:
|
||||
async def message(self):
|
||||
"""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())
|
||||
current_app.logger.info(
|
||||
"Received SMS from {From}: {Body}".format(**request.values.to_dict())
|
||||
)
|
||||
|
||||
await self.bot.send_subscribers(
|
||||
TwilioWebhookPayload.parse(request.values.to_dict()).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:
|
||||
async def call(self):
|
||||
"""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())
|
||||
current_app.logger.info(
|
||||
"Received Call from {From}".format(**request.values.to_dict())
|
||||
)
|
||||
await self.bot.send_subscribers(
|
||||
TwilioWebhookPayload.parse(request.values.to_dict()).to_markdownv2()
|
||||
)
|
||||
|
||||
# Always reject calls
|
||||
CALL_COUNT.inc()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from smsbot.utils.twilio import TwilioMessage
|
||||
from smsbot.utils import TwilioMessage
|
||||
|
||||
|
||||
def test_twiliomessage_normal():
|
||||
|
||||
Reference in New Issue
Block a user