22 Commits

Author SHA1 Message Date
d00a4048cd Version 0.2.2 2025-08-17 15:28:55 +01:00
bb5472f092 Add a basic config file into the container 2025-08-17 15:28:34 +01:00
6ce86042fb Build ARM64 container 2025-08-17 15:26:32 +01:00
0a2970c38f Add workdir to Dockerfile 2025-08-17 15:25:42 +01:00
126713c84a Fix environment variable config overriding 2025-08-17 15:24:23 +01:00
bace0200ab Add devcontainer configuration 2025-08-17 12:51:32 +01:00
e98b8e6b8c Version 0.2.1 2025-08-17 12:38:05 +01:00
876b0363c0 Merge pull request #69 from nikdoof/renovate/actions-checkout-5.x
Update actions/checkout action to v5
2025-08-17 12:37:05 +01:00
d0c18a1d00 Add default task 2025-08-17 12:35:25 +01:00
b056d6328d Add missing var in the example configuration 2025-08-17 12:33:39 +01:00
40a263686d Add example deployment for Flux 2025-08-17 12:31:51 +01:00
5f1e5508f0 Correct default port for Docker 2025-08-17 12:28:22 +01:00
renovate[bot]
6f36caf6e1 Update actions/checkout action to v5 2025-08-17 11:25:44 +00:00
40e23c32a8 Fix Docker build 2025-08-17 12:21:29 +01:00
51f4a62738 Cleanup lint issues 2025-08-17 12:18:56 +01:00
b0afb9b15d Version 0.2.0 2025-08-17 12:17:07 +01:00
1e28526be7 Use '.python-version' for builds 2025-08-17 12:16:55 +01:00
594f4ba8ef Support sending SMS 2025-08-17 11:59:11 +01:00
b4e833f440 Update workflows to test on the current Python version 2025-08-17 08:21:58 +01:00
4de940be7c Add logfile support 2025-08-17 08:21:47 +01:00
837b959b9b Update README 2025-08-17 07:30:45 +01:00
8ba995bc5f Cleanup types 2025-08-17 07:30:34 +01:00
20 changed files with 294 additions and 130 deletions

View 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
}
}
}

View File

@@ -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

View File

@@ -11,9 +11,9 @@ 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:

View File

@@ -10,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@v5
with:
python-version: "3.9"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@v6 uses: astral-sh/setup-uv@v6

View File

@@ -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

View File

@@ -1,4 +1,6 @@
FROM ghcr.io/astral-sh/uv:python3.13-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.13-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"]

View File

@@ -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.

View File

@@ -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,7 +19,7 @@ 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: smsbot:run:
desc: Run the SMSBot desc: Run the SMSBot

5
docs/examples/README.md Normal file
View 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.

View File

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

View File

@@ -7,8 +7,10 @@ port = 80
[telegram] [telegram]
owner_id = OWNER_USER_ID owner_id = OWNER_USER_ID
subscribers = 1111,2222,3333
bot_token = BOT_TOKEN bot_token = BOT_TOKEN
[twilio] [twilio]
account_sid = TWILIO_ACCOUNT_SID account_sid = TWILIO_ACCOUNT_SID
auth_token = TWILIO_AUTH_TOKEN auth_token = TWILIO_AUTH_TOKEN
from_number = +12345678901

View 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

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "smsbot" name = "smsbot"
version = "0.1.1" 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" }
@@ -30,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

View File

@@ -5,6 +5,7 @@ import os
import sys import sys
from configparser import ConfigParser from configparser import ConfigParser
from signal import SIGINT, SIGTERM from signal import SIGINT, SIGTERM
from twilio.rest import Client
import uvicorn import uvicorn
from asgiref.wsgi import WsgiToAsgi from asgiref.wsgi import WsgiToAsgi
@@ -14,6 +15,10 @@ from smsbot.utils import get_smsbot_version
from smsbot.webhook 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(
@@ -24,13 +29,14 @@ def main():
help="Path to the config file", help="Path to the config file",
) )
parser.add_argument("--debug", action="store_true", help="Enable debug mode") 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() args = parser.parse_args()
if args.debug: if args.debug:
level = logging.DEBUG level = logging.DEBUG
else: else:
level = logging.INFO level = logging.INFO
logging.basicConfig(level=level, stream=sys.stdout) 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)
@@ -42,22 +48,46 @@ def main():
# Override with environment variables, named SMSBOT_<SECTION>_<VALUE> # Override with environment variables, named SMSBOT_<SECTION>_<VALUE>
for key, value in os.environ.items(): for key, value in os.environ.items():
if key.startswith("SMSBOT_"): if key.startswith(ENVIRONMENT_PREFIX):
logging.debug("Overriding config %s with value %s", key, value) section, option = key[7:].lower().split("_", 1)
section, option = key[7:].split("_", 1) logging.debug("Overriding config %s/%s = %s", section, option, value)
if not config.has_section(section):
config.add_section(section)
config[section][option] = value config[section][option] = value
# Validate configuration # Validate configuration
if not config.has_section("telegram") or not config.get("telegram", "bot_token"): if not config.has_section("telegram") or not config.get("telegram", "bot_token"):
logging.error("Telegram bot token is required") 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 return
# Now the config is loaded, set the logger level # Now the config is loaded, set the logger level
level = getattr(logging, config.get("logging", "level", fallback="INFO").upper(), logging.INFO) level = getattr(logging, config.get("logging", "level", fallback="INFO").upper(), logging.INFO)
logging.getLogger().setLevel(level) 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(token=config.get("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 config.has_option("telegram", "owner_id"): if config.has_option("telegram", "owner_id"):
@@ -66,8 +96,8 @@ def main():
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", "default_subscribers"): if config.has_option("telegram", "subscribers"):
for chat_id in config.get("telegram", "default_subscribers").split(","): for chat_id in config.get("telegram", "subscribers").split(","):
telegram_bot.subscribers.append(int(chat_id.strip())) telegram_bot.subscribers.append(int(chat_id.strip()))
# Init the webhook handler # Init the webhook handler
@@ -75,7 +105,7 @@ def main():
account_sid=config.get("twilio", "account_sid", fallback=None), account_sid=config.get("twilio", "account_sid", fallback=None),
auth_token=config.get("twilio", "auth_token", fallback=None), auth_token=config.get("twilio", "auth_token", fallback=None),
) )
webhooks.set_bot(telegram_bot) webhooks.set_telegram_application(telegram_bot)
# Build a uvicorn ASGI server # Build a uvicorn ASGI server
flask_app = uvicorn.Server( flask_app = uvicorn.Server(

View File

@@ -9,21 +9,29 @@ from telegram.ext import (
ContextTypes, ContextTypes,
TypeHandler, 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: class TelegramSmsBot:
def __init__(self, token: str, owners: list[int] = [], subscribers: list[int] = []): def __init__(
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.app = Application.builder().token(token).build() self.app = Application.builder().token(token).build()
self.owners = owners self.owners = owners
self.subscribers = subscribers self.subscribers = subscribers
self.twilio_client = twilio_client
self.twilio_from_number = twilio_from_number
self.init_handlers() self.init_handlers()
@@ -32,24 +40,22 @@ class TelegramSmsBot:
self.app.add_handler(CommandHandler(["help", "start"], self.handler_help)) self.app.add_handler(CommandHandler(["help", "start"], self.handler_help))
self.app.add_handler(CommandHandler("subscribe", self.handler_subscribe)) self.app.add_handler(CommandHandler("subscribe", self.handler_subscribe))
self.app.add_handler(CommandHandler("unsubscribe", self.handler_unsubscribe)) 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): async def callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle the update""" """Handle the update"""
if update.effective_user.id in self.owners: if update.effective_user and update.message:
self.logger.info( if update.effective_user.id in self.owners:
f"{update.effective_user.username} sent {update.message.text}" self.logger.info(f"{update.effective_user.username} sent {update.message.text}")
) COMMAND_COUNT.inc()
COMMAND_COUNT.inc() else:
else: self.logger.debug(f"Ignoring message from user {update.effective_user.username}")
self.logger.debug(f"Ignoring message from user {update.effective_user.id}") raise ApplicationHandlerStop
raise ApplicationHandlerStop
async def send_message(self, chat_id: int, text: str): async def send_message(self, chat_id: int, text: str):
"""Send a message to a specific chat""" """Send a message to a specific chat"""
self.logger.info(f"Sending message to chat {chat_id}: {text}") self.logger.info(f"Sending message to chat {chat_id}: {text}")
await self.app.bot.send_message( await self.app.bot.send_message(chat_id=chat_id, text=text, parse_mode="MarkdownV2")
chat_id=chat_id, text=text, parse_mode="MarkdownV2"
)
async def send_subscribers(self, text: str): async def send_subscribers(self, text: str):
"""Send a message to all subscribers""" """Send a message to all subscribers"""
@@ -64,48 +70,60 @@ class TelegramSmsBot:
await self.send_message(owner, text) await self.send_message(owner, text)
@REQUEST_TIME.time() @REQUEST_TIME.time()
async def handler_help(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.app.handlers[0]: for command in self.app.handlers[0]:
if isinstance(command, CommandHandler): if isinstance(command, CommandHandler):
commands.extend(["/{0}".format(cmd) for cmd in command.commands]) commands.extend(["/{0}".format(cmd) for cmd in command.commands])
await 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()
async def handler_subscribe( async def handler_subscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
self, update: Update, context: ContextTypes.DEFAULT_TYPE
):
"""Handle subscription requests""" """Handle subscription requests"""
user_id = update.effective_user.id if update.effective_user and update.message:
if user_id not in self.subscribers: user_id = update.effective_user.id
self.subscribers.append(user_id) if user_id not in self.subscribers:
self.logger.info(f"User {user_id} subscribed.") self.subscribers.append(user_id)
self.logger.info(f"Current subscribers: {self.subscribers}") self.logger.info(f"User {user_id} subscribed.")
await update.message.reply_markdown( self.logger.info(f"Current subscribers: {self.subscribers}")
"You have successfully subscribed to updates." await update.message.reply_markdown("You have successfully subscribed to updates.")
) else:
else: self.logger.info(f"User {user_id} is already subscribed.")
self.logger.info(f"User {user_id} is already subscribed.")
@REQUEST_TIME.time() @REQUEST_TIME.time()
async def handler_unsubscribe( async def handler_unsubscribe(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
self, update: Update, context: ContextTypes.DEFAULT_TYPE
):
"""Handle unsubscription requests""" """Handle unsubscription requests"""
user_id = update.effective_user.id if update.effective_user and update.message:
if user_id in self.subscribers: user_id = update.effective_user.id
self.subscribers.remove(user_id) if user_id in self.subscribers:
self.logger.info(f"User {user_id} unsubscribed.") self.subscribers.remove(user_id)
self.logger.info(f"Current subscribers: {self.subscribers}") self.logger.info(f"User {user_id} unsubscribed.")
await update.message.reply_markdown( self.logger.info(f"Current subscribers: {self.subscribers}")
"You have successfully unsubscribed from updates." await update.message.reply_markdown("You have successfully unsubscribed from updates.")
) else:
else: self.logger.info(f"User {user_id} is not subscribed.")
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.")

5
smsbot/utils/__init__.py Normal file
View File

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

View File

@@ -1,13 +1,6 @@
from importlib.metadata import version
def get_smsbot_version():
return version("smsbot")
class TwilioWebhookPayload: class TwilioWebhookPayload:
@staticmethod @staticmethod
def parse(data: dict): def parse(data: dict[str, str]) -> "TwilioCall | TwilioMessage | None":
"""Return the correct class for the incoming Twilio webhook payload""" """Return the correct class for the incoming Twilio webhook payload"""
if "SmsMessageSid" in data: if "SmsMessageSid" in data:
return TwilioMessage(data) return TwilioMessage(data)
@@ -62,11 +55,7 @@ class TwilioMessage(TwilioWebhookPayload):
return msg return msg
def to_markdownv2(self): def to_markdownv2(self):
media_str = ( media_str = "\n".join([f"{self._escape(url)}" for url in self.media]) if self.media else ""
"\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}" 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 return msg
@@ -85,6 +74,6 @@ class TwilioCall(TwilioWebhookPayload):
msg = f"Call from {self.from_number}, rejected." msg = f"Call from {self.from_number}, rejected."
return msg return msg
def to_markdownv2(self): def to_markdownv2(self) -> str:
msg = f"Call from {self._escape(self.from_number)}, rejected\\." msg = f"Call from {self._escape(self.from_number)}, rejected\\."
return msg return msg

View File

@@ -6,20 +6,19 @@ from prometheus_client import Counter, Summary, make_wsgi_app
from twilio.request_validator import RequestValidator from twilio.request_validator import RequestValidator
from werkzeug.middleware.dispatcher import DispatcherMiddleware from werkzeug.middleware.dispatcher import DispatcherMiddleware
from smsbot.utils import TwilioWebhookPayload, get_smsbot_version from smsbot.utils import get_smsbot_version
from smsbot.utils.twilio import TwilioWebhookPayload
REQUEST_TIME = Summary( REQUEST_TIME = Summary("webhook_request_processing_seconds", "Time spent processing request")
"webhook_request_processing_seconds", "Time spent processing request"
)
MESSAGE_COUNT = Counter("webhook_message_count", "Total number of messages processed") MESSAGE_COUNT = Counter("webhook_message_count", "Total number of messages processed")
CALL_COUNT = Counter("webhook_call_count", "Total number of calls processed") CALL_COUNT = Counter("webhook_call_count", "Total number of calls processed")
class TwilioWebhookHandler(object): class TwilioWebhookHandler(object):
""" """
A wrapped Flask app handling webhooks received from Twilio A wrapped Flask app handling webhooks received from Twilio
""" """
def __init__(self, account_sid: str | None = None, auth_token: str | None = None): def __init__(self, account_sid: str | None = None, auth_token: str | None = None):
self.app = Flask(self.__class__.__name__) self.app = Flask(self.__class__.__name__)
self.app.add_url_rule("/", "index", self.index, methods=["GET"]) self.app.add_url_rule("/", "index", self.index, methods=["GET"])
@@ -50,9 +49,7 @@ class TwilioWebhookHandler(object):
async def decorated_function(*args, **kwargs): async def decorated_function(*args, **kwargs):
# Create an instance of the RequestValidator class # Create an instance of the RequestValidator class
if not self.auth_token: if not self.auth_token:
current_app.logger.warning( current_app.logger.warning("Twilio request validation skipped due to Twilio Auth Token missing")
"Twilio request validation skipped due to Twilio Auth Token missing"
)
return await func(*args, **kwargs) return await func(*args, **kwargs)
validator = RequestValidator(self.auth_token) validator = RequestValidator(self.auth_token)
@@ -72,44 +69,40 @@ class TwilioWebhookHandler(object):
return decorated_function return decorated_function
def set_bot(self, bot): def set_telegram_application(self, app):
self.bot = bot """Set the Telegram application instance to use for any webhook calls"""
self.telegram_app = app
async def index(self): async def index(self) -> str:
return f'smsbot v{get_smsbot_version()} - <a href="https://github.com/nikdoof/smsbot">GitHub</a>' return f'smsbot v{get_smsbot_version()} - <a href="https://github.com/nikdoof/smsbot">GitHub</a>'
async def health(self): async def health(self) -> dict[str, str | int]:
"""Return basic health information""" """Return basic health information"""
return { return {
"version": get_smsbot_version(), "version": get_smsbot_version(),
"owners": self.bot.owners, "owners": self.telegram_app.owners,
"subscribers": len(self.bot.subscribers), "subscribers": len(self.telegram_app.subscribers),
} }
@time(REQUEST_TIME) @time(REQUEST_TIME)
async def message(self): async def message(self) -> str:
"""Handle incoming SMS messages from Twilio""" """Handle incoming SMS messages from Twilio"""
current_app.logger.info( current_app.logger.info("Received SMS from {From}: {Body}".format(**request.values.to_dict()))
"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())
await self.bot.send_subscribers(
TwilioWebhookPayload.parse(request.values.to_dict()).to_markdownv2()
)
# Return a blank response # Return a blank response
MESSAGE_COUNT.inc() MESSAGE_COUNT.inc()
return '<?xml version="1.0" encoding="UTF-8"?><Response></Response>' return '<?xml version="1.0" encoding="UTF-8"?><Response></Response>'
@time(REQUEST_TIME) @time(REQUEST_TIME)
async def call(self): async def call(self) -> str:
"""Handle incoming calls from Twilio""" """Handle incoming calls from Twilio"""
current_app.logger.info( current_app.logger.info("Received Call from {From}".format(**request.values.to_dict()))
"Received Call from {From}".format(**request.values.to_dict()) hook_data = TwilioWebhookPayload.parse(request.values.to_dict())
) if hook_data:
await self.bot.send_subscribers( await self.telegram_app.send_subscribers(hook_data.to_markdownv2())
TwilioWebhookPayload.parse(request.values.to_dict()).to_markdownv2()
)
# Always reject calls # Always reject calls
CALL_COUNT.inc() CALL_COUNT.inc()

View File

@@ -1,4 +1,4 @@
from smsbot.utils import TwilioMessage from smsbot.utils.twilio import TwilioMessage
def test_twiliomessage_normal(): def test_twiliomessage_normal():

2
uv.lock generated
View File

@@ -592,7 +592,7 @@ wheels = [
[[package]] [[package]]
name = "smsbot" name = "smsbot"
version = "0.1.1" version = "0.2.2"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "flask", extra = ["async"] }, { name = "flask", extra = ["async"] },