10 Commits

10 changed files with 187 additions and 86 deletions

View File

@@ -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]+"

3
.gitignore vendored
View File

@@ -212,3 +212,6 @@ __marimo__/
# Built Visual Studio Code Extensions # Built Visual Studio Code Extensions
*.vsix *.vsix
# smsbot config file
config.ini

View File

@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:python3.9-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_COMPILE_BYTECODE=1 UV_LINK_MODE=copy
ENV UV_PYTHON_DOWNLOADS=0 ENV UV_PYTHON_DOWNLOADS=0
WORKDIR /app WORKDIR /app

14
config-example.ini Normal file
View File

@@ -0,0 +1,14 @@
[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

View File

@@ -1,11 +1,11 @@
[project] [project]
name = "smsbot" name = "smsbot"
version = "0.1.0" version = "0.1.1"
description = "A simple Telegram bot to receive SMS messages." description = "A simple Telegram bot to receive SMS messages."
authors = [{ name = "Andrew Williams", email = "andy@tensixtyone.com" }] authors = [{ name = "Andrew Williams", email = "andy@tensixtyone.com" }]
license = { text = "MIT" } license = { text = "MIT" }
readme = "README.md" readme = "README.md"
requires-python = ">=3.13,<3.14" requires-python = ">=3.13"
dependencies = [ dependencies = [
"flask[async]>=3.1.1", "flask[async]>=3.1.1",
"prometheus-async>=25.1.0", "prometheus-async>=25.1.0",

View File

@@ -2,6 +2,8 @@ import argparse
import asyncio import asyncio
import logging import logging
import os import os
import sys
from configparser import ConfigParser
from signal import SIGINT, SIGTERM from signal import SIGINT, SIGTERM
import uvicorn import uvicorn
@@ -9,59 +11,79 @@ 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
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, type=int
)
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("--debug", action="store_true")
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) logging.basicConfig(level=level, stream=sys.stdout)
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("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")
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)
# Start bot # Start bot
telegram_bot = TelegramSmsBot(token=args.telegram_bot_token) telegram_bot = TelegramSmsBot(token=config.get("telegram", "bot_token"))
# 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.owners = [int(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", "default_subscribers"):
for chat_id in args.default_subscribers.split(","): for chat_id in config.get("telegram", "default_subscribers").split(","):
telegram_bot.subscribers.append(int(chat_id.strip())) telegram_bot.subscribers.append(int(chat_id.strip()))
webhooks = TwilioWebhookHandler() # 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_bot(telegram_bot) webhooks.set_bot(telegram_bot)
# Build a uvicorn ASGI server # Build a uvicorn ASGI server
flask_app = uvicorn.Server( flask_app = uvicorn.Server(
config=uvicorn.Config( config=uvicorn.Config(
app=WsgiToAsgi(webhooks.app), app=WsgiToAsgi(webhooks.app),
port=args.listen_port, port=config.getint("webhook", "port", fallback=5000),
use_colors=False, use_colors=False,
host=args.listen_host, host=config.get("webhook", "host", fallback="127.0.0.1"),
) )
) )

View File

@@ -1,16 +1,17 @@
import logging import logging
from smsbot.utils import get_smsbot_version
from prometheus_client import Counter, Summary from prometheus_client import Counter, Summary
from telegram import Update from telegram import Update
from telegram.ext import ( from telegram.ext import (
Application, Application,
ApplicationHandlerStop, ApplicationHandlerStop,
CommandHandler,
ContextTypes, ContextTypes,
TypeHandler, TypeHandler,
CommandHandler,
) )
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"
) )
@@ -28,7 +29,7 @@ class TelegramSmsBot:
def init_handlers(self): def init_handlers(self):
self.app.add_handler(TypeHandler(Update, self.callback), -1) self.app.add_handler(TypeHandler(Update, self.callback), -1)
self.app.add_handler(CommandHandler("help", 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))

View File

@@ -5,19 +5,14 @@ def get_smsbot_version():
return version("smsbot") return version("smsbot")
class TwilioMessage: class TwilioWebhookPayload:
""" @staticmethod
Parses a Twilio webhook message. def parse(data: dict):
""" """Return the correct class for the incoming Twilio webhook payload"""
if "SmsMessageSid" in data:
def __init__(self, data: dict) -> None: return TwilioMessage(data)
self.from_number: str = data.get("From", "Unknown") if "CallSid" in data:
self.to_number: str = data.get("To", "Unknown") return TwilioCall(data)
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 _escape(self, text: str) -> str: def _escape(self, text: str) -> str:
"""Escape text for MarkdownV2""" """Escape text for MarkdownV2"""
@@ -45,6 +40,19 @@ class TwilioMessage:
text = text.replace(char, rf"\{char}") text = text.replace(char, rf"\{char}")
return text 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: def __repr__(self) -> str:
return f"TwilioWebhookMessage(from={self.from_number}, to={self.to_number})" return f"TwilioWebhookMessage(from={self.from_number}, to={self.to_number})"
@@ -54,6 +62,29 @@ class TwilioMessage:
return msg return msg
def to_markdownv2(self): 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}" 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
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):
msg = f"Call from {self._escape(self.from_number)}, rejected\\."
return msg

View File

@@ -1,4 +1,3 @@
import os
from functools import wraps from functools import wraps
from flask import Flask, abort, current_app, request from flask import Flask, abort, current_app, request
@@ -7,7 +6,7 @@ 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 TwilioMessage, get_smsbot_version from smsbot.utils import TwilioWebhookPayload, get_smsbot_version
REQUEST_TIME = Summary( REQUEST_TIME = Summary(
"webhook_request_processing_seconds", "Time spent processing request" "webhook_request_processing_seconds", "Time spent processing request"
@@ -16,47 +15,22 @@ MESSAGE_COUNT = Counter("webhook_message_count", "Total number of messages proce
CALL_COUNT = Counter("webhook_call_count", "Total number of calls 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)
async def decorated_function(*args, **kwargs):
# 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 await 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): class TwilioWebhookHandler(object):
def __init__(self): 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"])
self.app.add_url_rule("/health", "health", self.health, 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("/message", "message", self.message, methods=["POST"])
self.app.add_url_rule("/call", "call", self.call, methods=["POST"]) self.app.add_url_rule("/call", "call", self.call, methods=["POST"])
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 # Add prometheus wsgi middleware to route /metrics requests
self.app.wsgi_app = DispatcherMiddleware( self.app.wsgi_app = DispatcherMiddleware(
self.app.wsgi_app, self.app.wsgi_app,
@@ -65,6 +39,35 @@ class TwilioWebhookHandler(object):
}, },
) )
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_bot(self, bot): def set_bot(self, bot):
self.bot = bot self.bot = bot
@@ -80,7 +83,6 @@ class TwilioWebhookHandler(object):
} }
@time(REQUEST_TIME) @time(REQUEST_TIME)
@validate_twilio_request
async def message(self): async def message(self):
"""Handle incoming SMS messages from Twilio""" """Handle incoming SMS messages from Twilio"""
current_app.logger.info( current_app.logger.info(
@@ -88,7 +90,7 @@ class TwilioWebhookHandler(object):
) )
await self.bot.send_subscribers( await self.bot.send_subscribers(
TwilioMessage(request.values.to_dict()).to_markdownv2() TwilioWebhookPayload.parse(request.values.to_dict()).to_markdownv2()
) )
# Return a blank response # Return a blank response
@@ -96,14 +98,13 @@ class TwilioWebhookHandler(object):
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)
@validate_twilio_request
async def call(self): async def call(self):
"""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())
) )
await self.bot.send_subscribers( await self.bot.send_subscribers(
"Received Call from {From}, rejecting.".format(**request.values.to_dict()) TwilioWebhookPayload.parse(request.values.to_dict()).to_markdownv2()
) )
# Always reject calls # Always reject calls

37
uv.lock generated
View File

@@ -1,6 +1,6 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = "==3.13.*" requires-python = ">=3.13"
[[package]] [[package]]
name = "aiohappyeyeballs" name = "aiohappyeyeballs"
@@ -135,6 +135,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" },
{ url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" },
{ url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" },
{ url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" },
{ url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" },
{ url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" },
{ url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" },
{ url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" },
{ url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
] ]
@@ -581,7 +592,7 @@ wheels = [
[[package]] [[package]]
name = "smsbot" name = "smsbot"
version = "0.1.0" version = "0.1.1"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "flask", extra = ["async"] }, { name = "flask", extra = ["async"] },
@@ -696,6 +707,26 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" },
{ url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" },
{ url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" },
{ url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" },
{ url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" },
{ url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" },
{ url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" },
{ url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" },
{ url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" },
{ url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" },
{ url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" },
{ url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" },
{ url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" },
{ url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" },
{ url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" },
{ url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" },
{ url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" },
{ url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" },
{ url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" },
{ url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" },
{ url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" },
] ]