18 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
18 changed files with 188 additions and 32 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

@@ -13,7 +13,7 @@ jobs:
matrix: matrix:
python-version: ["3.13"] 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

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

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

@@ -13,3 +13,4 @@ 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" }

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(
@@ -43,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, define a token either in the config file or as an environment variable.") 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"):
@@ -76,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,6 +9,7 @@ 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
@@ -17,11 +18,20 @@ COMMAND_COUNT = Counter("telegram_command_count", "Total number of commands proc
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()
@@ -30,6 +40,7 @@ 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"""
@@ -97,3 +108,22 @@ class TelegramSmsBot:
await update.message.reply_markdown("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,10 +1,3 @@
from importlib.metadata import version
def get_smsbot_version() -> str:
return version("smsbot")
class TwilioWebhookPayload: class TwilioWebhookPayload:
@staticmethod @staticmethod
def parse(data: dict[str, str]) -> "TwilioCall | TwilioMessage | None": def parse(data: dict[str, str]) -> "TwilioCall | TwilioMessage | None":

View File

@@ -6,7 +6,8 @@ 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("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") MESSAGE_COUNT = Counter("webhook_message_count", "Total number of messages processed")
@@ -68,8 +69,9 @@ 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) -> str: 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>'
@@ -78,8 +80,8 @@ class TwilioWebhookHandler(object):
"""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)
@@ -88,7 +90,7 @@ class TwilioWebhookHandler(object):
current_app.logger.info("Received SMS from {From}: {Body}".format(**request.values.to_dict())) current_app.logger.info("Received SMS from {From}: {Body}".format(**request.values.to_dict()))
hook_data = TwilioWebhookPayload.parse(request.values.to_dict()) hook_data = TwilioWebhookPayload.parse(request.values.to_dict())
if hook_data: if hook_data:
await self.bot.send_subscribers(hook_data.to_markdownv2()) await self.telegram_app.send_subscribers(hook_data.to_markdownv2())
# Return a blank response # Return a blank response
MESSAGE_COUNT.inc() MESSAGE_COUNT.inc()
@@ -100,7 +102,7 @@ class TwilioWebhookHandler(object):
current_app.logger.info("Received Call from {From}".format(**request.values.to_dict())) current_app.logger.info("Received Call from {From}".format(**request.values.to_dict()))
hook_data = TwilioWebhookPayload.parse(request.values.to_dict()) hook_data = TwilioWebhookPayload.parse(request.values.to_dict())
if hook_data: if hook_data:
await self.bot.send_subscribers(hook_data.to_markdownv2()) await self.telegram_app.send_subscribers(hook_data.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"] },