~kf5jwc/sms-printer

88d91e19f2499a3933341a270f81e30ebc048ac3 — Kyle Jones 2 years ago bc02043
Refactor for adding JSON schema based parsing
M Pipfile => Pipfile +2 -0
@@ 10,6 10,7 @@ name = "pypi"
gunicorn = "*"
flask = "*"
pyxdg = "*"
jsonschema = "*"


[dev-packages]


@@ 22,4 23,5 @@ mypy = "*"

[requires]


python_version = "3.5"

M Pipfile.lock => Pipfile.lock +17 -10
@@ 1,7 1,7 @@
{
    "_meta": {
        "hash": {
            "sha256": "08aa17c554300a19a8641e929816cb35d9884f816301602b42678109041a320a"
            "sha256": "8f1516fa624f8674026fd9cbe007802aa0480e14bc0346218ca441e4c64d7265"
        },
        "pipfile-spec": 6,
        "requires": {


@@ 50,6 50,13 @@
            ],
            "version": "==2.10"
        },
        "jsonschema": {
            "hashes": [
                "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08",
                "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02"
            ],
            "version": "==2.6.0"
        },
        "markupsafe": {
            "hashes": [
                "sha256:a6be69091dac236ea9c6bc7d012beab42010fa914c459791d627dad4910eb665"


@@ 74,10 81,10 @@
    "develop": {
        "astroid": {
            "hashes": [
                "sha256:35cfae47aac19c7b407b7095410e895e836f2285ccf1220336afba744cc4c5f2",
                "sha256:38186e481b65877fd8b1f9acc33e922109e983eb7b6e487bd4c71002134ad331"
                "sha256:032f6e09161e96f417ea7fad46d3fac7a9019c775f202182c22df0e4f714cb1c",
                "sha256:dea42ae6e0b789b543f728ddae7ddb6740ba33a49fb52c4a4d9cb7bb4aa6ec09"
            ],
            "version": "==1.6.3"
            "version": "==1.6.4"
        },
        "isort": {
            "hashes": [


@@ 137,10 144,10 @@
        },
        "pylint": {
            "hashes": [
                "sha256:0b7e6b5d9f1d4e0b554b5d948f14ed7969e8cdf9a0120853e6e5af60813b18ab",
                "sha256:34738a82ab33cbd3bb6cd4cef823dbcabdd2b6b48a4e3a3054a2bbbf0c712be9"
                "sha256:aa519865f8890a5905fa34924fed0f3bfc7d84fc9f9142c16dac52ffecd25a39",
                "sha256:c353d8225195b37cc3aef18248b8f3fe94c5a6a95affaf885ae21a24ca31d8eb"
            ],
            "version": "==1.8.4"
            "version": "==1.9.1"
        },
        "six": {
            "hashes": [


@@ 180,10 187,10 @@
        },
        "yapf": {
            "hashes": [
                "sha256:7d8ae3567f3fb2d288f127d35e4decb3348c96cd091001e02e818465da618f90",
                "sha256:dd23b52edbb4c0461d0383050f7886175b0df9ab8fd0b67edd41f94e25770993"
                "sha256:6567745f0b6656f9c33a73c56a393071c699e6284a70d793798ab6e3769d25ec",
                "sha256:a98a6eacca64d2b920558f4a2f78150db9474de821227e60deaa29f186121c63"
            ],
            "version": "==0.21.0"
            "version": "==0.22.0"
        }
    }
}

M sms_printer/__init__.py => sms_printer/__init__.py +6 -1
@@ 1,3 1,8 @@
"This is a small script to accept SMS from bandwidth.com and print it locally"
"""
This prints SMS messages (on a real printer) using CUPS.

This is a web server which accepts JSON-formatted SMS messages
at /sms-printer from brokers (such as bandwidth.com) for printing.
"""

from .application import APP as application

M sms_printer/application.py => sms_printer/application.py +23 -62
@@ 1,20 1,13 @@
import os
from math import floor
from subprocess import CalledProcessError, run
from tempfile import NamedTemporaryFile
import logging

from flask import Flask, Response, json, request
from xdg import BaseDirectory

from .sms import NoMatchingSchema, parse_sms
from .utils import log_request, allowed_sender, log_request, print_message


APP = Flask(__name__)
ARCHIVE = os.getenv('ARCHIVE_DIR', BaseDirectory.save_data_path(__name__))
ALLOWED_SENDERS = [
    num for num in os.getenv('ALLOWED_SENDERS', '').split(',') if num
]
PRINTER_FORMAT = "\n\n" + "      {message}"*2
TEXT_SCALE = 0.6
CPI_DEFAULT = 10
LPI_DEFAULT = 6
logging.basicConfig(level=logging.DEBUG)


@APP.route("/", methods=['GET'])


@@ 24,61 17,29 @@ def main():

@APP.route("/sms-printer", methods=['POST'])
def sms_printer() -> Response:
    def sms_main() -> Response:
        user_agent = (request.user_agent.platform, request.user_agent.version)
        if user_agent is ("BandwidthAPI", "v2"):
            return bandwidth_v2()

        # Default, since they report no UA
        return bandwidth_v1()

    def allowed_sender(sender: str) -> bool:
        return (bool(ALLOWED_SENDERS) and sender not in ALLOWED_SENDERS)

    def archive_request(sms_id: str, data: json):
        with open(os.path.join(ARCHIVE, sms_id), 'w+') as archive_file:
            json.dump(data, archive_file)

    def print_message(message: str) -> bool:
        with NamedTemporaryFile(mode='w+') as tmp_file:
            print(PRINTER_FORMAT.format(message=message), file=tmp_file)
            tmp_file.flush()

            command = [
                "lp",
                "-o cpi={}".format(floor(CPI_DEFAULT*(1/TEXT_SCALE))),
                "-o lpi={}".format(floor(LPI_DEFAULT*(1/TEXT_SCALE))),
                tmp_file.name]
            try:
                run(command, check=True, timeout=10)
    # If this throws, flask takes care of it
    sms_input = request.get_json(force=True)

            except CalledProcessError:
                return False
    try:
        sms_list = parse_sms(sms_input)
        log_request(sms_input)

        return True
    except NoMatchingSchema:
        logging.error("Unrecognized message format!")
        logging.debug(json.dumps(sms_input))
        return Response(status=415)

    def bandwidth_v1() -> Response:
        sms = request.get_json(force=True)
        archive_request(sms['messageId'], sms)
    for sms in sms_list:

        if not allowed_sender(sms['from']):
        logging.info("Recieved sms from %s", sms.sender)
        if not allowed_sender(sms.sender):
            logging.warn("Unauthorized sender %s", sms.sender)
            return Response(status=401)

        if not print_message(sms['text']):
        logging.info("Printing sms from %s", sms.sender)
        logging.debug("Text: %s", sms.text)
        if not print_message(sms.text):
            logging.error("Error printing sms")
            return Response(status=500)

        return Response(status=200)

    def bandwidth_v2() -> Response:
        for sms in request.get_json(force=True):
            archive_request(sms['message']['id'], sms)

            if not allowed_sender(sms['message']['from']):
                return Response(status=401)

            if not print_message(sms['message']['text']):
                return Response(status=500)

        return Response(status=200)

    sms_main()

A sms_printer/sms/__init__.py => sms_printer/sms/__init__.py +21 -0
@@ 0,0 1,21 @@
from jsonschema import validate
from jsonschema.exceptions import ValidationError
from flask import json

from .schemas import SCHEMAS
from .schemas.types import sms_base

class NoMatchingSchema(Exception):
    pass

def parse_sms(sms_input: str) -> sms_base:
    for schema, sms_parser in SCHEMAS.items():
        try:
            validate(sms_input, json.loads(schema))
            return sms_parser(sms_input)
        except ValidationError:
            continue

    raise NoMatchingSchema

__all__ = ['parse_sms', 'NoMatchingSchema']

A sms_printer/sms/schemas/__init__.py => sms_printer/sms/schemas/__init__.py +31 -0
@@ 0,0 1,31 @@
"""
This module provides the filtering and parsing. They currently only
provide a list of messages with the sender number and message text.

Each module has a SCHEMA and a Parser(sms_base)

The SCHEMA is matched against incoming messages to determine the
correct parser to use for a message.

Adding more is simple enough. Define a new parser and its schema in
its own module next to the others, and add it to SCHEMAS here.

I use `genson` to generate schemas based on an example message.
"""


from typing import Dict, Type

import types

from . import bandwidth_v1
from . import bandwidth_v2


SCHEMAS: Dict[str, Type[types.sms_base]] = {}

SCHEMAS[bandwidth_v1.SCHEMA] = bandwidth_v1.Parser
SCHEMAS[bandwidth_v2.SCHEMA] = bandwidth_v2.Parser


__all__ = ['SCHEMAS', 'types']

A sms_printer/sms/schemas/bandwidth_v1.py => sms_printer/sms/schemas/bandwidth_v1.py +65 -0
@@ 0,0 1,65 @@
from typing import Dict, List

from .types import sms, sms_base


class Parser(sms_base):
    messages: List[sms] = []

    def __init__(self, sms_input: Dict) -> None:
        msg = sms(
            sms_input['from'],
            sms_input['text'])
        self.messages.append(msg)


SCHEMA = """
{
   "type" : "object",
   "properties" : {
      "direction" : {
         "type" : "string"
      },
      "from" : {
         "type" : "string"
      },
      "state" : {
         "type" : "string"
      },
      "messageUri" : {
         "type" : "string"
      },
      "applicationId" : {
         "type" : "string"
      },
      "text" : {
         "type" : "string"
      },
      "time" : {
         "type" : "string"
      },
      "messageId" : {
         "type" : "string"
      },
      "to" : {
         "type" : "string"
      },
      "eventType" : {
         "type" : "string"
      }
   },
   "required" : [
      "applicationId",
      "direction",
      "eventType",
      "from",
      "messageId",
      "messageUri",
      "state",
      "text",
      "time",
      "to"
   ],
   "$schema" : "http://json-schema.org/schema#"
}
"""

A sms_printer/sms/schemas/bandwidth_v2.py => sms_printer/sms/schemas/bandwidth_v2.py +99 -0
@@ 0,0 1,99 @@
from typing import Dict, List

from .types import sms, sms_base


class Parser(sms_base):
    messages: List[sms] = []

    def __init__(self, sms_json: List) -> None:
        for msg_input in sms_json:
            msg = sms(
                msg_input['message']['from'],
                msg_input['message']['text'])
            self.messages.append(msg)


SCHEMA = """
{
   "items" : {
      "properties" : {
         "time" : {
            "type" : "string"
         },
         "message" : {
            "properties" : {
               "time" : {
                  "type" : "string"
               },
               "direction" : {
                  "type" : "string"
               },
               "owner" : {
                  "type" : "string"
               },
               "media" : {
                  "items" : {
                     "type" : "string"
                  },
                  "type" : "array"
               },
               "applicationId" : {
                  "type" : "string"
               },
               "id" : {
                  "type" : "string"
               },
               "segmentCount" : {
                  "type" : "integer"
               },
               "text" : {
                  "type" : "string"
               },
               "from" : {
                  "type" : "string"
               },
               "to" : {
                  "type" : "array",
                  "items" : {
                     "type" : "string"
                  }
               }
            },
            "required" : [
               "applicationId",
               "direction",
               "from",
               "id",
               "media",
               "owner",
               "segmentCount",
               "text",
               "time",
               "to"
            ],
            "type" : "object"
         },
         "to" : {
            "type" : "string"
         },
         "description" : {
            "type" : "string"
         },
         "type" : {
            "type" : "string"
         }
      },
      "type" : "object",
      "required" : [
         "description",
         "message",
         "time",
         "to",
         "type"
      ]
   },
   "$schema" : "http://json-schema.org/schema#",
   "type" : "array"
}
"""

A sms_printer/sms/schemas/types.py => sms_printer/sms/schemas/types.py +26 -0
@@ 0,0 1,26 @@
from typing import List


class sms(object):
    sender: str
    text: str

    def __init__(self, sender: str, text: str) -> None:
        self.sender = sender
        self.text = text


class sms_base(object):
    schema: str
    messages: List[sms]

    def __init__(self, sms_input):
        pass

    def __iter__(self):
        return self

    def __next__(self) -> sms:
        if not bool(self.messages):
            raise StopIteration
        return self.messages.pop(0)

A sms_printer/utils.py => sms_printer/utils.py +52 -0
@@ 0,0 1,52 @@
"""
This file feels like it has way too much configuration to make sense
in its current state.
"""


import os
from math import floor
from subprocess import CalledProcessError, run
from tempfile import NamedTemporaryFile
from time import strftime

from flask import json
from xdg import BaseDirectory

ARCHIVE = os.getenv('ARCHIVE_DIR', BaseDirectory.save_data_path(__name__))
ALLOWED_SENDERS = [
    num for num in os.getenv('ALLOWED_SENDERS', '').split(',') if num
]
PRINTER_FORMAT = "\n\n" + "      {message}"*2
TEXT_SCALE = 0.6
CPI_DEFAULT = 10
LPI_DEFAULT = 6
TOP_MARGIN = None
SIDE_MARGIN = None


def allowed_sender(sender: str) -> bool:
    return (not bool(ALLOWED_SENDERS) or sender in ALLOWED_SENDERS)

def log_request(data: str):
    timestamp = strftime("%Y%m%d-%H%M%S")
    with open(os.path.join(ARCHIVE, timestamp), 'w+') as archive_file:
        json.dump(data, archive_file)

def print_message(message: str) -> bool:
    with NamedTemporaryFile(mode='w+') as tmp_file:
        print(PRINTER_FORMAT.format(message=message), file=tmp_file)
        tmp_file.flush()

        command = [
            "lp",
            "-o cpi={}".format(floor(CPI_DEFAULT*(1/TEXT_SCALE))),
            "-o lpi={}".format(floor(LPI_DEFAULT*(1/TEXT_SCALE))),
            tmp_file.name]
        try:
            run(command, check=True, timeout=10)

        except CalledProcessError:
            return False

    return True