diff --git a/dialing.py b/dialing.py new file mode 100755 index 0000000..f8eb26e --- /dev/null +++ b/dialing.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -* +import os +import django +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "djing.settings") +django.setup() +from messaging.sms import SmsSubmit, SmsDeliver +import re +import asterisk.manager +from time import sleep +from dialing_app.models import SMSModel +from django.conf import settings + +DJING_USERNAME_PASSWORD = getattr(settings, 'DJING_USERNAME_PASSWORD', ('admin', 'admin')) + + +class SMS(object): + def __init__(self, text, who, dev): + self.text = text + self.who = who + self.dev = dev + + def __add__(self, other): + if not isinstance(other, SMS): + raise TypeError + if self.who == other.who and self.dev == other.dev: + self.text += other.text + return self + + def __str__(self): + return "%s: %s" % (self.who, self.text) + + +class ChunkedMsg(object): + def __init__(self, sms_count, ref, sms): + self.sms_count = sms_count + self.ref = ref + self.sms = sms + + +class MyAstManager(asterisk.manager.Manager): + sms_chunks = list() + + def new_chunked_sms(self, count, ref, sms): + msg = ChunkedMsg(count, ref, sms) + self.sms_chunks.append(msg) + + @staticmethod + def save_sms(sms): + print('Inbox %s:' % sms.who, sms.text) + if not isinstance(sms, SMS): + raise TypeError + SMSModel.objects.create( + who=sms.who, + dev=sms.dev, + text=sms.text + ) + + def push_text(self, sms, ref, cnt): + if not isinstance(sms, SMS): + raise TypeError + chunk = [c for c in self.sms_chunks if c.ref == ref] + chunk_len = len(chunk) + if chunk_len == 1: + chunk = chunk[0] + chunk.sms += sms + if chunk.sms_count == cnt: + self.save_sms(chunk.sms) + self.sms_chunks.remove(chunk) + + elif chunk_len == 0: + self.new_chunked_sms(cnt, ref, sms) + + + +manager = MyAstManager() + + +def validate_tel(tel, reg=re.compile(r'^\+7978\d{7}$')): + return bool(re.match(reg, tel)) + + +def send_sms(dev, recipient, utext): + if not validate_tel(recipient): + print("Tel %s is not valid" % recipient) + return + sms = SmsSubmit(recipient, utext) + for pdu in sms.to_pdu(): + response = manager.command('dongle pdu %s %s' % (dev, pdu.pdu)) + print(response.data) + + +def handle_shutdown(event, manager): + print("Recieved shutdown event") + manager.close() + # we could analize the event and reconnect here + + +def handle_inbox_long_sms_message(event, manager): + if event.has_header('Message'): + pdu = event.get_header('Message') + pdu = re.sub(r'^\+CMGR\:\s\d\,\,\d{1,3}\\r\\n', '', pdu) + sd = SmsDeliver(pdu) + data = sd.data + chunks_count = data.get('cnt') + sms = SMS( + text=data.get('text'), + who=data.get('number'), + dev=event.get_header('Device') + ) + if chunks_count is not None: + # more than 1 message + manager.push_text(sms=sms, ref=data.get('ref'), cnt=chunks_count) + else: + # one message + manager.save_sms(sms) + + +if __name__ == '__main__': + try: + manager.connect('10.12.1.2') + manager.login(*DJING_USERNAME_PASSWORD) + + # register some callbacks + manager.register_event('Shutdown', handle_shutdown) + manager.register_event('DongleNewCMGR', handle_inbox_long_sms_message) # PDU Here + + # get a status report + response = manager.status() + print(response) + while True: + sleep(60) + + except asterisk.manager.ManagerSocketException as e: + print("Error connecting to the manager: %s" % e.strerror) + except asterisk.manager.ManagerAuthException as e: + print("Error logging in to the manager: %s" % e.strerror) + except asterisk.manager.ManagerException as e: + print("Error: %s" % e.strerror) + finally: + manager.logoff() diff --git a/dialing_app/locale/ru/LC_MESSAGES/django.po b/dialing_app/locale/ru/LC_MESSAGES/django.po index 7e810aa..1137788 100644 --- a/dialing_app/locale/ru/LC_MESSAGES/django.po +++ b/dialing_app/locale/ru/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2017-05-22 11:59+0300\n" +"POT-Creation-Date: 2017-12-29 13:59+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Dmitry Novikov nerosketch@gmail.com\n" "Language: \n" @@ -19,85 +19,108 @@ msgstr "" "%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" "%100>=11 && n%100<=14)? 2 : 3);\n" -#: dialing_app/models.py:8 dialing_app/models.py:43 +#: models.py:8 models.py:43 msgid "No answer" msgstr "Не отвечен" -#: dialing_app/models.py:9 dialing_app/models.py:45 +#: models.py:9 models.py:45 msgid "Failed" msgstr "С ошибкой" -#: dialing_app/models.py:10 dialing_app/models.py:47 +#: models.py:10 models.py:47 msgid "Busy" msgstr "Занято" -#: dialing_app/models.py:11 dialing_app/models.py:49 +#: models.py:11 models.py:49 msgid "Answered" msgstr "Отвечен" -#: dialing_app/models.py:12 dialing_app/models.py:51 +#: models.py:12 models.py:51 msgid "Unknown" msgstr "Не определён" -#: dialing_app/templates/index.html:9 -msgid "Dialing" -msgstr "Звонки" +#: models.py:79 +msgid "Can view sms" +msgstr "Может просматривать смс" + +#: models.py:81 models.py:82 +msgid "SMS" +msgstr "СМС" -#: dialing_app/templates/index.html:12 +#: templates/ext.html:7 templates/ext.html.py:21 views.py:19 msgid "Last calls" msgstr "Последние звонки" -#: dialing_app/templates/index.html:20 +#: templates/ext.html:28 views.py:46 +msgid "Voice mail request" +msgstr "Заявки на подключение" + +#: templates/ext.html:35 views.py:57 +msgid "Voice mail report" +msgstr "Заявки на поломки" + +#: templates/index.html:10 templates/vmail.html.py:10 msgid "Play" msgstr "Слушать" -#: dialing_app/templates/index.html:21 +#: templates/index.html:11 templates/vmail.html.py:11 msgid "calldate" msgstr "дата звонка" -#: dialing_app/templates/index.html:22 +#: templates/index.html:12 templates/vmail.html.py:12 msgid "src" msgstr "кто" -#: dialing_app/templates/index.html:23 +#: templates/index.html:13 msgid "dst" msgstr "куда" -#: dialing_app/templates/index.html:24 +#: templates/index.html:14 templates/vmail.html.py:13 msgid "duration" msgstr "продолжительность" -#: dialing_app/templates/index.html:25 +#: templates/index.html:15 templates/vmail.html.py:14 msgid "start" msgstr "начало" -#: dialing_app/templates/index.html:26 +#: templates/index.html:16 templates/vmail.html.py:15 msgid "answer" msgstr "ответ" -#: dialing_app/templates/index.html:27 +#: templates/index.html:17 templates/vmail.html.py:16 msgid "end" msgstr "конец" -#: dialing_app/templates/index.html:28 +#: templates/index.html:18 templates/vmail.html.py:17 msgid "disposition" msgstr "состояние" -#: dialing_app/templates/index.html:50 +#: templates/index.html:24 views.py:74 +msgid "Find dials" +msgstr "Найти звонки" + +#: templates/index.html:26 +msgid "Telephone" +msgstr "Телефон" + +#: templates/index.html:47 +msgid "Download" +msgstr "Скачать" + +#: templates/index.html:63 templates/vmail.html.py:42 msgid "Calls was not found" msgstr "Звонки не найдены" -#: dialing_app/views.py:27 +#: views.py:32 msgid "Multiple users with the telephone number" msgstr "Несколько абонентов с указанным номером телефона" -#: dialing_app/views.py:29 +#: views.py:34 msgid "User with the telephone number not found" msgstr "Абонент с таким номером телефона не найден" -msgid "Voice mail request" -msgstr "Заявки на подключение" - -msgid "Voice mail report" -msgstr "Заявки на поломки" +msgid "Dialing" +msgstr "Звонки" +msgid "Inbox sms" +msgstr "Входящие смс" \ No newline at end of file diff --git a/dialing_app/migrations/0002_auto_20171229_1353.py b/dialing_app/migrations/0002_auto_20171229_1353.py new file mode 100644 index 0000000..7f69e88 --- /dev/null +++ b/dialing_app/migrations/0002_auto_20171229_1353.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2017-12-29 13:53 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dialing_app', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SMSModel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('when', models.DateTimeField(auto_now_add=True)), + ('who', models.CharField(max_length=32)), + ('dev', models.CharField(max_length=20)), + ('text', models.CharField(max_length=255)), + ], + options={ + 'verbose_name': 'SMS', + 'verbose_name_plural': 'SMS', + 'db_table': 'sms', + 'permissions': (('can_view_sms', 'Can view sms'),), + }, + ), + migrations.AlterModelOptions( + name='asteriskcdr', + options={'managed': False}, + ), + ] diff --git a/dialing_app/models.py b/dialing_app/models.py index 3f939b4..d320b48 100644 --- a/dialing_app/models.py +++ b/dialing_app/models.py @@ -65,3 +65,21 @@ class AsteriskCDR(models.Model): class Meta: db_table = 'cdr' managed = False + + +class SMSModel(models.Model): + when = models.DateTimeField(auto_now_add=True) + who = models.CharField(max_length=32) + dev = models.CharField(max_length=20) + text = models.CharField(max_length=255) + + class Meta: + db_table = 'sms' + permissions = ( + ('can_view_sms', _('Can view sms')), + ) + verbose_name = _('SMS') + verbose_name_plural = _('SMS') + + def __str__(self): + return self.text diff --git a/dialing_app/templates/ext.html b/dialing_app/templates/ext.html index a5c9401..b9fd956 100644 --- a/dialing_app/templates/ext.html +++ b/dialing_app/templates/ext.html @@ -29,13 +29,20 @@ - {% url 'dialapp:vmail_report' as dialmail %} + {% url 'dialapp:vmail_report' as dialmail %} {% trans 'Voice mail report' %} + {% url 'dialapp:inbox_sms' as dialsmsin %} + + + {% trans 'Inbox sms' %} + + + diff --git a/dialing_app/templates/inbox_sms.html b/dialing_app/templates/inbox_sms.html new file mode 100644 index 0000000..e95cb33 --- /dev/null +++ b/dialing_app/templates/inbox_sms.html @@ -0,0 +1,30 @@ +{% extends request.is_ajax|yesno:'nullcont.htm,ext.html' %} +{% load i18n %} +{% block content %} + +
+
+

{% trans 'Inbox sms' %}

+
+
+ + {% for msg in sms_messages %} + +
+
From {{ msg.who }} + {{ msg.when|date:'d M, H:i:s' }} via {{ msg.dev }} +
+ +

{{ msg.text }}

+
+ + {% empty %} +
+

{% trans 'Message history is empty' %}

+
+ {% endfor %} + +
+
+ +{% endblock %} diff --git a/dialing_app/urls.py b/dialing_app/urls.py index 6dc3fe8..ce611b4 100644 --- a/dialing_app/urls.py +++ b/dialing_app/urls.py @@ -7,5 +7,6 @@ urlpatterns = [ url(r'^filter$', views.vfilter, name='vfilter'), url(r'^to_abon(?P\+?\d+)$', views.to_abon, name='to_abon'), url(r'^requests$', views.vmail_request, name='vmail_request'), - url(r'^reports$', views.vmail_report, name='vmail_report') + url(r'^reports$', views.vmail_report, name='vmail_report'), + url(r'^sms/in$', views.inbox_sms, name='inbox_sms') ] diff --git a/dialing_app/views.py b/dialing_app/views.py index 4386a07..c8c16b8 100644 --- a/dialing_app/views.py +++ b/dialing_app/views.py @@ -7,7 +7,7 @@ from django.db.models import Q from abonapp.models import Abon from mydefs import only_admins, pag_mn -from .models import AsteriskCDR +from .models import AsteriskCDR, SMSModel @login_required @@ -75,3 +75,12 @@ def vfilter(request): 's': s }) + +@login_required +@permission_required('dialing_app.can_view_sms') +def inbox_sms(request): + msgs = SMSModel.objects.all() + msgs = pag_mn(request, msgs) + return render(request, 'inbox_sms.html', { + 'sms_messages': msgs + }) diff --git a/djing/settings_example.py b/djing/settings_example.py index 0931626..8a8a929 100644 --- a/djing/settings_example.py +++ b/djing/settings_example.py @@ -181,3 +181,5 @@ DEFAULT_SNMP_PASSWORD = 'public' TELEGRAM_BOT_TOKEN = 'bot token' TELEPHONE_REGEXP = r'^\+[7,8,9,3]\d{10,11}$' + +DJING_USERNAME_PASSWORD = ('username', 'secret') diff --git a/messaging/__init__.py b/messaging/__init__.py new file mode 100644 index 0000000..5e9a3bc --- /dev/null +++ b/messaging/__init__.py @@ -0,0 +1,3 @@ +# see LICENSE + +VERSION = (0, 5, 12) diff --git a/messaging/mms/__init__.py b/messaging/mms/__init__.py new file mode 100644 index 0000000..738c8ee --- /dev/null +++ b/messaging/mms/__init__.py @@ -0,0 +1,63 @@ +# This library is free software. +# +# It was originally distributed under the terms of the GNU Lesser +# General Public License Version 2. +# +# python-messaging opts to apply the terms of the ordinary GNU +# General Public License v2, as permitted by section 3 of the LGPL +# v2.1. This re-licensing allows the entirety of python-messaging to +# be distributed according to the terms of GPL-2. +# +# See the COPYING file included in this archive +# +# Copyright (C) 2007 Francois Aucamp +# +""" +Multimedia Messaging Service (MMS) library + +The :mod:`messaging.mms` module provides several classes for the creation +and manipulation of MMS messages (multimedia messages) used in mobile +devices such as cellular telephones. + +Multimedia Messaging Service (MMS) is a messaging service for the mobile +environment standardized by the WAP Forum and 3GPP. To the end-user MMS is +very similar to the text-based Short Message Service (SMS): it provides +automatic immediate delivery for user-created content from device to device. + +In addition to text, however, MMS messages can contain multimedia content such +as still images, audio clips and video clips, which are binded together +into a "mini presentation" (or slideshow) that controls for example, the order +in which images are to appear on the screen, how long they will be displayed, +when an audio clip should be played, etc. Furthermore, MMS messages do not have +the 160-character limit of SMS messages. + +An MMS message is a multimedia presentation in one entity; it is not a text +file with attachments. + +This library enables the creation of MMS messages with full support for +presentation layout, and multimedia data parts such as JPEG, GIF, AMR, MIDI, +3GP, etc. It also allows the decoding and unpacking of received MMS messages. + +@version: 0.2 +@author: Francois Aucamp C{} +@license: GNU General Public License, version 2 +@note: References used in the code and this document: + +.. [1] MMS Conformance Document version 2.0.0, 6 February 2002 + U{www.bogor.net/idkf/bio2/mobile-docs/mms_conformance_v2_0_0.pdf} + +.. [2] Forum Nokia, "How To Create MMS Services, Version 4.0" + U{http://forum.nokia.com/info/sw.nokia.com/id/a57a4f20-b7f2-475b-b426-19eff18a5afb/How_To_Create_MMS_Services_v4_0_en2.pdf.html} + +.. [3] Wap Forum/Open Mobile ALliance, "WAP-206 MMS Client Transactions" + U{http://www.openmobilealliance.org/tech/affiliates/LicenseAgreement.asp?DocName=/wap/wap-206-mmsctr-20020115-a.pdf} + +.. [4] Wap Forum/Open Mobile Alliance, "WAP-209 MMS Encapsulation Protocol" + U{http://www.openmobilealliance.org/tech/affiliates/LicenseAgreement.asp?DocName=/wap/wap-209-mmsencapsulation-20020105-a.pdf} + +.. [5] Wap Forum/Open Mobile Alliance, "WAP-230 Wireless Session Protocol Specification" + U{http://www.openmobilealliance.org/tech/affiliates/LicenseAgreement.asp?DocName=/wap/wap-230-wsp-20010705-a.pdf} + +.. [6] IANA: "Character Sets" + U{http://www.iana.org/assignments/character-sets} +""" diff --git a/messaging/mms/iterator.py b/messaging/mms/iterator.py new file mode 100644 index 0000000..cd49505 --- /dev/null +++ b/messaging/mms/iterator.py @@ -0,0 +1,71 @@ +# This library is free software. +# +# It was originally distributed under the terms of the GNU Lesser +# General Public License Version 2. +# +# python-messaging opts to apply the terms of the ordinary GNU +# General Public License v2, as permitted by section 3 of the LGPL +# v2.1. This re-licensing allows the entirety of python-messaging to +# be distributed according to the terms of GPL-2. +# +# See the COPYING file included in this archive +# +# The docstrings in this module contain epytext markup; API documentation +# may be created by processing this file with epydoc: http://epydoc.sf.net +"""Iterator with "value preview" capability.""" + + +class PreviewIterator(object): + """An ``iter`` wrapper class providing a "previewable" iterator. + + This "preview" functionality allows the iterator to return successive + values from its ``iterable`` object, without actually mvoving forward + itself. This is very usefuly if the next item(s) in an iterator must + be used for something, after which the iterator should "undo" those + read operations, so that they can be read again by another function. + + From the user point of view, this class supersedes the builtin iter() + function: like iter(), it is called as PreviewIter(iterable). + """ + def __init__(self, data): + self._it = iter(data) + self._cached_values = [] + self._preview_pos = 0 + + def __iter__(self): + return self + + def next(self): + self.reset_preview() + if len(self._cached_values) > 0: + return self._cached_values.pop(0) + else: + return self._it.next() + + def preview(self): + """ + Return the next item in the ``iteratable`` object + + But it does not modify the actual iterator (i.e. do not + intefere with :func:`next`. + + Successive calls to :func:`preview` will return successive values from + the ``iterable`` object, exactly in the same way :func:`next` does. + + However, :func:`preview` will always return the next item from + ``iterable`` after the item returned by the previous :func:`preview` + or :func:`next` call, whichever was called the most recently. + To force the "preview() iterator" to synchronize with the "next() + iterator" (without calling :func:`next`), use :func:`reset_preview`. + """ + if self._preview_pos < len(self._cached_values): + value = self._cached_values[self._preview_pos] + else: + value = self._it.next() + self._cached_values.append(value) + + self._preview_pos += 1 + return value + + def reset_preview(self): + self._preview_pos = 0 diff --git a/messaging/mms/message.py b/messaging/mms/message.py new file mode 100644 index 0000000..6cff8a0 --- /dev/null +++ b/messaging/mms/message.py @@ -0,0 +1,555 @@ +# This library is free software. +# +# It was originally distributed under the terms of the GNU Lesser +# General Public License Version 2. +# +# python-messaging opts to apply the terms of the ordinary GNU +# General Public License v2, as permitted by section 3 of the LGPL +# v2.1. This re-licensing allows the entirety of python-messaging to +# be distributed according to the terms of GPL-2. +# +# See the COPYING file included in this archive +# +# The docstrings in this module contain epytext markup; API documentation +# may be created by processing this file with epydoc: http://epydoc.sf.net +"""High-level MMS message classes""" + +from __future__ import with_statement +import array +import mimetypes +import os +import xml.dom.minidom + + +class MMSMessage: + """ + I am an MMS message + + References used in this class: [1][2][3][4][5] + """ + def __init__(self): + self._pages = [] + self._data_parts = [] + self._metaTags = {} + self._mms_message = None + self.headers = { + 'Message-Type': 'm-send-req', + 'Transaction-Id': '1234', + 'MMS-Version': '1.0', + 'Content-Type': ('application/vnd.wap.multipart.mixed', {}), + } + self.width = 176 + self.height = 220 + self.transactionID = '12345' + self.subject = 'test' + + @property + def content_type(self): + """ + Returns the Content-Type of this data part header + + No parameter information is returned; to get that, access the + "Content-Type" header directly (which has a tuple value) from + the message's ``headers`` attribute. + + This is equivalent to calling DataPart.headers['Content-Type'][0] + """ + return self.headers['Content-Type'][0] + + def add_page(self, page): + """ + Adds `page` to the message + + :type page: MMSMessagePage + :param page: The message slide/page to add + """ + if self.content_type != 'application/vnd.wap.multipart.related': + value = ('application/vnd.wap.multipart.related', {}) + self.headers['Content-Type'] = value + + self._pages.append(page) + + @property + def pages(self): + """Returns a list of all the pages in this message""" + return self._pages + + def add_data_part(self, data_part): + """Adds a single data part (DataPart object) to the message, without + connecting it to a specific slide/page in the message. + + A data part encapsulates some form of attachment, e.g. an image, audio + etc. It is not necessary to explicitly add data parts to the message + using this function if :func:`add_page` is used; this method is mainly + useful if you want to create MMS messages without SMIL support, + i.e. messages of type "application/vnd.wap.multipart.mixed" + + :param data_part: The data part to add + :type data_part: DataPart + """ + self._data_parts.append(data_part) + + @property + def data_parts(self): + """ + Returns a list of all the data parts in this message + + including data parts that were added to slides in this message""" + parts = [] + if len(self._pages): + parts.append(self.smil()) + for slide in self._mms_message._pages: + parts.extend(slide.data_parts()) + + parts.extend(self._data_parts) + return parts + + def smil(self): + """Returns the text of the message's SMIL file""" + impl = xml.dom.minidom.getDOMImplementation() + smil_doc = impl.createDocument(None, "smil", None) + + # Create the SMIL header + head_node = smil_doc.createElement('head') + # Add metadata to header + for tag_name in self._metaTags: + meta_node = smil_doc.createElement('meta') + meta_node.setAttribute(tag_name, self._metaTags[tag_name]) + head_node.appendChild(meta_node) + + # Add layout info to header + layout_node = smil_doc.createElement('layout') + root_layout_node = smil_doc.createElement('root-layout') + root_layout_node.setAttribute('width', str(self.width)) + root_layout_node.setAttribute('height', str(self.height)) + layout_node.appendChild(root_layout_node) + + areas = (('Image', '0', '0', '176', '144'), + ('Text', '176', '144', '176', '76')) + + for region_id, left, top, width, height in areas: + region_node = smil_doc.createElement('region') + region_node.setAttribute('id', region_id) + region_node.setAttribute('left', left) + region_node.setAttribute('top', top) + region_node.setAttribute('width', width) + region_node.setAttribute('height', height) + layout_node.appendChild(region_node) + + head_node.appendChild(layout_node) + smil_doc.documentElement.appendChild(head_node) + + # Create the SMIL body + body_node = smil_doc.createElement('body') + # Add pages to body + for page in self._pages: + par_node = smil_doc.createElement('par') + par_node.setAttribute('duration', str(page.duration)) + # Add the page content information + if page.image is not None: + #TODO: catch unpack exception + part, begin, end = page.image + if 'Content-Location' in part.headers: + src = part.headers['Content-Location'] + elif 'Content-ID' in part.headers: + src = part.headers['Content-ID'] + else: + src = part.data + + image_node = smil_doc.createElement('img') + image_node.setAttribute('src', src) + image_node.setAttribute('region', 'Image') + if begin > 0 or end > 0: + if end > page.duration: + end = page.duration + + image_node.setAttribute('begin', str(begin)) + image_node.setAttribute('end', str(end)) + + par_node.appendChild(image_node) + + if page.text is not None: + part, begin, end = page.text + src = part.data + text_node = smil_doc.createElement('text') + text_node.setAttribute('src', src) + text_node.setAttribute('region', 'Text') + if begin > 0 or end > 0: + if end > page.duration: + end = page.duration + + text_node.setAttribute('begin', str(begin)) + text_node.setAttribute('end', str(end)) + + par_node.appendChild(text_node) + + if page.audio is not None: + part, begin, end = page.audio + if 'Content-Location' in part.headers: + src = part.headers['Content-Location'] + elif 'Content-ID' in part.headers: + src = part.headers['Content-ID'] + else: + src = part.data + + audio_node = smil_doc.createElement('audio') + audio_node.setAttribute('src', src) + if begin > 0 or end > 0: + if end > page.duration: + end = page.duration + + audio_node.setAttribute('begin', str(begin)) + audio_node.setAttribute('end', str(end)) + + par_node.appendChild(text_node) + par_node.appendChild(audio_node) + + body_node.appendChild(par_node) + + smil_doc.documentElement.appendChild(body_node) + return smil_doc.documentElement.toprettyxml() + + def encode(self): + """ + Return a binary representation of this MMS message + + This uses the `~:class:messaging.mms.mms_pdu.MMSEncoder` internally + + :return: The binary-encoded MMS data, as an array of bytes + :rtype: array.array('B') + """ + from messaging.mms import mms_pdu + encoder = mms_pdu.MMSEncoder() + return encoder.encode(self) + + def to_file(self, filename): + """ + Writes this MMS message to `filename` in binary-encoded form + + This uses the `~:class:messaging.mms.mms_pdu.MMSEncoder` internally + + :param filename: The path where to store the message data + :type filename: str + + :rtype array.array('B') + :return: The binary-encode MMS data, as an array of bytes + """ + with open(filename, 'wb') as f: + self.encode().tofile(f) + + @staticmethod + def from_data(data): + """ + Returns a new `:class:MMSMessage` out of ``data`` + + This uses the `~:class:messaging.mms.mms_pdu.MMSEncoder` internally + + :param data: The data to load + :type filename: array.array + """ + from messaging.mms import mms_pdu + decoder = mms_pdu.MMSDecoder() + return decoder.decode_data(data) + + @staticmethod + def from_file(filename): + """ + Returns a new `:class:MMSMessage` out of file ``filename`` + + This uses the `~:class:messaging.mms.mms_pdu.MMSEncoder` internally + + :param filename: The name of the file to load + :type filename: str + """ + from messaging.mms import mms_pdu + decoder = mms_pdu.MMSDecoder() + return decoder.decode_file(filename) + + +class MMSMessagePage: + """ + A single page/slide in an MMS Message. + + In order to ensure that the MMS message can be correctly displayed by most + terminals, each page's content is limited to having 1 image, 1 audio clip + and 1 block of text, as stated in [1]. + + The default slide duration is set to 4 seconds; use :func:`set_duration` + to change this. + """ + def __init__(self): + self.duration = 4000 + self.image = None + self.audio = None + self.text = None + + @property + def data_parts(self): + """Returns a list of the data parst in this slide""" + return [part for part in (self.image, self.audio, self.text) + if part is not None] + + def number_of_parts(self): + """ + Returns the number of data parts in this slide + + @rtype: int + """ + num_parts = 0 + for item in (self.image, self.audio, self.text): + if item is not None: + num_parts += 1 + + return num_parts + + #TODO: find out what the "ref" element in SMIL does + #TODO: add support for "alt" element; also make sure what it does + def add_image(self, filename, time_begin=0, time_end=0): + """ + Adds an image to this slide. + + :param filename: The name of the image file to add. Supported formats + are JPEG, GIF and WBMP. + :type filename: str + :param time_begin: The time (in milliseconds) during the duration of + this slide to begin displaying the image. If this is + 0 or less, the image will be displayed from the + moment the slide is opened. + :type time_begin: int + :param time_end: The time (in milliseconds) during the duration of this + slide at which to stop showing (i.e. hide) the image. + If this is 0 or less, or if it is greater than the + actual duration of this slide, it will be shown until + the next slide is accessed. + :type time_end: int + + :raise TypeError: An inappropriate variable type was passed in of the + parameters + """ + if not isinstance(filename, str): + raise TypeError("filename must be a string") + + if not isinstance(time_begin, int) or not isinstance(time_end, int): + raise TypeError("time_begin and time_end must be ints") + + if not os.path.isfile(filename): + raise OSError("filename must be a file") + + if time_end > 0 and time_end < time_begin: + raise ValueError('time_end cannot be lower than time_begin') + + self.image = (DataPart(filename), time_begin, time_end) + + def add_audio(self, filename, time_begin=0, time_end=0): + """ + Adds an audio clip to this slide. + + :param filename: The name of the audio file to add. Currently the only + supported format is AMR. + :type filename: str + :param time_begin: The time (in milliseconds) during the duration of + this slide to begin playback of the audio clip. If + this is 0 or less, the audio clip will be played the + moment the slide is opened. + :type time_begin: int + :param time_end: The time (in milliseconds) during the duration of this + slide at which to stop playing (i.e. mute) the audio + clip. If this is 0 or less, or if it is greater than + the actual duration of this slide, the entire audio + clip will be played, or until the next slide is + accessed. + :type time_end: int + :raise TypeError: An inappropriate variable type was passed in of the + parameters + """ + if not isinstance(filename, str): + raise TypeError("filename must be a string") + + if not isinstance(time_begin, int) or not isinstance(time_end, int): + raise TypeError("time_begin and time_end must be ints") + + if not os.path.isfile(filename): + raise OSError("filename must be a file") + + if time_end > 0 and time_end < time_begin: + raise ValueError('time_end cannot be lower than time_begin') + + self.audio = (DataPart(filename), time_begin, time_end) + + def add_text(self, text, time_begin=0, time_end=0): + """ + Adds a block of text to this slide. + + :param text: The text to add to the slide. + :type text: str + :param time_begin: The time (in milliseconds) during the duration of + this slide to begin displaying the text. If this is + 0 or less, the text will be displayed from the + moment the slide is opened. + :type time_begin: int + :param time_end: The time (in milliseconds) during the duration of this + slide at which to stop showing (i.e. hide) the text. + If this is 0 or less, or if it is greater than the + actual duration of this slide, it will be shown until + the next slide is accessed. + :type time_end: int + + :raise TypeError: An inappropriate variable type was passed in of the + parameters + """ + if not isinstance(text, str): + raise TypeError("Text must be a string") + + if not isinstance(time_begin, int) or not isinstance(time_end, int): + raise TypeError("time_begin and time_end must be ints") + + if time_end > 0 and time_end < time_begin: + raise ValueError('time_end cannot be lower than time_begin') + + time_data = DataPart() + time_data.set_text(text) + self.text = (time_data, time_begin, time_end) + + def set_duration(self, duration): + """ Sets the maximum duration of this slide (i.e. how long this slide + should be displayed) + + @param duration: the maxium slide duration, in milliseconds + @type duration: int + + @raise TypeError: must be an integer + @raise ValueError: the requested duration is invalid (must be a + non-zero, positive integer) + """ + if not isinstance(duration, int): + raise TypeError("Duration must be an int") + + if duration < 1: + raise ValueError('duration may not be 0 or negative') + + self.duration = duration + + +class DataPart(object): + """ + I am a data entry in the MMS body + + A DataPart object encapsulates any data content that is to be added + to the MMS (e.g. an image , raw image data, audio clips, text, etc). + + A DataPart object can be queried using the Python built-in :func:`len` + function. + + This encapsulation allows custom header/parameter information to be set + for each data entry in the MMS. Refer to [5] for more information on + these. + """ + def __init__(self, filename=None): + """ @param srcFilename: If specified, load the content of the file + with this name + @type srcFilename: str + """ + super(DataPart, self).__init__() + + self.content_type_parameters = {} + self.headers = {'Content-Type': ('application/octet-stream', {})} + self._filename = None + self._data = None + + if filename is not None: + self.from_file(filename) + + def _get_content_type(self): + """ Returns the string representation of this data part's + "Content-Type" header. No parameter information is returned; + to get that, access the "Content-Type" header directly (which has a + tuple value)from this part's C{headers} attribute. + + This is equivalent to calling DataPart.headers['Content-Type'][0] + """ + return self.headers['Content-Type'][0] + + def _set_content_type(self, value): + """Sets the content type string, with no parameters """ + self.headers['Content-Type'] = value, {} + + content_type = property(_get_content_type, _set_content_type) + + def from_file(self, filename): + """ + Load the data contained in the specified file + + This function clears any previously-set header entries. + + :param filename: The name of the file to open + :type filename: str + + :raises OSError: The filename is invalid + """ + if not os.path.isfile(filename): + raise OSError('The file "%s" does not exist' % filename) + + # Clear any headers that are currently set + self.headers = {} + self._data = None + self.headers['Content-Location'] = os.path.basename(filename) + content_type = (mimetypes.guess_type(filename)[0] + or 'application/octet-stream', {}) + self.headers['Content-Type'] = content_type + self._filename = filename + + def set_data(self, data, content_type, ct_parameters=None): + """ + Explicitly set the data contained by this part + + This function clears any previously-set header entries. + + :param data: The data to hold + :type data: str + :param content_type: The MIME content type of the specified data + :type content_type: str + :param ct_parameters: Any content type header paramaters to add + :type ct_parameters: dict + """ + self.headers = {} + self._filename = None + self._data = data + + if ct_parameters is None: + ct_parameters = {} + + self.headers['Content-Type'] = content_type, ct_parameters + + def set_text(self, text): + """ + Convenience wrapper method for set_data() + + This method sets the :class:`DataPart` object to hold the + specified text string, with MIME content type "text/plain". + + @param text: The text to hold + @type text: str + """ + self.set_data(text, 'text/plain') + + def __len__(self): + """Provides the length of the data encapsulated by this object""" + if self._filename is not None: + return int(os.stat(self._filename)[6]) + else: + return len(self.data) + + @property + def data(self): + """A buffer containing the binary data of this part""" + if self._data is not None: + if type(self._data) == array.array: + self._data = self._data.tostring() + return self._data + + elif self._filename is not None: + with open(self._filename, 'r') as f: + self._data = f.read() + return self._data + + return '' diff --git a/messaging/mms/mms_pdu.py b/messaging/mms/mms_pdu.py new file mode 100644 index 0000000..ed44c46 --- /dev/null +++ b/messaging/mms/mms_pdu.py @@ -0,0 +1,996 @@ +# This library is free software. +# +# It was originally distributed under the terms of the GNU Lesser +# General Public License Version 2. +# +# python-messaging opts to apply the terms of the ordinary GNU +# General Public License v2, as permitted by section 3 of the LGPL +# v2.1. This re-licensing allows the entirety of python-messaging to +# be distributed according to the terms of GPL-2. +# +# See the COPYING file included in this archive +"""MMS Data Unit structure encoding and decoding classes""" + +from __future__ import with_statement +import array +import os +import random + +from messaging.utils import debug +from messaging.mms import message, wsp_pdu +from messaging.mms.iterator import PreviewIterator + + +def flatten_list(x): + """Flattens ``x`` into a single list""" + result = [] + for el in x: + if hasattr(el, "__iter__") and not isinstance(el, basestring): + result.extend(flatten_list(el)) + else: + result.append(el) + return result + + +mms_field_names = { + 0x01: ('Bcc', 'encoded_string_value'), + 0x02: ('Cc', 'encoded_string_value'), + 0x03: ('Content-Location', 'uri_value'), + 0x04: ('Content-Type', 'content_type_value'), + 0x05: ('Date', 'date_value'), + 0x06: ('Delivery-Report', 'boolean_value'), + 0x07: ('Delivery-Time', 'delivery_time_value'), + 0x08: ('Expiry', 'expiry_value'), + 0x09: ('From', 'from_value'), + 0x0a: ('Message-Class', 'message_class_value'), + 0x0b: ('Message-ID', 'text_string'), + 0x0c: ('Message-Type', 'message_type_value'), + 0x0d: ('MMS-Version', 'version_value'), + 0x0e: ('Message-Size', 'long_integer'), + 0x0f: ('Priority', 'priority_value'), + 0x10: ('Read-Reply', 'boolean_value'), + 0x11: ('Report-Allowed', 'boolean_value'), + 0x12: ('Response-Status', 'response_status_value'), + 0x13: ('Response-Text', 'encoded_string_value'), + 0x14: ('Sender-Visibility', 'sender_visibility_value'), + 0x15: ('Status', 'status_value'), + 0x16: ('Subject', 'encoded_string_value'), + 0x17: ('To', 'encoded_string_value'), + 0x18: ('Transaction-Id', 'text_string'), +} + + +class MMSDecoder(wsp_pdu.Decoder): + """A decoder for MMS messages""" + + def __init__(self, filename=None): + """ + :param filename: If specified, decode the content of the MMS + message file with this name + :type filename: str + """ + self._mms_data = array.array('B') + self._mms_message = message.MMSMessage() + self._parts = [] + + def decode_file(self, filename): + """ + Load the data contained in the specified ``filename``, and decode it. + + :param filename: The name of the MMS message file to open + :type filename: str + + :raise OSError: The filename is invalid + + :return: The decoded MMS data + :rtype: MMSMessage + """ + num_bytes = os.stat(filename)[6] + data = array.array('B') + + with open(filename, 'rb') as f: + data.fromfile(f, num_bytes) + + return self.decode_data(data) + + def decode_data(self, data): + """ + Decode the specified MMS message data + + :param data: The MMS message data to decode + :type data: array.array('B') + + :return: The decoded MMS data + :rtype: MMSMessage + """ + self._mms_message = message.MMSMessage() + self._mms_data = data + body_iter = self.decode_message_header() + self.decode_message_body(body_iter) + return self._mms_message + + def decode_message_header(self): + """ + Decodes the (full) MMS header data + + This must be called before :func:`_decodeBody`, as it sets + certain internal variables relating to data lengths, etc. + """ + data_iter = PreviewIterator(self._mms_data) + + # First 3 headers (in order + ############################ + # - X-Mms-Message-Type + # - X-Mms-Transaction-ID + # - X-Mms-Version + # TODO: reimplement strictness - currently we allow these 3 headers + # to be mixed with any of the other headers (this allows the + # decoding of "broken" MMSs, but is technically incorrect) + + # Misc headers + ############## + # The next few headers will not be in a specific order, except for + # "Content-Type", which should be the last header + # According to [4], MMS header field names will be short integers + content_type_found = False + header = '' + while content_type_found == False: + try: + header, value = self.decode_header(data_iter) + except StopIteration: + break + + if header == mms_field_names[0x04][0]: + content_type_found = True + else: + self._mms_message.headers[header] = value + + if header == 'Content-Type': + # Otherwise it might break Content-Location + # content_type, params = value + self._mms_message.headers[header] = value + + return data_iter + + def decode_message_body(self, data_iter): + """ + Decodes the MMS message body + + :param data_iter: an iterator over the sequence of bytes of the MMS + body + :type data_iter: iter + """ + ######### MMS body: headers ########### + # Get the number of data parts in the MMS body + try: + num_entries = self.decode_uint_var(data_iter) + except StopIteration: + return + + #print 'Number of data entries (parts) in MMS body:', num_entries + + ########## MMS body: entries ########## + # For every data "part", we have to read the following sequence: + # , + # , + # , + # + for part_num in xrange(num_entries): + #print '\nPart %d:\n------' % part_num + headers_len = self.decode_uint_var(data_iter) + data_len = self.decode_uint_var(data_iter) + + # Prepare to read content-type + other possible headers + ct_field_bytes = [] + for i in xrange(headers_len): + ct_field_bytes.append(data_iter.next()) + + ct_iter = PreviewIterator(ct_field_bytes) + # Get content type + ctype, ct_parameters = self.decode_content_type_value(ct_iter) + headers = {'Content-Type': (ctype, ct_parameters)} + + # Now read other possible headers until bytes + # have been read + while True: + try: + hdr, value = self.decode_header(ct_iter) + headers[hdr] = value + except StopIteration: + break + + # Data (note: this is not null-terminated) + data = array.array('B') + for i in xrange(data_len): + data.append(data_iter.next()) + + part = message.DataPart() + part.set_data(data, ctype) + part.content_type_parameters = ct_parameters + part.headers = headers + self._mms_message.add_data_part(part) + + @staticmethod + def decode_header(byte_iter): + """ + Decodes a header entry from an MMS message + + starting at the byte pointed to by :func:`byte_iter.next` + + From [4], section 7.1:: + + Header = MMS-header | Application-header + + The return type of the "header value" depends on the header + itself; it is thus up to the function calling this to determine + what that type is (or at least compensate for possibly + different return value types). + + :raise DecodeError: This uses :func:`decode_mms_header` and + :func:`decode_application_header`, and will raise this + exception under the same circumstances as + :func:`decode_application_header`. ``byte_iter`` will + not be modified in this case. + + :return: The decoded header entry from the MMS, in the format: + (, ) + :rtype: tuple + """ + try: + return MMSDecoder.decode_mms_header(byte_iter) + except wsp_pdu.DecodeError: + return wsp_pdu.Decoder.decode_header(byte_iter) + + @staticmethod + def decode_mms_header(byte_iter): + """ + Decodes the MMS header pointed by ``byte_iter`` + + This method takes into account the assigned number values for MMS + field names, as specified in [4], section 7.3, table 8. + + From [4], section 7.1:: + + MMS-header = MMS-field-name MMS-value + MMS-field-name = Short-integer + MMS-value = Bcc-value | Cc-value | Content-location-value | Content-type-value | etc + + + :raise wsp_pdu.DecodeError: The MMS field name could not be parsed. + ``byte_iter`` will not be modified. + + :return: The decoded MMS header, in the format: + (, ) + :rtype: tuple + """ + # Get the MMS-field-name + mms_field_name = '' + preview = byte_iter.preview() + byte = wsp_pdu.Decoder.decode_short_integer_from_byte(preview) + + if byte in mms_field_names: + byte_iter.next() + mms_field_name = mms_field_names[byte][0] + else: + byte_iter.reset_preview() + raise wsp_pdu.DecodeError('Invalid MMS Header: could ' + 'not decode MMS field name') + + # Now get the MMS-value + mms_value = '' + try: + name = mms_field_names[byte][1] + mms_value = getattr(MMSDecoder, 'decode_%s' % name)(byte_iter) + except wsp_pdu.DecodeError, msg: + raise wsp_pdu.DecodeError('Invalid MMS Header: Could ' + 'not decode MMS-value: %s' % msg) + except: + raise RuntimeError('A fatal error occurred, probably due to an ' + 'unimplemented decoding operation. Tried to ' + 'decode header: %s' % mms_field_name) + + return mms_field_name, mms_value + + @staticmethod + def decode_encoded_string_value(byte_iter): + """ + Decodes the encoded string value pointed by ``byte_iter`` + + From [4], section 7.2.9:: + + Encoded-string-value = Text-string | Value-length Char-set Text-string + + The Char-set values are registered by IANA as MIBEnum value. + + This function is not fully implemented, in that it does not + have proper support for the Char-set values; it basically just + reads over that sequence of bytes, and ignores it (see code for + details) - any help with this will be greatly appreciated. + + :return: The decoded text string + :rtype: str + """ + try: + # First try "Value-length Char-set Text-string" + value_length = wsp_pdu.Decoder.decode_value_length(byte_iter) + # TODO: add proper support for charsets... + try: + charset = wsp_pdu.Decoder.decode_well_known_charset(byte_iter) + except wsp_pdu.DecodeError, msg: + raise Exception('encoded_string_value decoding error - ' + 'Could not decode Charset value: %s' % msg) + + return wsp_pdu.Decoder.decode_text_string(byte_iter) + except wsp_pdu.DecodeError: + # Fall back on just "Text-string" + return wsp_pdu.Decoder.decode_text_string(byte_iter) + + @staticmethod + def decode_boolean_value(byte_iter): + """ + Decodes the boolean value pointed by ``byte_iter`` + + From [4], section 7.2.6:: + + Delivery-report-value = Yes | No + Yes = + No = + + A lot of other yes/no fields use this encoding (read-reply, + report-allowed, etc) + + :raise wsp_pdu.DecodeError: The boolean value could not be parsed. + ``byte_iter`` will not be modified. + + :return: The value for the field + :rtype: bool + """ + byte = byte_iter.preview() + if byte not in (128, 129): + byte_iter.reset_preview() + raise wsp_pdu.DecodeError('Error parsing boolean value ' + 'for byte: %s' % hex(byte)) + byte = byte_iter.next() + return byte == 128 + + @staticmethod + def decode_delivery_time_value(byte_iter): + value_length = wsp_pdu.Decoder.decode_value_length(byte_iter) + token = byte_iter.next() + value = wsp_pdu.Decoder.decode_long_integer(byte_iter) + if token == 128: + token_type = 'absolute' + elif token == 129: + token_type = 'relative' + else: + raise wsp_pdu.DecodeError('Delivery-Time type token value is undefined' + ' (%s), should be either 128 or 129' % token) + return (token_type, value) + + @staticmethod + def decode_from_value(byte_iter): + """ + Decodes the "From" value pointed by ``byte_iter`` + + From [4], section 7.2.11:: + + From-value = Value-length (Address-present-token Encoded-string-value | Insert-address-token ) + Address-present-token = + Insert-address-token = + + :return: The "From" address value + :rtype: str + """ + value_length = wsp_pdu.Decoder.decode_value_length(byte_iter) + # See what token we have + byte = byte_iter.next() + if byte == 129: # Insert-address-token + return '' + + return MMSDecoder.decode_encoded_string_value(byte_iter) + + @staticmethod + def decode_message_class_value(byte_iter): + """ + Decodes the "Message-Class" value pointed by ``byte_iter`` + + From [4], section 7.2.12:: + + Message-class-value = Class-identifier | Token-text + Class-identifier = Personal | Advertisement | Informational | Auto + Personal = + Advertisement = + Informational = + Auto = + + The token-text is an extension method to the message class. + + :return: The decoded message class + :rtype: str + """ + class_identifiers = { + 128: 'Personal', + 129: 'Advertisement', + 130: 'Informational', + 131: 'Auto', + } + byte = byte_iter.preview() + if byte in class_identifiers: + byte_iter.next() + return class_identifiers[byte] + + byte_iter.reset_preview() + return wsp_pdu.Decoder.decode_token_text(byte_iter) + + @staticmethod + def decode_message_type_value(byte_iter): + """ + Decodes the "Message-Type" value pointed by ``byte_iter`` + + Defined in [4], section 7.2.14. + + :return: The decoded message type, or '' + :rtype: str + """ + message_types = { + 0x80: 'm-send-req', + 0x81: 'm-send-conf', + 0x82: 'm-notification-ind', + 0x83: 'm-notifyresp-ind', + 0x84: 'm-retrieve-conf', + 0x85: 'm-acknowledge-ind', + 0x86: 'm-delivery-ind', + } + + byte = byte_iter.preview() + if byte in message_types: + byte_iter.next() + return message_types[byte] + + byte_iter.reset_preview() + return '' + + @staticmethod + def decode_priority_value(byte_iter): + """ + Decode the "Priority" value pointed by ``byte_iter`` + + Defined in [4], section 7.2.17 + + :raise wsp_pdu.DecodeError: The priority value could not be decoded; + ``byte_iter`` is not modified in this case. + + :return: The decoded priority value + :rtype: str + """ + priorities = {128: 'Low', 129: 'Normal', 130: 'High'} + + byte = byte_iter.preview() + if byte in priorities: + byte = byte_iter.next() + return priorities[byte] + + byte_iter.reset_preview() + raise wsp_pdu.DecodeError('Error parsing Priority value ' + 'for byte: %s' % byte) + + @staticmethod + def decode_sender_visibility_value(byte_iter): + """ + Decodes the sender visibility value pointed by ``byte_iter`` + + Defined in [4], section 7.2.22:: + + Sender-visibility-value = Hide | Show + Hide = + Show = + + :raise wsp_pdu.DecodeError: The sender visibility value could not be + parsed. ``byte_iter`` will not be modified + in this case. + + :return: The sender visibility: 'Hide' or 'Show' + :rtype: str + """ + byte = byte_iter.preview() + if byte not in (128, 129): + byte_iter.reset_preview() + raise wsp_pdu.DecodeError('Error parsing sender visibility ' + 'value for byte: %s' % hex(byte)) + + byte = byte_iter.next() + value = 'Hide' if byte == 128 else 'Show' + return value + + @staticmethod + def decode_response_status_value(byte_iter): + """ + Decodes the "Response Status" value pointed by ``byte_iter`` + + Defined in [4], section 7.2.20 + + + :raise wsp_pdu.DecodeError: The sender visibility value could not be + parsed. ``byte_iter`` will not be modified in + this case. + + :return: The decoded Response-status-value + :rtype: str + """ + response_status_values = { + 0x80: 'Ok', + 0x81: 'Error-unspecified', + 0x82: 'Error-service-denied', + 0x83: 'Error-message-format-corrupt', + 0x84: 'Error-sending-address-unresolved', + 0x85: 'Error-message-not-found', + 0x86: 'Error-network-problem', + 0x87: 'Error-content-not-accepted', + 0x88: 'Error-unsupported-message', + } + byte = byte_iter.preview() + byte_iter.next() + # Return error unspecified if it couldn't be decoded + return response_status_values.get(byte, 0x81) + + @staticmethod + def decode_status_value(byte_iter): + """ + Used to decode the "Status" MMS header. + + Defined in [4], section 7.2.23 + + :raise wsp_pdu.DecodeError: The sender visibility value could not be + parsed. ``byte_iter`` will not be + modified in this case. + + :return: The decoded Status-value + :rtype: str + """ + status_values = { + 0x80: 'Expired', + 0x81: 'Retrieved', + 0x82: 'Rejected', + 0x83: 'Deferred', + 0x84: 'Unrecognised', + } + + byte = byte_iter.next() + # Return an unrecognised state if it couldn't be decoded + return status_values.get(byte, 0x84) + + @staticmethod + def decode_expiry_value(byte_iter): + """ + Used to decode the "Expiry" MMS header. + + From [4], section 7.2.10:: + + Expiry-value = Value-length (Absolute-token Date-value | Relative-token Delta-seconds-value) + Absolute-token = + Relative-token = + + :raise wsp_pdu.DecodeError: The Expiry-value could not be decoded + + :return: The decoded Expiry-value, either as a date, or as a delta-seconds value + :rtype: str or int + """ + value_length = MMSDecoder.decode_value_length(byte_iter) + token = byte_iter.next() + + if token == 0x80: # Absolute-token + return MMSDecoder.decode_date_value(byte_iter) + elif token == 0x81: # Relative-token + return MMSDecoder.decode_delta_seconds_value(byte_iter) + + raise wsp_pdu.DecodeError('Unrecognized token value: %s' % hex(token)) + + +class MMSEncoder(wsp_pdu.Encoder): + """MMS Encoder""" + + def __init__(self): + self._mms_message = message.MMSMessage() + + def encode(self, mms_message): + """ + Encodes the specified MMS message ``mms_message`` + + :param mms_message: The MMS message to encode + :type mms_message: MMSMessage + + :return: The binary-encoded MMS data, as a sequence of bytes + :rtype: array.array('B') + """ + self._mms_message = mms_message + msg_data = self.encode_message_header() + msg_data.extend(self.encode_message_body()) + return msg_data + + def encode_message_header(self): + """ + Binary-encodes the MMS header data. + + The encoding used for the MMS header is specified in [4]. + All "constant" encoded values found/used in this method + are also defined in [4]. For a good example, see [2]. + + :return: the MMS PDU header, as an array of bytes + :rtype: array.array('B') + """ + # See [4], chapter 8 for info on how to use these + # from_types = {'Address-present-token': 0x80, + # 'Insert-address-token': 0x81} + + # content_types = {'application/vnd.wap.multipart.related': 0xb3} + + # Create an array of 8-bit values + message_header = array.array('B') + + headers_to_encode = self._mms_message.headers + + # If the user added any of these to the message manually + # (X- prefix) use those instead + for hdr in ('X-Mms-Message-Type', 'X-Mms-Transaction-Id', + 'X-Mms-Version'): + if hdr in headers_to_encode: + if hdr == 'X-Mms-Version': + clean_header = 'MMS-Version' + else: + clean_header = hdr.replace('X-Mms-', '', 1) + + headers_to_encode[clean_header] = headers_to_encode[hdr] + del headers_to_encode[hdr] + + # First 3 headers (in order), according to [4]: + ################################################ + # - X-Mms-Message-Type + # - X-Mms-Transaction-ID + # - X-Mms-Version + + ### Start of Message-Type verification + if 'Message-Type' not in headers_to_encode: + # Default to 'm-retrieve-conf'; we don't need a To/CC field for + # this (see WAP-209, section 6.3, table 5) + headers_to_encode['Message-Type'] = 'm-retrieve-conf' + + # See if the chosen message type is valid, given the message's + # other headers. NOTE: we only distinguish between 'm-send-req' + # (requires a destination number) and 'm-retrieve-conf' + # (requires no destination number) - if "Message-Type" is + # something else, we assume the message creator knows + # what she is doing + if headers_to_encode['Message-Type'] == 'm-send-req': + found_dest_address = False + for address_type in ('To', 'Cc', 'Bc'): + if address_type in headers_to_encode: + found_dest_address = True + break + + if not found_dest_address: + headers_to_encode['Message-Type'] = 'm-retrieve-conf' + ### End of Message-Type verification + + ### Start of Transaction-Id verification + if 'Transaction-Id' not in headers_to_encode: + trans_id = str(random.randint(1000, 9999)) + headers_to_encode['Transaction-Id'] = trans_id + ### End of Transaction-Id verification + + ### Start of MMS-Version verification + if 'MMS-Version' not in headers_to_encode: + headers_to_encode['MMS-Version'] = '1.0' + + # Encode the first three headers, in correct order + for hdr in ('Message-Type', 'Transaction-Id', 'MMS-Version'): + message_header.extend( + MMSEncoder.encode_header(hdr, headers_to_encode[hdr])) + del headers_to_encode[hdr] + + # Encode all remaining MMS message headers, except "Content-Type" + # -- this needs to be added last, according [2] and [4] + for hdr in headers_to_encode: + if hdr != 'Content-Type': + message_header.extend( + MMSEncoder.encode_header(hdr, headers_to_encode[hdr])) + + # Ok, now only "Content-type" should be left + content_type, ct_parameters = headers_to_encode['Content-Type'] + message_header.extend(MMSEncoder.encode_mms_field_name('Content-Type')) + ret = MMSEncoder.encode_content_type_value(content_type, ct_parameters) + message_header.extend(flatten_list(ret)) + + return message_header + + def encode_message_body(self): + """ + Binary-encodes the MMS body data + + The MMS body's header should not be confused with the actual + MMS header, as returned by :func:`encode_header`. + + The encoding used for the MMS body is specified in [5], + section 8.5. It is only referenced in [4], however [2] + provides a good example of how this ties in with the MMS + header encoding. + + The MMS body is of type `application/vnd.wap.multipart` ``mixed`` + or ``related``. As such, its structure is divided into a header, and + the data entries/parts:: + + [ header ][ entries ] + ^^^^^^^^^^^^^^^^^^^^^ + MMS Body + + The MMS Body header consists of one entry[5]:: + + name type purpose + ------- ------- ----------- + num_entries uint_var num of entries in the multipart entity + + The MMS body's multipart entries structure:: + + name type purpose + ------- ----- ----------- + HeadersLen uint_var length of the ContentType and + Headers fields combined + DataLen uint_var length of the Data field + ContentType Multiple octets the content type of the data + Headers ( + - length of + ) octets the part's headers + Data octets the part's data + + :return: The binary-encoded MMS PDU body, as an array of bytes + :rtype: array.array('B') + """ + message_body = array.array('B') + + #TODO: enable encoding of MMSs without SMIL file + ########## MMS body: header ########## + # Parts: SMIL file + + num_entries = 1 + for page in self._mms_message._pages: + num_entries += page.number_of_parts() + + for data_part in self._mms_message._data_parts: + num_entries += 1 + + message_body.extend(self.encode_uint_var(num_entries)) + + ########## MMS body: entries ########## + # For every data "part", we have to add the following sequence: + # , + # , + # , + # . + + # Gather the data parts, adding the MMS message's SMIL file + smil_part = message.DataPart() + smil = self._mms_message.smil() + smil_part.set_data(smil, 'application/smil') + #TODO: make this dynamic.... + smil_part.headers['Content-ID'] = '<0000>' + parts = [smil_part] + for slide in self._mms_message._pages: + for part_tuple in (slide.image, slide.audio, slide.text): + if part_tuple is not None: + parts.append(part_tuple[0]) + + for part in parts: + name, val_type = part.headers['Content-Type'] + part_content_type = self.encode_content_type_value(name, val_type) + + encoded_part_headers = [] + for hdr in part.headers: + if hdr == 'Content-Type': + continue + encoded_part_headers.extend( + wsp_pdu.Encoder.encode_header(hdr, part.headers[hdr])) + + # HeadersLen entry (length of the ContentType and + # Headers fields combined) + headers_len = len(part_content_type) + len(encoded_part_headers) + message_body.extend(self.encode_uint_var(headers_len)) + # DataLen entry (length of the Data field) + message_body.extend(self.encode_uint_var(len(part))) + # ContentType entry + message_body.extend(part_content_type) + # Headers + message_body.extend(encoded_part_headers) + # Data (note: we do not null-terminate this) + for char in part.data: + message_body.append(ord(char)) + + return message_body + + @staticmethod + def encode_header(header_field_name, header_value): + """ + Encodes a header entry for an MMS message + + The return type of the "header value" depends on the header + itself; it is thus up to the function calling this to determine + what that type is (or at least compensate for possibly different + return value types). + + From [4], section 7.1:: + + Header = MMS-header | Application-header + MMS-header = MMS-field-name MMS-value + MMS-field-name = Short-integer + MMS-value = Bcc-value | Cc-value | Content-location-value | Content-type-value | etc + + :raise DecodeError: This uses :func:`decode_mms_header` and + :func:`decode_application_header`, and will raise this + exception under the same circumstances as + :func:`decode_application_header`. ``byte_iter`` will + not be modified in this case. + + :return: The decoded header entry from the MMS, in the format: + (, ) + :rtype: tuple + """ + encoded_header = [] + # First try encoding the header as a "MMS-header"... + for assigned_number in mms_field_names: + header = mms_field_names[assigned_number][0] + if header == header_field_name: + encoded_header.extend( + wsp_pdu.Encoder.encode_short_integer(assigned_number)) + # Now encode the value + expected_type = mms_field_names[assigned_number][1] + try: + ret = getattr(MMSEncoder, + 'encode_%s' % expected_type)(header_value) + encoded_header.extend(ret) + except wsp_pdu.EncodeError, msg: + raise wsp_pdu.EncodeError('Error encoding parameter ' + 'value: %s' % msg) + except: + debug('A fatal error occurred, probably due to an ' + 'unimplemented encoding operation') + raise + + break + + # See if the "MMS-header" encoding worked + if not len(encoded_header): + # ...it didn't. Use "Application-header" encoding + header_name = wsp_pdu.Encoder.encode_token_text(header_field_name) + encoded_header.extend(header_name) + # Now add the value + encoded_header.extend( + wsp_pdu.Encoder.encode_text_string(header_value)) + + return encoded_header + + @staticmethod + def encode_mms_field_name(field_name): + """ + Encodes an MMS header field name + + From [4], section 7.1:: + + MMS-field-name = Short-integer + + :raise EncodeError: The specified header field name is not a + well-known MMS header. + + :param field_name: The header field name to encode + :type field_name: str + + :return: The encoded header field name, as a sequence of bytes + :rtype: list + """ + encoded_mms_field_name = [] + + for assigned_number in mms_field_names: + if mms_field_names[assigned_number][0] == field_name: + encoded_mms_field_name.extend( + wsp_pdu.Encoder.encode_short_integer(assigned_number)) + break + + if not len(encoded_mms_field_name): + raise wsp_pdu.EncodeError('The specified header field name is not ' + 'a well-known MMS header field name') + + return encoded_mms_field_name + + @staticmethod + def encode_from_value(from_value=''): + """ + Encodes the "From" address value + + From [4], section 7.2.11:: + + From-value = Value-length (Address-present-token Encoded-string-value | Insert-address-token ) + Address-present-token = + Insert-address-token = + + :param from_value: The "originator" of the MMS message. This may be an + empty string, in which case a token will be encoded + informing the MMSC to insert the address of the + device that sent this message (default). + :type from_value: str + + :return: The encoded "From" address value, as a sequence of bytes + :rtype: list + """ + encoded_from_value = [] + if len(from_value) == 0: + value_length = wsp_pdu.Encoder.encode_value_length(1) + encoded_from_value.extend(value_length) + encoded_from_value.append(129) # Insert-address-token + else: + encoded_address = MMSEncoder.encode_encoded_string_value(from_value) + # the "+1" is for the Address-present-token + length = len(encoded_address) + 1 + value_length = wsp_pdu.Encoder.encode_value_length(length) + encoded_from_value.extend(value_length) + encoded_from_value.append(128) # Address-present-token + encoded_from_value.extend(encoded_address) + + return encoded_from_value + + @staticmethod + def encode_encoded_string_value(string_value): + """ + Encodes ``string_value`` + + This is a simple wrapper to :func:`encode_text_string` + + From [4], section 7.2.9:: + + Encoded-string-value = Text-string | Value-length Char-set Text-string + + The Char-set values are registered by IANA as MIBEnum value. + + :param string_value: The text string to encode + :type string_value: str + + :return: The encoded string value, as a sequence of bytes + :rtype: list + """ + return wsp_pdu.Encoder.encode_text_string(string_value) + + @staticmethod + def encode_message_type_value(message_type): + """ + Encodes the Message-Type value ``message_type`` + + Unknown message types are discarded; thus they will be encoded + as 0x80 ("m-send-req") by this function + + Defined in [4], section 7.2.14. + + :param message_type: The MMS message type to encode + :type message_type: str + + :return: The encoded message type, as a sequence of bytes + :rtype: list + """ + message_types = { + 'm-send-req': 0x80, + 'm-send-conf': 0x81, + 'm-notification-ind': 0x82, + 'm-notifyresp-ind': 0x83, + 'm-retrieve-conf': 0x84, + 'm-acknowledge-ind': 0x85, + 'm-delivery-ind': 0x86, + } + + return [message_types.get(message_type, 0x80)] + + @staticmethod + def encode_status_value(status_value): + status_values = { + 'Expired': 0x80, + 'Retrieved': 0x81, + 'Rejected': 0x82, + 'Deferred': 0x83, + 'Unrecognised': 0x84, + } + + # Return an unrecognised state if it couldn't be decoded + return [status_values.get(status_value, 'Unrecognised')] diff --git a/messaging/mms/wsp_pdu.py b/messaging/mms/wsp_pdu.py new file mode 100644 index 0000000..bacd46d --- /dev/null +++ b/messaging/mms/wsp_pdu.py @@ -0,0 +1,2055 @@ +# This library is free software. +# +# It was originally distributed under the terms of the GNU Lesser +# General Public License Version 2. +# +# python-messaging opts to apply the terms of the ordinary GNU +# General Public License v2, as permitted by section 3 of the LGPL +# v2.1. This re-licensing allows the entirety of python-messaging to +# be distributed according to the terms of GPL-2. +# +# See the COPYING file included in this archive +# +# The docstrings in this module contain epytext markup; API documentation +# may be created by processing this file with epydoc: http://epydoc.sf.net +""" +WSP Data Unit structure encoding and decoding classes + +Throughout the classes defined in this module, the following "primitive data +type" terminology applies, as specified in [5], section 8.1.1:: + + Data Type Definition + bit 1 bit of data + octet 8 bits of opaque data + uint8 8-bit unsigned integer + uint16 16-bit unsigned integer + uint32 32-bit unsigned integer + uintvar variable length unsigned integer + +This Encoder and Decoder classes provided in this module firstly provides +public methods for decoding and encoding each of these data primitives (where +needed). + +Next, they provide methods encapsulating the basic WSP Header encoding rules +as defined in section 8.4.2.1 of [5]. + +Finally, the classes defined here provide methods for decoding/parsing +specific WSP header fields. + +References used in the code and this document: + +[5] Wap Forum/Open Mobile Alliance, "WAP-230 Wireless Session Protocol Specification" + U{http://www.openmobilealliance.org/tech/affiliates/LicenseAgreement.asp?DocName=/wap/wap-230-wsp-20010705-a.pdf} +""" + +import array +from datetime import datetime + +from messaging.utils import debug +from messaging.mms.iterator import PreviewIterator + +wsp_pdu_types = { + 0x01: 'Connect', + 0x02: 'ConnectReply', + 0x03: 'Redirect', + 0x04: 'Reply', + 0x05: 'Disconnect', + 0x06: 'Push', + 0x07: 'ConfirmedPush', + 0x08: 'Suspend', + 0x09: 'Resume', + 0x40: 'Get', + 0x60: 'Post', +} + +# Well-known parameter assignments ([5], table 38) +well_known_parameters = { + 0x00: ('Q', 'q_value'), + 0x01: ('Charset', 'well_known_charset'), + 0x02: ('Level', 'version_value'), + 0x03: ('Type', 'integer_value'), + 0x05: ('Name', 'text_string'), + 0x06: ('Filename', 'text_string'), + 0x07: ('Differences', 'Field-name'), + 0x08: ('Padding', 'short_integer'), + 0x09: ('Type', 'constrained_encoding'), # encoding version 1.2 + 0x0a: ('Start', 'text_string'), + 0x0b: ('Start-info', 'text_string'), + 0x0c: ('Comment', 'text_string'), # encoding version 1.3 + 0x0d: ('Domain', 'text_string'), + 0x0e: ('Max-Age', 'delta_seconds_value'), + 0x0f: ('Path', 'text_string'), + 0x10: ('Secure', 'no_value'), + 0x11: ('SEC', 'short_integer'), # encoding version 1.4 + 0x12: ('MAC', 'text_value'), + 0x13: ('Creation-date', 'date_value'), + 0x14: ('Modification-date', 'date_value'), + 0x15: ('Read-date', 'date_value'), + 0x16: ('Size', 'integer_value'), + 0x17: ('Name', 'text_value'), + 0x18: ('Filename', 'text_value'), + 0x19: ('Start', 'text_value'), + 0x1a: ('Start-info', 'text_value'), + 0x1b: ('Comment', 'text_value'), + 0x1c: ('Domain', 'text_value'), + 0x1d: ('Path', 'text_value'), +} + + +# Content type assignments ([5], table 40) +well_known_content_types = [ + '*/*', 'text/*', 'text/html', 'text/plain', + 'text/x-hdml', 'text/x-ttml', 'text/x-vCalendar', + 'text/x-vCard', 'text/vnd.wap.wml', + 'text/vnd.wap.wmlscript', 'text/vnd.wap.wta-event', + 'multipart/*', 'multipart/mixed', 'multipart/form-data', + 'multipart/byterantes', 'multipart/alternative', + 'application/*', 'application/java-vm', + 'application/x-www-form-urlencoded', + 'application/x-hdmlc', 'application/vnd.wap.wmlc', + 'application/vnd.wap.wmlscriptc', + 'application/vnd.wap.wta-eventc', + 'application/vnd.wap.uaprof', + 'application/vnd.wap.wtls-ca-certificate', + 'application/vnd.wap.wtls-user-certificate', + 'application/x-x509-ca-cert', + 'application/x-x509-user-cert', + 'image/*', 'image/gif', 'image/jpeg', 'image/tiff', + 'image/png', 'image/vnd.wap.wbmp', + 'application/vnd.wap.multipart.*', + 'application/vnd.wap.multipart.mixed', + 'application/vnd.wap.multipart.form-data', + 'application/vnd.wap.multipart.byteranges', + 'application/vnd.wap.multipart.alternative', + 'application/xml', 'text/xml', + 'application/vnd.wap.wbxml', + 'application/x-x968-cross-cert', + 'application/x-x968-ca-cert', + 'application/x-x968-user-cert', + 'text/vnd.wap.si', + 'application/vnd.wap.sic', + 'text/vnd.wap.sl', + 'application/vnd.wap.slc', + 'text/vnd.wap.co', + 'application/vnd.wap.coc', + 'application/vnd.wap.multipart.related', + 'application/vnd.wap.sia', + 'text/vnd.wap.connectivity-xml', + 'application/vnd.wap.connectivity-wbxml', + 'application/pkcs7-mime', + 'application/vnd.wap.hashed-certificate', + 'application/vnd.wap.signed-certificate', + 'application/vnd.wap.cert-response', + 'application/xhtml+xml', + 'application/wml+xml', + 'text/css', + 'application/vnd.wap.mms-message', + 'application/vnd.wap.rollover-certificate', + 'application/vnd.wap.locc+wbxml', + 'application/vnd.wap.loc+xml', + 'application/vnd.syncml.dm+wbxml', + 'application/vnd.syncml.dm+xml', + 'application/vnd.syncml.notification', + 'application/vnd.wap.xhtml+xml', + 'application/vnd.wv.csp.cir', + 'application/vnd.oma.dd+xml', + 'application/vnd.oma.drm.message', + 'application/vnd.oma.drm.content', + 'application/vnd.oma.drm.rights+xml', + 'application/vnd.oma.drm.rights+wbxml', +] + +# Well-known character sets (table 42 of [5]) +# Format { : } +# Note that the assigned number is the same as the IANA MIBEnum value +# "gsm-default-alphabet" is not included, as it is not assigned any +# value in [5]. Also note, this is by no means a complete list +well_known_charsets = { + 0x07EA: 'big5', + 0x03E8: 'iso-10646-ucs-2', + 0x04: 'iso-8859-1', + 0x05: 'iso-8859-2', + 0x06: 'iso-8859-3', + 0x07: 'iso-8859-4', + 0x08: 'iso-8859-5', + 0x09: 'iso-8859-6', + 0x0A: 'iso-8859-7', + 0x0B: 'iso-8859-8', + 0x0C: 'iso-8859-9', + 0x11: 'shift_JIS', + 0x03: 'us-ascii', + 0x6A: 'utf-8', +} + +# Header Field Name assignments ([5], table 39) +header_field_names = [ + 'Accept', 'Accept-Charset', 'Accept-Encoding', + 'Accept-Language', 'Accept-Ranges', 'Age', + 'Allow', 'Authorization', 'Cache-Control', + 'Connection', 'Content-Base', 'Content-Encoding', + 'Content-Language', 'Content-Length', + 'Content-Location', 'Content-MD5', 'Content-Range', + 'Content-Type', 'Date', 'Etag', 'Expires', 'From', + 'Host', 'If-Modified-Since', 'If-Match', + 'If-None-Match', 'If-Range', 'If-Unmodified-Since', + 'Location', 'Last-Modified', 'Max-Forwards', 'Pragma', + 'Proxy-Authenticate', 'Proxy-Authorization', 'Public', + 'Range', 'Referer', 'Retry-After', 'Server', + 'Transfer-Encoding', 'Upgrade', 'User-Agent', + 'Vary', 'Via', 'Warning', 'WWW-Authenticate', + 'Content-Disposition', + # encoding version 1.2 + 'X-Wap-Application-Id', 'X-Wap-Content-URI', + 'X-Wap-Initiator-URI', 'Accept-Application', + 'Bearer-Indication', 'Push-Flag', 'Profile', + 'Profile-Diff', 'Profile-Warning', + # encoding version 1.3 + 'Expect', 'TE', 'Trailer', 'Accept-Charset', + 'Accept-Encoding', 'Cache-Control', + 'Content-Range', 'X-Wap-Tod', 'Content-ID', + 'Set-Cookie', 'Cookie', 'Encoding-Version', + # encoding version 1.4 + 'Profile-Warning', 'Content-Disposition', + 'X-WAP-Security', 'Cache-Control', +] + + +# TODO: combine this dict with the header_field_names table (same as well +# known parameter assignments) +# Temporary fix to allow different types of header field values to be +# dynamically decoded +header_field_encodings = {'Accept': 'accept_value', 'Pragma': 'pragma_value'} + + +def get_header_field_names(version='1.2'): + """ + Formats list of assigned values for header field names, for the + specified WSP encoding version. + + :param version: The WSP encoding version to use. This defaults + to "1.2", but may be "1.1", "1.2", "1.3" or + "1.4" (see table 39 in [5] for details). + :type version: str + + :raise ValueError: The specified encoding version is invalid. + + :return: A list containing the WSP header field names with assigned + numbers for the specified encoding version (and lower). + :rtype: list + """ + if version not in ('1.1', '1.2', '1.3', '1.4'): + raise ValueError('version must be "1.1",' + '"1.2", "1.3" or "1.4"') + + version = int(version.split('.')[1]) + + versioned_field_names = header_field_names[:] + if version == 3: + versioned_field_names = versioned_field_names[:0x44] + elif version == 2: + versioned_field_names = versioned_field_names[:0x38] + elif version == 1: + versioned_field_names = versioned_field_names[:0x2f] + + return versioned_field_names + + +def get_well_known_parameters(version='1.2'): + """ + Return a list of assigned values for parameter names for ``version`` + + Formats list of assigned values for well-known parameter names, + for the specified WSP encoding version. + + :param version: The WSP encoding version to use. This defaults + to "1.2", but may be "1.1", "1.2", "1.3" or + "1.4" (see table 38 in [5] for details). + :type version: str + + :raise ValueError: The specified encoding version is invalid. + + :return: A dictionary containing the well-known parameters with + assigned numbers for the specified encoding version (and + lower). Entries in this dict follow the format:: + + : (, ) + :rtype: dict + """ + if version not in ('1.1', '1.2', '1.3', '1.4'): + raise ValueError('version must be "1.1",' + '"1.2", "1.3" or "1.4"') + else: + version = int(version.split('.')[1]) + + versioned_params = well_known_parameters.copy() + if version <= 3: + for assigned_number in range(0x11, 0x1e): + del versioned_params[assigned_number] + + if version <= 2: + for assigned_number in range(0x0c, 0x11): + del versioned_params[assigned_number] + + if version == 1: + for assigned_number in range(0x09, 0x0c): + del versioned_params[assigned_number] + + return versioned_params + + +class DecodeError(Exception): + """ + Raised when a decoding operation failed + + Most probably due to an invalid byte in the sequence provided for decoding + """ + + +class EncodeError(Exception): + """ + Raised when an encoding operation failed + + Most probably due to an invalid value provided for encoding + """ + + +class Decoder: + """A WSP Data unit decoder""" + + @staticmethod + def decode_uint_8(byte_iter): + """ + Decodes an 8-bit uint from the byte pointed to by ``byte_iter`` + + This function will move the iterator passed as ``byte_iter`` one + byte forward. + + :param byte_iter: an iterator over a sequence of bytes + :type byte_iter: iter + + :return: the decoded 8-bit unsigned integer + :rtype: int + """ + # Make the byte unsigned + return next(byte_iter) & 0xf + + @staticmethod + def decode_uint_var(byte_iter): + """ + Decodes the uint starting at the byte pointed to by ``byte_iter`` + + See :func:`wsp.Encoder.encode_uint_var` for a detailed description of + the encoding scheme used for ``uint_var`` sequences. + + This function will move the iterator passed as ``byte_iter`` to + the last octet in the uintvar sequence; thus, after calling this, + that iterator's `next()` function will return the first byte + **after** the uintvar sequence. + + :param byte_iter: an iterator over a sequence of bytes + :type byte_iter: iter + + :return: the decoded unsigned integer + :rtype: int + """ + uint = 0 + byte = next(byte_iter) + while (byte >> 7) == 0x01: + uint = uint << 7 + uint |= byte & 0x7f + byte = next(byte_iter) + + uint = uint << 7 + uint |= byte & 0x7f + return uint + + @staticmethod + def decode_short_integer(byte_iter): + """ + Decodes the short-integer value starting at ``byte_iter`` + + The encoding for a long integer is specified in [5], section 8.4.2.1:: + + Short-integer = OCTET + + Integers in range 0-127 shall be encoded as a one octet value with + the most significant bit set to one (1xxx xxxx) and with the value + in the remaining least significant bits. + + :raise DecodeError: Not a valid short-integer; the most significant + isn't set to 1. ``byte_iter`` will not be + modified if this is raised + + :return: The decoded short integer + :rtype: int + """ + byte = byte_iter.preview() + if not byte & 0x80: + byte_iter.reset_preview() + raise DecodeError('Not a valid short-integer: MSB not set') + + byte = next(byte_iter) + return byte & 0x7f + + @staticmethod + def decode_short_integer_from_byte(byte): + """ + Decodes the short-integer value contained in the specified byte value + + :param byte: the byte value to decode + :type byte: int + + :raise DecodeError: Not a valid short-integer; the MSB isn't set to 1. + :return: The decoded short integer + :rtype: int + """ + if not byte & 0x80: + raise DecodeError('Not a valid short-integer: MSB not set') + + return byte & 0x7f + + @staticmethod + def decode_long_integer(byte_iter): + """ + Decodes the long int value pointed to by ``byte_iter`` + + If this function returns successfully, it will move the + iterator passed as ``byte_iter`` to the last octet in the + encoded long integer sequence; thus, after calling this, that + iterator's `next()` function will return the first byte + **after** the encoded long integer sequence. + + The encoding for a long integer is specified in [5], section 8.4.2.1, + and follows the form:: + + Long-integer = [Short-length] [Multi-octet-integer] + ^^^^^^ ^^^^^^^^^^^^^^^^^^^^^ + 1 byte bytes + + The Short-length indicates the length of the Multi-octet-integer. + + :raise DecodeError: The byte pointed to by ``byte_iter.next`` does + not indicate the start of a valid long-integer + sequence (short-length is invalid). If this is + raised, the iterator passed as ``byte_iter`` will + not be modified. + + :param byte_iter: an iterator over a sequence of bytes + :type byte_iter: iter + + :return: The decoded long integer + :rtype: int + """ + try: + shortLength = Decoder.decode_short_length(byte_iter) + except DecodeError: + raise DecodeError('short-length byte is invalid') + + longInt = 0 + # Decode the Multi-octect-integer + for i in range(shortLength): + longInt = longInt << 8 + longInt |= next(byte_iter) + + return longInt + + @staticmethod + def decode_text_string(byte_iter): + """ + Decodes the null-terminated, binary-encoded string value starting + at the byte pointed to by ``byte_iter``. + + this function will move the iterator passed as ``byte_iter`` to + the last octet in the encoded string sequence; thus, after + calling this, that iterator's `next()` function will return + the first byte **after** the encoded string sequence. + + This follows the basic encoding rules specified in [5], section + 8.4.2.1 + + :param byte_iter: an iterator over a sequence of bytes + :type byte_iter: iter + + :return: The decoded text string + :rtype: str + """ + decoded_string = '' + byte = next(byte_iter) + # Remove Quote character (octet 127), if present + if byte == 127: + byte = next(byte_iter) + + while byte != 0x00: + decoded_string += chr(byte) + byte = next(byte_iter) + + return decoded_string + + @staticmethod + def decode_quoted_string(byte_iter): + """ + From [5], section 8.4.2.1:: + + Quoted-string = *TEXT End-of-string + + The TEXT encodes an RFC2616 Quoted-string with the enclosing + quotation-marks <"> removed + + :return: The decoded text string + :rtype: str + """ + # look for the quote character + byte = byte_iter.preview() + if byte != 34: + byte_iter.reset_preview() + raise DecodeError('Invalid quoted string: must ' + 'start with ') + + next(byte_iter) + # CHECK: should the quotation chars be pre- and appended before + # returning *technically* we should not check for quote characters. + return Decoder.decode_text_string(byte_iter) + + @staticmethod + def decode_token_text(byte_iter): + """ From [5], section 8.4.2.1: + Token-text = Token End-of-string + + :raise DecodeError: invalid token; byte_iter is not modified + + :return: The token string if successful, otherwise the read byte + :rtype: str or int + """ + separators = (11, 32, 40, 41, 44, 47, 58, 59, 60, 61, 62, 63, 64, 91, + 92, 93, 123, 125) + token = '' + byte = byte_iter.preview() + if byte <= 31 or byte in separators: + byte_iter.reset_preview() + raise DecodeError('Invalid token') + + byte = next(byte_iter) + while byte > 31 and byte not in separators: + token += chr(byte) + byte = next(byte_iter) + + return token + + @staticmethod + def decode_extension_media(byte_iter): + """ + Decode the extension media pointed by ``byte_iter`` + + This encoding is used for media values, which have no well-known + binary encoding + + From [5], section 8.4.2.1:: + + Extension-media = *TEXT End-of-string + + + :raise DecodeError: The TEXT started with an invalid character. + ``byte_iter`` is not modified if this happens. + + :return: The decoded media type value + :rtype: str + """ + media_value = '' + byte = byte_iter.preview() + if byte < 32 or byte == 127: + byte_iter.reset_preview() + raise DecodeError('Invalid Extension-media: TEXT ' + 'starts with invalid character: %d' % byte) + + byte = next(byte_iter) + while byte != 0x00: + media_value += chr(byte) + byte = next(byte_iter) + + return media_value + + @staticmethod + def decode_constrained_encoding(byte_iter): + """Constrained-encoding = Extension-Media --or-- Short-integer + This encoding is used for token values, which have no well-known + binary encoding, or when the assigned number of the well-known + encoding is small enough to fit into Short-integer. + + :return: The decoding constrained-encoding token value + :rtype: str or int + """ + result = None + try: + # First try and see if this is just a short-integer + result = Decoder.decode_short_integer(byte_iter) + except DecodeError: + # Ok, it should be Extension-Media then + try: + result = Decoder.decode_extension_media(byte_iter) + except DecodeError: + # Give up + raise DecodeError('Not a valid Constrained-encoding sequence') + + return result + + @staticmethod + def decode_short_length(byte_iter): + """ From [5], section 8.4.2.2: + Short-length = + + :raise DecodeError: The byte is not a valid short-length value; + it is not in octet range 0-30. In this case, the + iterator passed as ``byte_iter`` is not modified. + + :note: If this function returns successfully, the iterator passed as + ``byte_iter`` is moved one byte forward. + + :return: The decoded short-length + :rtype: int + """ + # Make sure it's a valid short-length + byte = byte_iter.preview() + if byte > 30: + byte_iter.reset_preview() + raise DecodeError('Not a valid short-length: ' + 'should be in octet range 0-30') + + return next(byte_iter) + + @staticmethod + def decode_value_length(byte_iter): + """ + Decodes the value length indicator starting at ``byte_iter`` + + "Value length" is used to indicate the length of a value to follow, as + used in the `Content-Type` header in the MMS body, for example. + + The encoding for a value length indicator is specified in [5], + section 8.4.2.2, and follows the form:: + + Value-length = [Short-length] --or-- [Length-quote] [Length] + ^^^^^^ ^^^^^^ ^^^^^^ + 1 byte 1 byte x bytes + uint_var-integer + + :raise DecodeError: The value_length could not be decoded. If this + happens, ``byte_iter`` is not modified. + + :return: The decoded value length indicator + :rtype: int + """ + length_value = 0 + # Check for short-length + try: + length_value = Decoder.decode_short_length(byte_iter) + except DecodeError: + byte = byte_iter.preview() + # CHECK: this strictness MAY cause issues, but it is correct + if byte == 31: + next(byte_iter) # skip past the length-quote + length_value = Decoder.decode_uint_var(byte_iter) + else: + byte_iter.reset_preview() + raise DecodeError('Invalid Value-length: not short-length, ' + 'and no length-quote present') + + return length_value + + @staticmethod + def decode_integer_value(byte_iter): + """ + Decodes the integer value pointed by ``byte_iter`` + + From [5], section 8.4.2.3:: + + Integer-Value = Short-integer | Long-integer + + If successful, this function will move the iterator passed as + ``byte_iter`` to the last octet in the integer value sequence; + thus, after calling this, that iterator's `next()` function + will return the first byte **after** the integer value sequence. + + :raise DecodeError: The sequence of bytes starting at ``byte_iter`` + does not contain a valid integer value. If this + is raised, the iterator is not modified. + + :return: The decoded integer value + :rtype: int + """ + integer = 0 + # First try and see if it's a short-integer + try: + integer = Decoder.decode_short_integer(byte_iter) + except DecodeError: + try: + integer = Decoder.decode_long_integer(byte_iter) + except DecodeError: + raise DecodeError('Not a valid integer value') + + return integer + + @staticmethod + def decode_content_type_value(byte_iter): + """ + Decodes an encoded content type value. + + From [5], section 8.4.2.24:: + + Content-type-value = Constrained-media | Content-general-form + + The short form of the Content-type-value MUST only be used when the + well-known media is in the range of 0-127 or a text string. In all + other cases the general form MUST be used. + + :return: The media type (content type), and a dictionary of + parameters to this content type (which is empty if there + are no parameters). This parameter dictionary is in the + format: + {: }. + The final returned tuple is in the format: + (, ) + :rtype: tuple + """ + # First try do decode it as Constrained-media + content_type = '' + params = {} + try: + content_type = Decoder.decode_constrained_media(byte_iter) + except DecodeError: + # Try the general form + content_type, params = Decoder.decode_content_general_form(byte_iter) + + return content_type, params + + @staticmethod + def decode_well_known_media(byte_iter): + """ + Decodes the well known media pointed by ``byte_iter`` + From [5], section 8.4.2.7:: + + Well-known-media = Integer-value + + It is encoded using values from the "Content Type Assignments" table + (see [5], table 40). + + :param byte_iter: an iterator over a sequence of bytes + :type byte_iter: iter + + :raise DecodeError: This is raised if the integer value representing + the well-known media type cannot be decoded + correctly, or the well-known media type value + could not be found in the table of assigned + content types. + If this exception is raised, the iterator passed + as ``byte_iter`` is not modified. + + If successful, this function will move the iterator passed as + ``byte_iter`` to the last octet in the content type value + sequence; thus, after calling this, that iterator's `next()` + function will return the first byte B{after}the content type + value sequence. + + :return: the decoded MIME content type name + :rtype: str + """ + try: + value = Decoder.decode_integer_value(byte_iter) + except DecodeError: + raise DecodeError('Invalid well-known media: could not read ' + 'integer value representing it') + + try: + return well_known_content_types[value] + except IndexError: + raise DecodeError('Invalid well-known media: could not ' + 'find content type in table of assigned values') + + @staticmethod + def decode_media_type(byte_iter): + """ + Decodes the media type pointed by ``byte_iter`` + + Used by :func:`decode_content_general_form` + + From [5], section 8.2.4.24:: + + Media-type = (Well-known-media | Extension-Media) *(Parameter) + + :param byte_iter: an iterator over a sequence of bytes + :type byte_iter: iter + + :return: The decoded media type + :rtype: str + """ + try: + return Decoder.decode_well_known_media(byte_iter) + except DecodeError: + return Decoder.decode_extension_media(byte_iter) + + @staticmethod + def decode_constrained_media(byte_iter): + """ + Decodes the constrained media pointed pointed by ``byte_iter`` + + It is encoded using values from the "Content Type Assignments" table. + + From [5], section 8.4.2.7:: + + Constrained-media = Constrained-encoding + + :raise DecodeError: Invalid constrained media sequence + + :return: The decoded media type + :rtype: str + """ + try: + media_value = Decoder.decode_constrained_encoding(byte_iter) + except DecodeError as msg: + raise DecodeError('Invalid Constrained-media: %s' % msg) + + if isinstance(media_value, int): + try: + return well_known_content_types[media_value] + except IndexError: + raise DecodeError('Invalid constrained media: could not ' + 'find well-known content type') + + return media_value + + @staticmethod + def decode_content_general_form(byte_iter): + """ + Decodes the content general form pointed by ``byte_iter`` + + From [5], section 8.4.2.24:: + + Content-general-form = Value-length Media-type + + Used in decoding Content-type fields and their parameters; + see :func:`decode_content_type_value`. Used by + :func:`decode_content_type_value`. + + :return: The media type (content type), and a dictionary of + parameters to this content type (which is empty if there + are no parameters). This parameter dictionary is in the + format: + {: }. + The final returned tuple is in the format: + (, ) + :rtype: tuple + """ + # This is the length of the (encoded) media-type and all parameters + value_length = Decoder.decode_value_length(byte_iter) + + # Read parameters, etc, until is reached + ct_field_bytes = array.array('B') + for i in range(value_length): + ct_field_bytes.append(next(byte_iter)) + + ct_iter = PreviewIterator(ct_field_bytes) + # Now, decode all the bytes read + media_type = Decoder.decode_media_type(ct_iter) + # Decode the included paramaters (if any) + parameters = {} + while True: + try: + parameter, value = Decoder.decode_parameter(ct_iter) + parameters[parameter] = value + except StopIteration: + break + + return media_type, parameters + + @staticmethod + def decode_parameter(byte_iter): + """ + From [5], section 8.4.2.4:: + + Parameter = Typed-parameter | Untyped-parameter + + :return: The name of the parameter, and its value, in the format: + (, ) + :rtype: tuple + """ + try: + return Decoder.decode_typed_parameter(byte_iter) + except DecodeError: + return Decoder.decode_untyped_parameter(byte_iter) + + @staticmethod + def decode_typed_parameter(byte_iter): + """ + Decodes the typed parameter pointed by ``byte_iter`` + + The actual expected type of the value is implied by the well-known + parameter. + + This is used in decoding parameters; see :func:`decode_parameter` + + From [5], section 8.4.2.4:: + + Typed-parameter = Well-known-parameter-token Typed-value + + :return: The name of the parameter, and its value, in the format: + (, ) + :rtype: tuple + """ + token, value_type = Decoder.decode_well_known_parameter(byte_iter) + typed_value = '' + try: + typed_value = getattr(Decoder, 'decode_%s' % value_type)(byte_iter) + except DecodeError as msg: + raise DecodeError('Could not decode Typed-parameter: %s' % msg) + except: + debug('A fatal error occurred, probably due to an ' + 'unimplemented decoding operation') + raise + + return token, typed_value + + @staticmethod + def decode_untyped_parameter(byte_iter): + """ + Decodes the untyped parameter pointed by ``byte_iter`` + + This is used in decoding parameters; see :func:`decode_parameter` + + The type of the value is unknown, but it shall be encoded as an + integer, if that is possible. + + From [5], section 8.4.2.4:: + + Untyped-parameter = Token-text Untyped-value + + :return: The name of the parameter, and its value, in the format: + (, ) + :rtype: tuple + """ + token = Decoder.decode_token_text(byte_iter) + value = Decoder.decode_untyped_value(byte_iter) + return token, value + + @staticmethod + def decode_untyped_value(byte_iter): + """ + Decodes the untyped value pointed by ``byte_iter`` + + This is used in decoding parameter values; see + :func:`decode_untyped_parameter` + + From [5], section 8.4.2.4:: + + Untyped-value = Integer-value | Text-value + + :return: The decoded untyped-value + :rtype: int or str + """ + try: + return Decoder.decode_integer_value(byte_iter) + except DecodeError: + return Decoder.decode_text_value(byte_iter) + + @staticmethod + def decode_well_known_parameter(byte_iter, version='1.2'): + """Decodes the name and expected value type of a parameter of (for + example) a "Content-Type" header entry, taking into account the WSP + short form (assigned numbers) of well-known parameter names, as + specified in section 8.4.2.4 and table 38 of [5]. + + The code values used for parameters are specified in [5], table 38 + + From [5], section 8.4.2.4:: + + Well-known-parameter-token = Integer-value + + :raise ValueError: The specified encoding version is invalid. + :raise DecodeError: This is raised if the integer value representing + the well-known parameter name cannot be decoded + correctly, or the well-known paramter token value + could not be found in the table of assigned + content types. + If this exception is raised, the iterator passed + as ``byte_iter`` is not modified. + + :param version: The WSP encoding version to use. This defaults + to "1.2", but may be "1.1", "1.2", "1.3" or + 1.4" (see table 39 in [5] for details). + :type version: str + + :return: the decoded parameter name, and its expected value type, in + the format (, ) + :rtype: tuple + """ + parameter_name = expected_value = '' + try: + parameter_value = Decoder.decode_integer_value(byte_iter) + except DecodeError: + raise DecodeError('Invalid well-known parameter token: could ' + 'not read integer value representing it') + + wk_params = get_well_known_parameters(version) + if parameter_value in wk_params: + parameter_name, expected_value = wk_params[parameter_value] + else: + #If this is reached, the parameter isn't a WSP well-known one + raise DecodeError('Invalid well-known parameter token: could ' + 'not find in table of assigned numbers ' + '(encoding version %s)' % version) + + return parameter_name, expected_value + + #TODO: somehow this should be more dynamic; we need to know what type + # is EXPECTED (hence the TYPED value) + @staticmethod + def decode_typed_value(byte_iter): + """ + Decodes the typed value pointed by ``byte_iter`` + + In addition to the expected type, there may be no value. + If the value cannot be encoded using the expected type, it shall be + encoded as text. + + This is used in decoding parameters, see :func:`decode_parameter` + From [5], section 8.4.2.4:: + + Typed-value = Compact-value | Text-value + + :return: The decoded Parameter Typed-value + :rtype: str + """ + typedValue = '' + try: + typedValue = Decoder.decode_compact_value(byte_iter) + except DecodeError: + try: + typedValue = Decoder.decode_text_value(byte_iter) + except DecodeError: + raise DecodeError('Could not decode the Parameter Typed-value') + + return typedValue + + # TODO: somehow this should be more dynamic; we need to know what + # type is EXPECTED + @staticmethod + def decode_compact_value(byte_iter): + """ + Decodes the compact value pointed by ``byte_iter`` + + This is used in decoding parameters, see :func:`decodeTypeValue` + + From [5], section 8.4.2.4:: + + Compact-value = Integer-value | Date-value | Delta-seconds-value | Q-value | Version-value | Uri-value + + :raise DecodeError: Failed to decode the Parameter Compact-value; + if this happens, ``byte_iter`` is unmodified + + :return: The decoded Compact-value (this is specific to the + parameter type + :rtype: str or int + """ + compact_value = None + try: + # First, see if it's an integer value + # This solves the checks for: Integer-value, Date-value, + # Delta-seconds-value, Q-value, Version-value + compact_value = Decoder.decode_integer_value(byte_iter) + except DecodeError: + try: + # Try parsing it as a Uri-value + compact_value = Decoder.decode_uri_value(byte_iter) + except DecodeError: + raise DecodeError('Could not decode Parameter Compact-value') + + return compact_value + + @staticmethod + def decode_date_value(byte_iter): + """ + Decode the data value pointed by ``byte_iter`` + + The encoding of dates shall be done in number of seconds from + 1970-01-01, 00:00:00 GMT. + + From [5], section 8.4.2.3:: + + Date-value = Long-integer + + :raise DecodeError: This method uses `decode_long_integer()`, and thus + raises this under the same conditions. + + :rtype: datetime.datetime + """ + return datetime.utcfromtimestamp(Decoder.decode_long_integer(byte_iter)) + + @staticmethod + def decode_delta_seconds_value(byte_iter): + """ + Decodes the delta seconds value pointed by ``byte_iter`` + + From [5], section 8.4.2.3:: + + Delta-seconds-value = Integer-value + + :raise DecodeError: This method uses `decode_integer_value`, and thus + raises this under the same conditions. + :return: the decoded delta-seconds-value + :rtype: int + """ + return Decoder.decode_integer_value(byte_iter) + + @staticmethod + def decode_q_value(byte_iter): + """ From [5], section 8.4.2.1: + The encoding is the same as in uint_var-integer, but with restricted + size. When quality factor 0 and quality factors with one or two + decimal digits are encoded, they shall be multiplied by 100 and + incremented by one, so that they encode as a one-octet value in + range 1-100, ie, 0.1 is encoded as 11 (0x0B) and 0.99 encoded as + 100 (0x64). Three decimal quality factors shall be multiplied with + 1000 and incremented by 100, and the result shall be encoded as a + one-octet or two-octet uintvar, eg, 0.333 shall be encoded as 0x83 + 0x31. Quality factor 1 is the default value and shall never be sent. + + :return: The decode quality factor (Q-value) + :rtype: float + """ + q_value_int = Decoder.decode_uint_var(byte_iter) + # TODO: limit the amount of decimal points + if q_value_int > 100: + return float(q_value_int - 100) / 1000.0 + + return float(q_value_int - 1) / 100.0 + + @staticmethod + def decode_version_value(byte_iter): + """ + Decodes the version-value. + + From [5], section 8.4.2.3:: + + Version-value = Short-integer | Text-string + + :return: the decoded version value in the format, usually in the + format: "." + :rtype: str + """ + try: + byteValue = Decoder.decode_short_integer(byte_iter) + major = (byteValue & 0x70) >> 4 + minor = byteValue & 0x0f + return '%d.%d' % (major, minor) + except DecodeError: + return Decoder.decode_text_string(byte_iter) + + @staticmethod + def decode_uri_value(byte_iter): + """ + Stub for Uri-value decoding; see :func:`decode_text_string` + """ + return Decoder.decode_text_string(byte_iter) + + @staticmethod + def decode_text_value(byte_iter): + """ + Stub for Parameter Text-value decoding. + + This is used when decoding parameter values; see + :func:`decode_typed_value` + + From [5], section 8.4.2.3:: + + Text-value = No-value | Token-text | Quoted-string + + :return: The decoded Parameter Text-value + :rtype: str + """ + try: + return Decoder.decode_token_text(byte_iter) + except DecodeError: + try: + return Decoder.decode_quoted_string(byte_iter) + except DecodeError: + # Ok, so it's a "No-value" + return '' + + @staticmethod + def decode_no_value(byte_iter): + """ + Verifies that the byte pointed to by ``byte_iter`` is 0x00. + + If successful, this function will move ``byte_iter`` one byte forward + + :raise DecodeError: If 0x00 is not found; ``byte_iter`` is not modified + if this is raised. + + :return: No-value, which is 0x00 + :rtype: int + """ + byte_iter, local_iter = next(byte_iter) + if next(local_iter) != 0x00: + raise DecodeError('Expected No-value') + + next(byte_iter) + return 0x00 + + @staticmethod + def decode_accept_value(byte_iter): + """ + most of these things are currently decoded, but discarded (e.g + accept-parameters); we only return the media type + + From [5], section 8.4.2.7:: + + Accept-value = Constrained-media | Accept-general-form + Accept-general-form = Value-length Media-range [Accept-parameters] + Media-range = (Well-known-media | Extension-Media) *(Parameter) + Accept-parameters = Q-token Q-value *(Accept-extension) + Accept-extension = Parameter + Q-token = + + :raise DecodeError: The decoding failed. ``byte_iter`` will not be + modified in this case. + :return: the decoded Accept-value (media/content type) + :rtype: str + """ + # Try to use Constrained-media encoding + try: + accept_value = Decoder.decode_constrained_media(byte_iter) + except DecodeError: + # ...now try Accept-general-form + value_length = Decoder.decode_value_length(byte_iter) + try: + media = Decoder.decode_well_known_media(byte_iter) + except DecodeError: + media = Decoder.decode_extension_media(byte_iter) + + # Check for the Q-Token (to see if there are Accept-parameters) + if byte_iter.preview() == 128: + next(byte_iter) + q_value = Decoder.decode_q_value(byte_iter) + try: + accept_extension = Decoder.decode_parameter(byte_iter) + except DecodeError: + # Just set an empty iterable + accept_extension = [] + + byte_iter.reset_preview() + accept_value = media + + return accept_value + + @staticmethod + def decode_pragma_value(byte_iter): + """ + Decodes the pragma value pointed by ``byte_iter`` + + Defined in [5], section 8.4.2.38:: + + Pragma-value = No-cache | (Value-length Parameter) + + From [5], section 8.4.2.15:: + + No-cache = + + :raise DecodeError: The decoding failed. ``byte_iter`` will not be + modified in this case. + :return: the decoded Pragma-value, in the format: + (, ) + :rtype: tuple + """ + byte = byte_iter.preview() + if byte == 0x80: # No-cache + next(byte_iter) + # TODO: Not sure if this parameter name (or even usage) is correct + name, value = 'Cache-control', 'No-cache' + else: + byte_iter.reset_preview() + value_length = Decoder.decode_value_length(byte_iter) + name, value = Decoder.decode_parameter(byte_iter) + + return name, value + + @staticmethod + def decode_well_known_charset(byte_iter): + """ + From [5], section 8.4.2.8:: + + Well-known-charset = Any-charset | Integer-value + Any-charset = + + It is encoded using values from "Character Set Assignments" table. + + Equivalent to the special RFC2616 charset value "*" + """ + # Look for the Any-charset value + byte = byte_iter.preview() + byte_iter.reset_preview() + if byte == 127: + next(byte_iter) + decoded_charset = '*' + else: + charset_value = Decoder.decode_integer_value(byte_iter) + if charset_value in well_known_charsets: + decoded_charset = well_known_charsets[charset_value] + else: + # This charset is not in our table... so just use the + # value (at least for now) + decoded_charset = str(charset_value) + + return decoded_charset + + @staticmethod + def decode_well_known_header(byte_iter): + """ + Currently, "Wap-value" is decoded as a Text-string in most cases + + From [5], section 8.4.2.6:: + + Well-known-header = Well-known-field-name Wap-value + Well-known-field-name = Short-integer + Wap-value = + + + :return: The header name, and its value, in the format: + (, ) + :rtype: tuple + """ + field_value = Decoder.decode_short_integer(byte_iter) + hdr_fields = get_header_field_names() + # TODO: *technically* this can fail, but then we have already + # read a byte... should fix? + if field_value not in range(len(hdr_fields)): + raise DecodeError('Invalid Header Field value: %d' % field_value) + + field_name = hdr_fields[field_value] + + # TODO: make this flow better, and implement it in + # decode_application_header also + # Currently we decode most headers as text_strings, except + # where we have a specific decoding algorithm implemented + if field_name in header_field_encodings: + wap_value_type = header_field_encodings[field_name] + try: + decoded_value = getattr(Decoder, + 'decode_%s' % wap_value_type)(byte_iter) + except DecodeError as msg: + raise DecodeError('Could not decode Wap-value: %s' % msg) + except: + debug('An error occurred, probably due to an ' + 'unimplemented decoding operation. Tried to ' + 'decode header: %s' % field_name) + raise + + else: + decoded_value = Decoder.decode_text_string(byte_iter) + + return field_name, decoded_value + + @staticmethod + def decode_application_header(byte_iter): + """ + From [5], section 8.4.2.6:: + + Application-header = Token-text Application-specific-value + + From [4], section 7.1:: + Application-header = Token-text Application-specific-value + Application-specific-value = Text-string + + This is used when decoding generic WSP headers; see + :func:`decode_header`. We follow [4], and decode the + "Application-specific-value" as a Text-string + + :return: The application-header, and its value, in the format: + (, ) + :rtype: tuple + """ + try: + app_header = Decoder.decode_token_text(byte_iter) + #FNA: added for brute-forcing + except DecodeError: + app_header = Decoder.decode_text_string(byte_iter) + + app_specific_value = Decoder.decode_text_string(byte_iter) + return app_header, app_specific_value + + @staticmethod + def decode_header(byte_iter): + """ + Decodes a WSP header entry + + "Shift-sequence" encoding has not been implemented. Currently, + almost all header values are treated as text-strings + + From [5], section 8.4.2.6:: + + Header = Message-header | Shift-sequence + Message-header = Well-known-header | Application-header + Well-known-header = Well-known-field-name Wap-value + Application-header = Token-text Application-specific-value + + + :return: The decoded headername, and its value, in the format: + (, ) + :rtype: tuple + """ + # First try decoding the header as a well-known-header + try: + return Decoder.decode_well_known_header(byte_iter) + except DecodeError: + # ...now try Application-header encoding + return Decoder.decode_application_header(byte_iter) + + +class Encoder: + """A WSP Data unit decoder""" + + @staticmethod + def encode_uint_8(uint): + """ + Encodes an 8-bit unsigned integer + + :param uint: The integer to encode + :type byte_iter: int + + :return: the encoded uint_8, as a sequence of bytes + :rtype: list + """ + # Make the byte unsigned + return [uint & 0xff] + + @staticmethod + def encode_uint_var(uint): + """ + Variable Length Unsigned Integer encoding algorithm + + This binary-encodes the given unsigned integer number as specified + in section 8.1.2 of [5]. Basically, each encoded byte has the + following structure:: + + [0][ Payload ] + | ^^^^^^^ + | 7 bits (actual data) + | + Continue bit + + The uint is split into 7-bit segments, and the "continue bit" of each + used octet is set to '1' to indicate more is to follow; the last used + octet's "continue bit" is set to 0. + + :return: the binary-encoded uint_var, as a list of byte values + :rtype: list + """ + uint_var = [uint & 0x7f] + # Since this is the lowest entry, we do not set the continue bit to 1 + uint = uint >> 7 + # ...but for the remaining octets, we have to + while uint > 0: + uint_var.insert(0, 0x80 | (uint & 0x7f)) + uint = uint >> 7 + + return uint_var + + @staticmethod + def encode_text_string(string): + """ Encodes a "Text-string" value. + + This follows the basic encoding rules specified in [5], section + 8.4.2.1 + + :param string: The text string to encode + :type string: str + + :return: the null-terminated, binary-encoded version of the + specified Text-string, as a list of byte values + :rtype: list + """ + encoded_string = [ord(c) for c in string] + encoded_string.append(0x00) + return encoded_string + + @staticmethod + def encode_short_integer(integer): + """ + Encodes the specified short-integer ``integer`` value + + Integers in range 0-127 shall be encoded as a one octet value with + the most significant bit set to one (1xxx xxxx) and with the value + in the remaining least significant bits. + + The encoding for a long integer is specified in [5], section 8.4.2.1:: + + Short-integer = OCTET + + :param integer: The short-integer value to encode + :type integer: int + + :raise EncodeError: Not a valid short-integer; the integer must be in + the range of 0-127 + + :return: The encoded short integer, as a list of byte values + :rtype: list + """ + if integer < 0 or integer > 127: + raise EncodeError('Short-integer value must be in ' + 'range 0-127: %d' % integer) + + # Make sure the MSB is set + return [integer | 0x80] + + @staticmethod + def encode_long_integer(integer): + """ + Encodes a Long-integer value ``integer`` + + The encoding for a long integer is specified in [5], section 8.4.2.1; + for a description of this encoding scheme, see + :func:`wsp.Decoder.decode_long_integer`. + + From [5], section 8.4.2.2:: + + Long-integer = Short-length Multi-octet-integer + Short-length = + + :raise EncodeError: is not of type "int" + + :param integer: The integer value to encode + :type integer: int + + :return: The encoded Long-integer, as a sequence of byte values + :rtype: list + """ + if not isinstance(integer, int): + raise EncodeError(' must be of type "int"') + + encoded_long_int = [] + longInt = integer + # Encode the Multi-octect-integer + while longInt > 0: + byte = 0xff & longInt + encoded_long_int.append(byte) + longInt = longInt >> 8 + + # Now add the SHort-length value, and make sure it's ok + shortLength = len(encoded_long_int) + if shortLength > 30: + raise EncodeError('Cannot encode Long-integer value: Short-length ' + 'is too long; should be in octet range 0-30') + encoded_long_int.insert(0, shortLength) + return encoded_long_int + + @staticmethod + def encode_version_value(version): + """ + Encodes the version-value. + + Example: An MMS version of "1.0" consists of a major version of 1 and a + minor version of 0, and would be encoded as 0x90. However, a version + of "1.2.4" would be encoded as the Text-string "1.2.4". + + From [5], section 8.4.2.3:: + Version-value = Short-integer | Text-string + + :param version: The version number to encode, e.g. "1.0" + :type version: str + + :raise TypeError: The specified version value was not of type `str` + + :return: the encoded version value, as a list of byte values + :rtype: list + """ + if not isinstance(version, str): + raise TypeError('Parameter must be of type "str"') + + encoded_version_val = [] + # First try short-integer encoding + try: + if len(version.split('.')) <= 2: + major_version = int(version.split('.')[0]) + if major_version < 1 or major_version > 7: + raise ValueError('Major version must be in range 1-7') + + major = major_version << 4 + if len(version.split('.')) == 2: + minor_version = int(version.split('.')[1]) + if minor_version < 0 or minor_version > 14: + raise ValueError('Minor version must be in range 0-14') + else: + minor_version = 15 + + minor = minor_version + encoded_version_val = Encoder.encode_short_integer(major | minor) + except: + # The value couldn't be encoded as a short-integer; use + # a text-string instead + encoded_version_val = Encoder.encode_text_string(version) + + return encoded_version_val + + @staticmethod + def encode_media_type(content_type): + """ + Encodes the specified MIME ``content_type`` ("Media-type" value) + + "Well-known-media" takes into account the WSP short form of well-known + content types, as specified in section 8.4.2.24 and table 40 of [5]. + + From [5], section 8.2.4.24:: + + Media-type = (Well-known-media | Extension-Media) *(Parameter) + + :param content_type: The MIME content type to encode + :type content_type: str + + :return: The binary-encoded content type, as a list of (integer) byte + values + :rtype: list + """ + if content_type in well_known_content_types: + # Short-integer encoding + val = Encoder.encode_short_integer( + well_known_content_types.index(content_type)) + else: + val = Encoder.encode_text_string(content_type) + + return [val] + + @staticmethod + def encode_parameter(parameter_name, parameter_value, version='1.2'): + """ + Encodes ``parameter_name`` and ``parameter_value`` using ``version`` + + Binary-encodes the name of a parameter of -say- a "Content-Type" + header entry, taking into account the WSP short form of + well-known parameter names, as specified in section 8.4.2.4 and table + 38 of [5]. + + From [5], section 8.4.2.4:: + + Parameter = Typed-parameter | Untyped-parameter + Typed-parameter = Well-known-parameter-token Typed-value + Untyped-parameter = Token-text Untyped-value + Untyped-value = Integer-value | Text-value + + :param parameter_name: The name of the parameter to encode + :type parameter_name: str + :param parameter_value: The value of the parameter + :type parameter_value: str or int + + :param version: The WSP encoding version to use. This defaults + to "1.2", but may be "1.1", "1.2", "1.3" or + "1.4" (see table 38 in [5] for details). + :type version: str + + :raise ValueError: The specified encoding version is invalid. + + :return: The binary-encoded parameter name, as a list of (integer) + byte values + :rtype: list + """ + wk_params = get_well_known_parameters(version) + encoded_parameter = [] + # Try to encode the parameter using a "Typed-parameter" value + wkParamNumbers = list(wk_params.keys()) + wkParamNumbers.sort(reverse=True) + for assigned_number in wkParamNumbers: + if wk_params[assigned_number][0] == parameter_name: + # Ok, it's a Typed-parameter; encode the parameter name + encoded_parameter.extend( + Encoder.encode_short_integer(assigned_number)) + # and now the value + expected_type = wk_params[assigned_number][1] + try: + ret = getattr(Encoder, + 'encode_%s' % expected_type)(parameter_value) + encoded_parameter.extend(ret) + except EncodeError as msg: + raise EncodeError('Error encoding param value: %s' % msg) + except: + debug('A fatal error occurred, probably due to an ' + 'unimplemented encoding operation') + raise + break + + # See if the "Typed-parameter" encoding worked + if len(encoded_parameter) == 0: + # it didn't. Use "Untyped-parameter" encoding + encoded_parameter.extend(Encoder.encode_token_text(parameter_name)) + value = [] + # First try to encode the untyped-value as an integer + try: + value = Encoder.encode_integer_value(parameter_value) + except EncodeError: + value = Encoder.encode_text_string(parameter_value) + + encoded_parameter.extend(value) + + return encoded_parameter + + # TODO: check up on the encoding/decoding of Token-text, in particular, + # how does this differ from text-string? does it have 0x00 at the end? + @staticmethod + def encode_token_text(text): + """ From [5], section 8.4.2.1: + Token-text = Token End-of-string + + :raise EncodeError: Specified text cannot be encoding as a token + + :return: The encoded token string, as a list of byte values + :rtype: list + """ + separators = (11, 32, 40, 41, 44, 47, 58, 59, 60, 61, 62, 63, 64, + 91, 92, 93, 123, 125) + # Sanity check + for char in separators: + if chr(char) in text: + raise EncodeError('Char "%s" in text string; cannot ' + 'encode as Token-text' % chr(char)) + + return Encoder.encode_text_string(text) + + @staticmethod + def encode_integer_value(integer): + """Encodes an integer value + + From [5], section 8.4.2.3: + Integer-Value = Short-integer | Long-integer + + This function will first try to encode the specified integer value + into a short-integer, and failing that, will encode into a + long-integer value. + + :param integer: The integer to encode + :type integer: int + + :raise EncodeError: The parameter is not of type `int` + + :return: The encoded integer value, as a list of byte values + :rtype: list + """ + if not isinstance(integer, int): + raise EncodeError(' must be of type "int"') + + # First try and see if it's a short-integer + try: + return Encoder.encode_short_integer(integer) + except EncodeError: + return Encoder.encode_long_integer(integer) + + @staticmethod + def encode_text_value(text): + """Stub for encoding Text-values; see :func:`encode_text_string`""" + return Encoder.encode_text_string(text) + + @staticmethod + def encode_no_value(value=None): + """ + Encodes a No-value ``value``, which is 0x00 + + This function mainly exists for use by automatically-selected + encoding routines (see :func:`encode_parameter`) for an example. + + :param value: This value is ignored; it is present so that this + method complies with the format of the other `encode` + methods. + + :return: A list containing a single "No-value", which is 0x00 + :rtype: list + """ + return [0x00] + + @staticmethod + def encode_header(field_name, value): + """ + Encodes a WSP header entry ``field_name``, and its ``value`` + + "Shift-sequence" encoding has not been implemented. Currently, + almost all header values are encoded as text-strings + + From [5], section 8.4.2.6:: + + Header = Message-header | Shift-sequence + Message-header = Well-known-header | Application-header + Well-known-header = Well-known-field-name Wap-value + Application-header = Token-text Application-specific-value + + :return: The encoded header, and its value, as a sequence of + byte values + :rtype: list + """ + encoded_header = [] + # First try encoding the header name as a "well-known-header"... + wkHdrFields = get_header_field_names() + if field_name in wkHdrFields: + header_field_value = Encoder.encode_short_integer( + wkHdrFields.index(field_name)) + encoded_header.extend(header_field_value) + else: + # otherwise, encode it as an "application header" + encoded_header_name = Encoder.encode_token_text(field_name) + encoded_header.extend(encoded_header_name) + + # Now add the value + # TODO: make this flow better (see also Decoder.decode_header) + # most header values are encoded as text_strings, except where we + # have a specific Wap-value encoding implementation + if field_name in header_field_encodings: + wap_value_type = header_field_encodings[field_name] + try: + ret = getattr(Encoder, 'encode_%s' % wap_value_type)(value) + encoded_header.extend(ret) + except EncodeError as msg: + raise EncodeError('Error encoding Wap-value: %s' % msg) + except: + debug('A fatal error occurred, probably due to an ' + 'unimplemented encoding operation') + raise + else: + encoded_header.extend(Encoder.encode_text_string(value)) + + return encoded_header + + @staticmethod + def encode_content_type_value(media_type, parameters): + """ + Encodes a content type, and its parameters + + The short form of the Content-type-value MUST only be used when the + well-known media is in the range of 0-127 or a text string. In all + other cases the general form MUST be used. + + From [5], section 8.4.2.24:: + + Content-type-value = Constrained-media | Content-general-form + + :return: The encoded Content-type-value (including parameters, if + any), as a sequence of bytes + :rtype: list + """ + # First try do encode it using Constrained-media encoding + try: + if len(parameters): + raise EncodeError('Need to use ' + 'Content-general-form for parameters') + + return Encoder.encode_constrained_media(media_type) + except EncodeError: + # Try the general form + return Encoder.encode_content_general_form(media_type, parameters) + + @staticmethod + def encode_constrained_media(media_type): + """ + Encodes the constrained media ``media_type`` + + It is encoded using values from the "Content Type Assignments" table. + + From [5], section 8.4.2.7:: + + Constrained-media = Constrained-encoding + + :param media_type: The media type to encode + :type media_type: str + + :raise EncodeError: Media value is unsuitable for Constrained-encoding + + :return: The encoded media type, as a sequence of bytes + :rtype: list + """ + # See if this value is in the table of well-known content types + if media_type in well_known_content_types: + value = well_known_content_types.index(media_type) + else: + value = media_type + + return Encoder.encode_constrained_encoding(value) + + @staticmethod + def encode_constrained_encoding(value): + """ + Constrained-encoding = Extension-Media --or-- Short-integer + + This encoding is used for token values, which have no well-known + binary encoding, or when the assigned number of the well-known + encoding is small enough to fit into Short-integer. + + :param value: The value to encode + :type value: int or str + + :raise EncodeError: cannot be encoded as a + Constrained-encoding sequence + + :return: The encoded constrained-encoding token value, as a sequence + of bytes + :rtype: list + """ + encoded_value = None + if isinstance(value, int): + # First try and encode the value as a short-integer + encoded_value = Encoder.encode_short_integer(value) + else: + # Ok, it should be Extension-Media then + try: + encoded_value = Encoder.encode_extension_media(value) + except EncodeError: + # Give up + raise EncodeError('Cannot encode %s as a ' + 'Constrained-encoding sequence' % str(value)) + + return encoded_value + + @staticmethod + def encode_extension_media(media_value): + """ + Encodes the extension media ``media_value`` + + This encoding is used for media values, which have no well-known + binary encoding + + From [5], section 8.4.2.1:: + + Extension-media = *TEXT End-of-string + + :param media_value: The media value (string) to encode + :type media_value: str + + :raise EncodeError: The value cannot be encoded as TEXT; probably it + starts with/contains an invalid character + + :return: The encoded media type value, as a sequence of bytes + :rtype: str + """ + if not isinstance(media_value, str): + try: + media_value = str(media_value) + except: + raise EncodeError('Invalid Extension-media: Cannot convert ' + 'value to text string') + char = media_value[0] + if ord(char) < 32 or ord(char) == 127: + raise EncodeError('Invalid Extension-media: TEXT starts with ' + 'invalid character: %s' % ord(char)) + + return Encoder.encode_text_string(media_value) + + @staticmethod + def encode_content_general_form(media_type, parameters): + """ + From [5], section 8.4.2.24:: + + Content-general-form = Value-length Media-type + + Used in decoding Content-type fields and their parameters; + see :func:`decode_content_type_value`. Used by + :func:`decode_content_type_value`. + + :return: The encoded Content-general-form, as a sequence of bytes + :rtype: list + """ + enconded_content_general_form = [] + encoded_media_type = [] + # Encode the actual content type + encoded_media_type = Encoder.encode_media_type(media_type) + # Encode all parameters + encoded_parameters = [Encoder.encode_parameter(name, parameters[name]) + for name in parameters] + + value_length = len(encoded_media_type) + len(encoded_parameters) + encoded_value_length = Encoder.encode_value_length(value_length) + enconded_content_general_form.extend(encoded_value_length) + enconded_content_general_form.extend(encoded_media_type) + enconded_content_general_form.extend(encoded_parameters) + + return enconded_content_general_form + + @staticmethod + def encode_value_length(length): + """ + Encodes the specified length value as a value length indicator + + "Value length" is used to indicate the length of a value to follow, as + used in the `Content-Type` header in the MMS body, for example. + + The encoding for a value length indicator is specified in [5], + section 8.4.2.2, and follows the form:: + + Value-length = [Short-length] --or-- [Length-quote] [Length] + ^^^^^^ ^^^^^^ ^^^^^^ + 1 byte 1 byte x bytes + uint_var-integer + + :raise EncodeError: The value_length could not be encoded. + + :return: The encoded value length indicator, as a sequence of bytes + :rtype: list + """ + encoded_value_length = [] + # Try and encode it as a short-length + try: + encoded_value_length = Encoder.encode_short_length(length) + except EncodeError: + # Encode it with a Length-quote and uint_var + encoded_value_length.append(31) # Length-quote + encoded_value_length.extend(Encoder.encode_uint_var(length)) + + return encoded_value_length + + @staticmethod + def encode_short_length(length): + """ + From [5], section 8.4.2.2:: + + Short-length = + + :raise EncodeError: The specified cannot be encoded as a + short-length value; it is not in octet range 0-30. + + :return: The encoded short-length, as a sequence of bytes + :rtype: list + """ + if length < 0 or length > 30: + raise EncodeError('Cannot encode short-length; length should ' + 'be in the 0-30 range') + + return [length] + + @staticmethod + def encode_accept_value(accept_value): + """ + From [5], section 8.4.2.7:: + + Accept-value = Constrained-media | Accept-general-form + Accept-general-form = Value-length Media-range [Accept-parameters] + Media-range = (Well-known-media | Extension-Media) *(Parameter) + Accept-parameters = Q-token Q-value *(Accept-extension) + Accept-extension = Parameter + Q-token = + + :note: This implementation does not currently support encoding of + "Accept-parameters". + + :param accept_value: The Accept-value to encode (media/content type) + :type accept_value: str + + :raise EncodeError: The encoding failed. + + :return: The encoded Accept-value, as a sequence of bytes + :rtype: list + """ + encoded_accept_value = [] + # Try to use Constrained-media encoding + try: + encoded_accept_value = Encoder.encode_constrained_media(accept_value) + except EncodeError: + # ...now try Accept-general-form + try: + encoded_media_range = Encoder.encode_media_type(accept_value) + except EncodeError as msg: + raise EncodeError('Cannot encode Accept-value: %s' % msg) + + value_length = Encoder.encode_value_length(len(encoded_media_range)) + encoded_accept_value = value_length + encoded_accept_value.extend(encoded_media_range) + + return encoded_accept_value diff --git a/messaging/sms/__init__.py b/messaging/sms/__init__.py new file mode 100644 index 0000000..f00ad10 --- /dev/null +++ b/messaging/sms/__init__.py @@ -0,0 +1,7 @@ +# See LICENSE + +from messaging.sms.submit import SmsSubmit +from messaging.sms.deliver import SmsDeliver +from messaging.sms.gsm0338 import is_gsm_text + +__all__ = ["SmsSubmit", "SmsDeliver", "is_gsm_text"] diff --git a/messaging/sms/base.py b/messaging/sms/base.py new file mode 100644 index 0000000..b7b1d59 --- /dev/null +++ b/messaging/sms/base.py @@ -0,0 +1,14 @@ +# see LICENSE + + +class SmsBase(object): + + def __init__(self): + self.udh = None + self.number = None + self.text = None + self.fmt = None + self.dcs = None + self.pid = None + self.csca = None + self.type = None diff --git a/messaging/sms/consts.py b/messaging/sms/consts.py new file mode 100644 index 0000000..caf7806 --- /dev/null +++ b/messaging/sms/consts.py @@ -0,0 +1,17 @@ +# see LICENSE +SEVENBIT_SIZE = 160 +EIGHTBIT_SIZE = 140 +UCS2_SIZE = 70 +SEVENBIT_MP_SIZE = SEVENBIT_SIZE - 7 +EIGHTBIT_MP_SIZE = EIGHTBIT_SIZE - 6 +UCS2_MP_SIZE = UCS2_SIZE - 3 + +# address type +UNKNOWN = 0 +INTERNATIONAL = 1 +NATIONAL = 2 +NETWORK_SPECIFIC = 3 +SUBSCRIBER = 4 +ALPHANUMERIC = 5 +ABBREVIATED = 6 +RESERVED = 7 diff --git a/messaging/sms/deliver.py b/messaging/sms/deliver.py new file mode 100644 index 0000000..37714e6 --- /dev/null +++ b/messaging/sms/deliver.py @@ -0,0 +1,264 @@ +# see LICENSE +"""Classes for processing received SMS""" + +from datetime import datetime, timedelta + +from messaging.utils import (swap, swap_number, encode_bytes, debug, + unpack_msg, unpack_msg2, to_array) +from messaging.sms import consts +from messaging.sms.base import SmsBase +from messaging.sms.udh import UserDataHeader + + +class SmsDeliver(SmsBase): + """I am a delivered SMS in your Inbox""" + + def __init__(self, pdu, strict=True): + super(SmsDeliver, self).__init__() + self._pdu = None + self._strict = strict + self.date = None + self.mtype = None + self.sr = None + + self.pdu = pdu + + @property + def data(self): + """ + Returns a dict populated with the SMS attributes + + It mimics the old API to ease the port to the new API + """ + ret = { + 'text': self.text, + 'pid': self.pid, + 'dcs': self.dcs, + 'csca': self.csca, + 'number': self.number, + 'type': self.type, + 'date': self.date, + 'fmt': self.fmt, + 'sr': self.sr, + } + + if self.udh is not None: + if self.udh.concat is not None: + ret.update({ + 'ref': self.udh.concat.ref, + 'cnt': self.udh.concat.cnt, + 'seq': self.udh.concat.seq, + }) + + return ret + + def _set_pdu(self, pdu): + if not self._strict and len(pdu) % 2: + # if not strict and PDU-length is odd, remove the last character + # and make it even. See the discussion of this bug at + # http://github.com/pmarti/python-messaging/issues#issue/7 + pdu = pdu[:-1] + + if len(pdu) % 2: + raise ValueError("Can not decode an odd-length pdu") + + # XXX: Should we keep the original PDU or the modified one? + self._pdu = pdu + + data = to_array(self._pdu) + + # Service centre address + smscl = data.pop(0) + if smscl > 0: + smscertype = data.pop(0) + smscl -= 1 + self.csca = swap_number(encode_bytes(data[:smscl])) + if (smscertype >> 4) & 0x07 == consts.INTERNATIONAL: + self.csca = '+%s' % self.csca + data = data[smscl:] + else: + self.csca = None + + # 1 byte(octet) == 2 char + # Message type TP-MTI bits 0,1 + # More messages to send/deliver bit 2 + # Status report request indicated bit 5 + # User Data Header Indicator bit 6 + # Reply path set bit 7 + try: + self.mtype = data.pop(0) + except TypeError: + raise ValueError("Decoding this type of SMS is not supported yet") + + mtype = self.mtype & 0x03 + + if mtype == 0x02: + return self._decode_status_report_pdu(data) + + if mtype == 0x01: + raise ValueError("Cannot decode a SmsSubmitReport message yet") + + sndlen = data.pop(0) + if sndlen % 2: + sndlen += 1 + sndlen = int(sndlen / 2.0) + + sndtype = (data.pop(0) >> 4) & 0x07 + if sndtype == consts.ALPHANUMERIC: + # coded according to 3GPP TS 23.038 [9] GSM 7-bit default alphabet + sender = unpack_msg2(data[:sndlen]).decode("gsm0338") + else: + # Extract phone number of sender + sender = swap_number(encode_bytes(data[:sndlen])) + if sndtype == consts.INTERNATIONAL: + sender = '+%s' % sender + + self.number = sender + data = data[sndlen:] + + # 1 byte TP-PID (Protocol IDentifier) + self.pid = data.pop(0) + # 1 byte TP-DCS (Data Coding Scheme) + self.dcs = data.pop(0) + if self.dcs & (0x04 | 0x08) == 0: + self.fmt = 0x00 + elif self.dcs & 0x04: + self.fmt = 0x04 + elif self.dcs & 0x08: + self.fmt = 0x08 + + datestr = '' + # Get date stamp (sender's local time) + date = list(encode_bytes(data[:6])) + for n in range(1, len(date), 2): + date[n - 1], date[n] = date[n], date[n - 1] + + data = data[6:] + + # Get sender's offset from GMT (TS 23.040 TP-SCTS) + tz = data.pop(0) + + offset = ((tz & 0x07) * 10 + ((tz & 0xf0) >> 4)) * 15 + if (tz & 0x08): + offset = offset * -1 + + # 02/08/26 19:37:41 + datestr = "%s%s/%s%s/%s%s %s%s:%s%s:%s%s" % tuple(date) + outputfmt = '%y/%m/%d %H:%M:%S' + + sndlocaltime = datetime.strptime(datestr, outputfmt) + sndoffset = timedelta(minutes=offset) + # date as UTC + self.date = sndlocaltime - sndoffset + + self._process_message(data) + + def _process_message(self, data): + # Now get message body + msgl = data.pop(0) + msg = encode_bytes(data[:msgl]) + # check for header + headlen = ud_len = 0 + + if self.mtype & 0x40: # UDHI present + ud_len = data.pop(0) + self.udh = UserDataHeader.from_bytes(data[:ud_len]) + headlen = (ud_len + 1) * 8 + if self.fmt == 0x00: + while headlen % 7: + headlen += 1 + headlen /= 7 + + headlen = int(headlen) + + if self.fmt == 0x00: + # XXX: Use unpack_msg2 + data = data[ud_len:].tolist() + #self.text = unpack_msg2(data).decode("gsm0338") + self.text = unpack_msg(msg)[headlen:msgl].decode("gsm0338") + + elif self.fmt == 0x04: + self.text = data[ud_len:].tostring() + + elif self.fmt == 0x08: + data = data[ud_len:].tolist() + _bytes = [int("%02X%02X" % (data[i], data[i + 1]), 16) + for i in range(0, len(data), 2)] + self.text = ''.join(list(map(chr, _bytes))) + + pdu = property(lambda self: self._pdu, _set_pdu) + + def _decode_status_report_pdu(self, data): + self.udh = UserDataHeader.from_status_report_ref(data.pop(0)) + + sndlen = data.pop(0) + if sndlen % 2: + sndlen += 1 + sndlen = int(sndlen / 2.0) + + sndtype = data.pop(0) + recipient = swap_number(encode_bytes(data[:sndlen])) + if (sndtype >> 4) & 0x07 == consts.INTERNATIONAL: + recipient = '+%s' % recipient + + data = data[sndlen:] + + date = swap(list(encode_bytes(data[:7]))) + try: + scts_str = "%s%s/%s%s/%s%s %s%s:%s%s:%s%s" % tuple(date[0:12]) + self.date = datetime.strptime(scts_str, "%y/%m/%d %H:%M:%S") + except (ValueError, TypeError): + scts_str = '' + debug('Could not decode scts: %s' % date) + + data = data[7:] + + date = swap(list(encode_bytes(data[:7]))) + try: + dt_str = "%s%s/%s%s/%s%s %s%s:%s%s:%s%s" % tuple(date[0:12]) + dt = datetime.strptime(dt_str, "%y/%m/%d %H:%M:%S") + except (ValueError, TypeError): + dt_str = '' + dt = None + debug('Could not decode date: %s' % date) + + data = data[7:] + + msg_l = [recipient, scts_str] + try: + status = data.pop(0) + except IndexError: + # Yes it is entirely possible that a status report comes + # with no status at all! I'm faking for now the values and + # set it to SR-UNKNOWN as that's all we can do + _status = None + status = 0x1 + sender = 'SR-UNKNOWN' + msg_l.append(dt_str) + else: + _status = status + if status == 0x00: + msg_l.append(dt_str) + else: + msg_l.append('') + if status == 0x00: + sender = "SR-OK" + elif status == 0x1: + sender = "SR-UNKNOWN" + elif status == 0x30: + sender = "SR-STORED" + else: + sender = "SR-UNKNOWN" + + self.number = sender + self.text = "|".join(msg_l) + self.fmt = 0x08 # UCS2 + self.type = 0x03 # status report + + self.sr = { + 'recipient': recipient, + 'scts': self.date, + 'dt': dt, + 'status': _status + } + diff --git a/messaging/sms/gsm0338.py b/messaging/sms/gsm0338.py new file mode 100644 index 0000000..10e4ae7 --- /dev/null +++ b/messaging/sms/gsm0338.py @@ -0,0 +1,292 @@ +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +import codecs +import sys +import traceback + +# data from +# http://snoops.roy202.org/testerman/browser/trunk/plugins/codecs/gsm0338.py + +# default GSM 03.38 -> unicode +def_regular_decode_dict = { + '\x00': u'\u0040', # COMMERCIAL AT + '\x01': u'\u00A3', # POUND SIGN + '\x02': u'\u0024', # DOLLAR SIGN + '\x03': u'\u00A5', # YEN SIGN + '\x04': u'\u00E8', # LATIN SMALL LETTER E WITH GRAVE + '\x05': u'\u00E9', # LATIN SMALL LETTER E WITH ACUTE + '\x06': u'\u00F9', # LATIN SMALL LETTER U WITH GRAVE + '\x07': u'\u00EC', # LATIN SMALL LETTER I WITH GRAVE + '\x08': u'\u00F2', # LATIN SMALL LETTER O WITH GRAVE + '\x09': u'\u00C7', # LATIN CAPITAL LETTER C WITH CEDILLA + # The Unicode page suggests this is a mistake: but + # it's still in the latest version of the spec and + # our implementation has to be exact. + + '\x0A': u'\u000A', # LINE FEED + '\x0B': u'\u00D8', # LATIN CAPITAL LETTER O WITH STROKE + '\x0C': u'\u00F8', # LATIN SMALL LETTER O WITH STROKE + '\x0D': u'\u000D', # CARRIAGE RETURN + '\x0E': u'\u00C5', # LATIN CAPITAL LETTER A WITH RING ABOVE + '\x0F': u'\u00E5', # LATIN SMALL LETTER A WITH RING ABOVE + '\x10': u'\u0394', # GREEK CAPITAL LETTER DELTA + '\x11': u'\u005F', # LOW LINE + '\x12': u'\u03A6', # GREEK CAPITAL LETTER PHI + '\x13': u'\u0393', # GREEK CAPITAL LETTER GAMMA + '\x14': u'\u039B', # GREEK CAPITAL LETTER LAMDA + '\x15': u'\u03A9', # GREEK CAPITAL LETTER OMEGA + '\x16': u'\u03A0', # GREEK CAPITAL LETTER PI + '\x17': u'\u03A8', # GREEK CAPITAL LETTER PSI + '\x18': u'\u03A3', # GREEK CAPITAL LETTER SIGMA + '\x19': u'\u0398', # GREEK CAPITAL LETTER THETA + '\x1A': u'\u039E', # GREEK CAPITAL LETTER XI + '\x1C': u'\u00C6', # LATIN CAPITAL LETTER AE + '\x1D': u'\u00E6', # LATIN SMALL LETTER AE + '\x1E': u'\u00DF', # LATIN SMALL LETTER SHARP S (German) + '\x1F': u'\u00C9', # LATIN CAPITAL LETTER E WITH ACUTE + '\x20': u'\u0020', # SPACE + '\x21': u'\u0021', # EXCLAMATION MARK + '\x22': u'\u0022', # QUOTATION MARK + '\x23': u'\u0023', # NUMBER SIGN + '\x24': u'\u00A4', # CURRENCY SIGN + '\x25': u'\u0025', # PERCENT SIGN + '\x26': u'\u0026', # AMPERSAND + '\x27': u'\u0027', # APOSTROPHE + '\x28': u'\u0028', # LEFT PARENTHESIS + '\x29': u'\u0029', # RIGHT PARENTHESIS + '\x2A': u'\u002A', # ASTERISK + '\x2B': u'\u002B', # PLUS SIGN + '\x2C': u'\u002C', # COMMA + '\x2D': u'\u002D', # HYPHEN-MINUS + '\x2E': u'\u002E', # FULL STOP + '\x2F': u'\u002F', # SOLIDUS + '\x30': u'\u0030', # DIGIT ZERO + '\x31': u'\u0031', # DIGIT ONE + '\x32': u'\u0032', # DIGIT TWO + '\x33': u'\u0033', # DIGIT THREE + '\x34': u'\u0034', # DIGIT FOUR + '\x35': u'\u0035', # DIGIT FIVE + '\x36': u'\u0036', # DIGIT SIX + '\x37': u'\u0037', # DIGIT SEVEN + '\x38': u'\u0038', # DIGIT EIGHT + '\x39': u'\u0039', # DIGIT NINE + '\x3A': u'\u003A', # COLON + '\x3B': u'\u003B', # SEMICOLON + '\x3C': u'\u003C', # LESS-THAN SIGN + '\x3D': u'\u003D', # EQUALS SIGN + '\x3E': u'\u003E', # GREATER-THAN SIGN + '\x3F': u'\u003F', # QUESTION MARK + '\x40': u'\u00A1', # INVERTED EXCLAMATION MARK + '\x41': u'\u0041', # LATIN CAPITAL LETTER A + '\x42': u'\u0042', # LATIN CAPITAL LETTER B + '\x43': u'\u0043', # LATIN CAPITAL LETTER C + '\x44': u'\u0044', # LATIN CAPITAL LETTER D + '\x45': u'\u0045', # LATIN CAPITAL LETTER E + '\x46': u'\u0046', # LATIN CAPITAL LETTER F + '\x47': u'\u0047', # LATIN CAPITAL LETTER G + '\x48': u'\u0048', # LATIN CAPITAL LETTER H + '\x49': u'\u0049', # LATIN CAPITAL LETTER I + '\x4A': u'\u004A', # LATIN CAPITAL LETTER J + '\x4B': u'\u004B', # LATIN CAPITAL LETTER K + '\x4C': u'\u004C', # LATIN CAPITAL LETTER L + '\x4D': u'\u004D', # LATIN CAPITAL LETTER M + '\x4E': u'\u004E', # LATIN CAPITAL LETTER N + '\x4F': u'\u004F', # LATIN CAPITAL LETTER O + '\x50': u'\u0050', # LATIN CAPITAL LETTER P + '\x51': u'\u0051', # LATIN CAPITAL LETTER Q + '\x52': u'\u0052', # LATIN CAPITAL LETTER R + '\x53': u'\u0053', # LATIN CAPITAL LETTER S + '\x54': u'\u0054', # LATIN CAPITAL LETTER T + '\x55': u'\u0055', # LATIN CAPITAL LETTER U + '\x56': u'\u0056', # LATIN CAPITAL LETTER V + '\x57': u'\u0057', # LATIN CAPITAL LETTER W + '\x58': u'\u0058', # LATIN CAPITAL LETTER X + '\x59': u'\u0059', # LATIN CAPITAL LETTER Y + '\x5A': u'\u005A', # LATIN CAPITAL LETTER Z + '\x5B': u'\u00C4', # LATIN CAPITAL LETTER A WITH DIAERESIS + '\x5C': u'\u00D6', # LATIN CAPITAL LETTER O WITH DIAERESIS + '\x5D': u'\u00D1', # LATIN CAPITAL LETTER N WITH TILDE + '\x5E': u'\u00DC', # LATIN CAPITAL LETTER U WITH DIAERESIS + '\x5F': u'\u00A7', # SECTION SIGN + '\x60': u'\u00BF', # INVERTED QUESTION MARK + '\x61': u'\u0061', # LATIN SMALL LETTER A + '\x62': u'\u0062', # LATIN SMALL LETTER B + '\x63': u'\u0063', # LATIN SMALL LETTER C + '\x64': u'\u0064', # LATIN SMALL LETTER D + '\x65': u'\u0065', # LATIN SMALL LETTER E + '\x66': u'\u0066', # LATIN SMALL LETTER F + '\x67': u'\u0067', # LATIN SMALL LETTER G + '\x68': u'\u0068', # LATIN SMALL LETTER H + '\x69': u'\u0069', # LATIN SMALL LETTER I + '\x6A': u'\u006A', # LATIN SMALL LETTER J + '\x6B': u'\u006B', # LATIN SMALL LETTER K + '\x6C': u'\u006C', # LATIN SMALL LETTER L + '\x6D': u'\u006D', # LATIN SMALL LETTER M + '\x6E': u'\u006E', # LATIN SMALL LETTER N + '\x6F': u'\u006F', # LATIN SMALL LETTER O + '\x70': u'\u0070', # LATIN SMALL LETTER P + '\x71': u'\u0071', # LATIN SMALL LETTER Q + '\x72': u'\u0072', # LATIN SMALL LETTER R + '\x73': u'\u0073', # LATIN SMALL LETTER S + '\x74': u'\u0074', # LATIN SMALL LETTER T + '\x75': u'\u0075', # LATIN SMALL LETTER U + '\x76': u'\u0076', # LATIN SMALL LETTER V + '\x77': u'\u0077', # LATIN SMALL LETTER W + '\x78': u'\u0078', # LATIN SMALL LETTER X + '\x79': u'\u0079', # LATIN SMALL LETTER Y + '\x7A': u'\u007A', # LATIN SMALL LETTER Z + '\x7B': u'\u00E4', # LATIN SMALL LETTER A WITH DIAERESIS + '\x7C': u'\u00F6', # LATIN SMALL LETTER O WITH DIAERESIS + '\x7D': u'\u00F1', # LATIN SMALL LETTER N WITH TILDE + '\x7E': u'\u00FC', # LATIN SMALL LETTER U WITH DIAERESIS + '\x7F': u'\u00E0', # LATIN SMALL LETTER A WITH GRAVE +} + +# default GSM 03.38 escaped characters -> unicode +def_escape_decode_dict = { + '\x0A': u'\u000C', # FORM FEED + '\x14': u'\u005E', # CIRCUMFLEX ACCENT + '\x28': u'\u007B', # LEFT CURLY BRACKET + '\x29': u'\u007D', # RIGHT CURLY BRACKET + '\x2F': u'\u005C', # REVERSE SOLIDUS + '\x3C': u'\u005B', # LEFT SQUARE BRACKET + '\x3D': u'\u007E', # TILDE + '\x3E': u'\u005D', # RIGHT SQUARE BRACKET + '\x40': u'\u007C', # VERTICAL LINE + '\x65': u'\u20AC', # EURO SIGN +} + +# Replacement characters, default is question mark. Used when it is not too +# important to ensure exact UTF-8 -> GSM -> UTF-8 equivilence, such as when +# humans read and write SMS. But for USSD and other M2M applications it's +# important to ensure the conversion is exact. +def_replace_encode_dict = { + u'\u00E7': '\x09', # LATIN SMALL LETTER C WITH CEDILLA + + u'\u0391': '\x41', # GREEK CAPITAL LETTER ALPHA + u'\u0392': '\x42', # GREEK CAPITAL LETTER BETA + u'\u0395': '\x45', # GREEK CAPITAL LETTER EPSILON + u'\u0397': '\x48', # GREEK CAPITAL LETTER ETA + u'\u0399': '\x49', # GREEK CAPITAL LETTER IOTA + u'\u039A': '\x4B', # GREEK CAPITAL LETTER KAPPA + u'\u039C': '\x4D', # GREEK CAPITAL LETTER MU + u'\u039D': '\x4E', # GREEK CAPITAL LETTER NU + u'\u039F': '\x4F', # GREEK CAPITAL LETTER OMICRON + u'\u03A1': '\x50', # GREEK CAPITAL LETTER RHO + u'\u03A4': '\x54', # GREEK CAPITAL LETTER TAU + u'\u03A7': '\x58', # GREEK CAPITAL LETTER CHI + u'\u03A5': '\x59', # GREEK CAPITAL LETTER UPSILON + u'\u0396': '\x5A', # GREEK CAPITAL LETTER ZETA +} + +QUESTION_MARK = chr(0x3f) + +# unicode -> default GSM 03.38 +def_regular_encode_dict = \ + dict((u, g) for g, u in iter(def_regular_decode_dict.items())) + +# unicode -> default escaped GSM 03.38 characters +def_escape_encode_dict = \ + dict((u, g) for g, u in iter(def_escape_decode_dict.items())) + + +def encode(input_, errors='strict'): + """ + :type input_: unicode + + :return: string + """ + result = [] + for c in input_: + try: + result.append(def_regular_encode_dict[c]) + except KeyError: + if c in def_escape_encode_dict: + # OK, let's encode it as an escaped characters + result.append('\x1b') + result.append(def_escape_encode_dict[c]) + else: + if errors == 'strict': + raise UnicodeError("Invalid GSM character") + elif errors == 'replace': + result.append( + def_replace_encode_dict.get(c, QUESTION_MARK)) + elif errors == 'ignore': + pass + else: + raise UnicodeError("Unknown error handling") + + ret = ''.join(result) + return ret, len(ret) + + +def decode(input_, errors='strict'): + """ + :type input_: str + + :return: unicode + """ + result = [] + index = 0 + while index < len(input_): + c = input_[index] + index += 1 + if c == '\x1b': + if index < len(input_): + c = input_[index] + index += 1 + result.append(def_escape_decode_dict.get(c, u'\xa0')) + else: + result.append(u'\xa0') + else: + try: + result.append(def_regular_decode_dict[c]) + except KeyError: + # error handling: unassigned byte, must be > 0x7f + if errors == 'strict': + raise UnicodeError("Unrecognized GSM character") + elif errors == 'replace': + result.append('?') + elif errors == 'ignore': + pass + else: + raise UnicodeError("Unknown error handling") + + ret = u''.join(result) + return ret, len(ret) + + +# encodings module API +def getregentry(encoding): + if encoding == 'gsm0338': + return codecs.CodecInfo(name='gsm0338', + encode=encode, + decode=decode) + +# Codec registration +codecs.register(getregentry) + + +def is_gsm_text(text): + """Returns True if ``text`` can be encoded as gsm text""" + try: + text.encode("gsm0338") + except UnicodeError: + return False + except: + traceback.print_exc(file=sys.stdout) + return False + + return True diff --git a/messaging/sms/pdu.py b/messaging/sms/pdu.py new file mode 100644 index 0000000..9d680d5 --- /dev/null +++ b/messaging/sms/pdu.py @@ -0,0 +1,10 @@ +# see LICENSE + + +class Pdu(object): + + def __init__(self, pdu, len_smsc, cnt=1, seq=1): + self.pdu = pdu.upper() + self.length = len(pdu) / 2 - len_smsc + self.cnt = cnt + self.seq = seq diff --git a/messaging/sms/submit.py b/messaging/sms/submit.py new file mode 100644 index 0000000..f0e9941 --- /dev/null +++ b/messaging/sms/submit.py @@ -0,0 +1,330 @@ +# See LICENSE +"""Classes for sending SMS""" + +from datetime import datetime, timedelta +import re + +from messaging.sms import consts +from messaging.utils import (debug, encode_str, clean_number, + pack_8bits_to_ucs2, pack_8bits_to_7bits, + pack_8bits_to_8bit, + timedelta_to_relative_validity, + datetime_to_absolute_validity) +from messaging.sms.base import SmsBase +from messaging.sms.gsm0338 import is_gsm_text +from messaging.sms.pdu import Pdu + +VALID_NUMBER = re.compile("^\+?\d{3,20}$") + + +class SmsSubmit(SmsBase): + """I am a SMS ready to be sent""" + + def __init__(self, number, text): + super(SmsSubmit, self).__init__() + self._number = None + self._csca = None + self._klass = None + self._validity = None + self.request_status = False + self.ref = None + self.rand_id = None + self.id_list = range(0, 255) + self.msgvp = 0xaa + self.pid = 0x00 + + self.number = number + self.text = text + self.text_gsm = None + + def _set_number(self, number): + if number and not VALID_NUMBER.match(number): + raise ValueError("Invalid number format: %s" % number) + + self._number = number + + number = property(lambda self: self._number, _set_number) + + def _set_csca(self, csca): + if csca and not VALID_NUMBER.match(csca): + raise ValueError("Invalid csca format: %s" % csca) + + self._csca = csca + + csca = property(lambda self: self._csca, _set_csca) + + def _set_validity(self, validity): + if validity is None or isinstance(validity, (timedelta, datetime)): + # valid values are None, timedelta and datetime + self._validity = validity + else: + raise TypeError("Don't know what to do with %s" % validity) + + validity = property(lambda self: self._validity, _set_validity) + + def _set_klass(self, klass): + if not isinstance(klass, int): + raise TypeError("_set_klass only accepts int objects") + + if klass not in [0, 1, 2, 3]: + raise ValueError("class must be between 0 and 3") + + self._klass = klass + + klass = property(lambda self: self._klass, _set_klass) + + def to_pdu(self): + """Returns a list of :class:`~messaging.pdu.Pdu` objects""" + smsc_pdu = self._get_smsc_pdu() + sms_submit_pdu = self._get_sms_submit_pdu() + tpmessref_pdu = self._get_tpmessref_pdu() + sms_phone_pdu = self._get_phone_pdu() + tppid_pdu = self._get_tppid_pdu() + sms_msg_pdu = self._get_msg_pdu() + + if len(sms_msg_pdu) == 1: + pdu = smsc_pdu + len_smsc = len(smsc_pdu) / 2 + pdu += sms_submit_pdu + pdu += tpmessref_pdu + pdu += sms_phone_pdu + pdu += tppid_pdu + pdu += sms_msg_pdu[0] + debug("smsc_pdu: %s" % smsc_pdu) + debug("sms_submit_pdu: %s" % sms_submit_pdu) + debug("tpmessref_pdu: %s" % tpmessref_pdu) + debug("sms_phone_pdu: %s" % sms_phone_pdu) + debug("tppid_pdu: %s" % tppid_pdu) + debug("sms_msg_pdu: %s" % sms_msg_pdu) + debug("-" * 20) + debug("full_pdu: %s" % pdu) + debug("full_text: %s" % self.text) + debug("-" * 20) + return [Pdu(pdu, len_smsc)] + + # multipart SMS + sms_submit_pdu = self._get_sms_submit_pdu(udh=True) + pdu_list = [] + cnt = len(sms_msg_pdu) + for i, sms_msg_pdu_item in enumerate(sms_msg_pdu): + pdu = smsc_pdu + len_smsc = len(smsc_pdu) / 2 + pdu += sms_submit_pdu + pdu += tpmessref_pdu + pdu += sms_phone_pdu + pdu += tppid_pdu + pdu += sms_msg_pdu_item + debug("smsc_pdu: %s" % smsc_pdu) + debug("sms_submit_pdu: %s" % sms_submit_pdu) + debug("tpmessref_pdu: %s" % tpmessref_pdu) + debug("sms_phone_pdu: %s" % sms_phone_pdu) + debug("tppid_pdu: %s" % tppid_pdu) + debug("sms_msg_pdu: %s" % sms_msg_pdu_item) + debug("-" * 20) + debug("full_pdu: %s" % pdu) + debug("full_text: %s" % self.text) + debug("-" * 20) + + pdu_list.append(Pdu(pdu, len_smsc, cnt=cnt, seq=i + 1)) + + return pdu_list + + def _get_smsc_pdu(self): + if not self.csca or not self.csca.strip(): + return "00" + + number = clean_number(self.csca) + ptype = 0x81 # set to unknown number by default + if number[0] == '+': + number = number[1:] + ptype = 0x91 + + if len(number) % 2: + number += 'F' + + ps = chr(ptype) + for n in range(0, len(number), 2): + num = number[n + 1] + number[n] + ps += chr(int(num, 16)) + + pl = len(ps) + ps = chr(pl) + ps + + return encode_str(ps) + + def _get_tpmessref_pdu(self): + if self.ref is None: + self.ref = self._get_rand_id() + + self.ref &= 0xFF + return encode_str(chr(self.ref)) + + def _get_phone_pdu(self): + number = clean_number(self.number) + ptype = 0x81 + if number[0] == '+': + number = number[1:] + ptype = 0x91 + + pl = len(number) + if len(number) % 2: + number += 'F' + + ps = chr(ptype) + for n in range(0, len(number), 2): + num = number[n + 1] + number[n] + ps += chr(int(num, 16)) + + ps = chr(pl) + ps + return encode_str(ps) + + def _get_tppid_pdu(self): + return encode_str(chr(self.pid)) + + def _get_sms_submit_pdu(self, udh=False): + sms_submit = 0x1 + if self.validity is None: + # handle no validity + pass + elif isinstance(self.validity, datetime): + # handle absolute validity + sms_submit |= 0x18 + elif isinstance(self.validity, timedelta): + # handle relative validity + sms_submit |= 0x10 + + if self.request_status: + sms_submit |= 0x20 + + if udh: + sms_submit |= 0x40 + + return encode_str(chr(sms_submit)) + + def _get_msg_pdu(self): + # Data coding scheme + if self.fmt is None: + if is_gsm_text(self.text): + self.fmt = 0x00 + else: + self.fmt = 0x08 + + self.dcs = self.fmt + + if self.klass is not None: + if self.klass == 0: + self.dcs |= 0x10 + elif self.klass == 1: + self.dcs |= 0x11 + elif self.klass == 2: + self.dcs |= 0x12 + elif self.klass == 3: + self.dcs |= 0x13 + + dcs_pdu = encode_str(chr(self.dcs)) + + # Validity period + msgvp_pdu = "" + if self.validity is None: + # handle no validity + pass + + elif isinstance(self.validity, timedelta): + # handle relative + msgvp = timedelta_to_relative_validity(self.validity) + msgvp_pdu = encode_str(chr(msgvp)) + + elif isinstance(self.validity, datetime): + # handle absolute + msgvp = datetime_to_absolute_validity(self.validity) + msgvp_pdu = ''.join(map(encode_str, map(chr, msgvp))) + + # UDL + UD + message_pdu = "" + + if self.fmt == 0x00: + self.text_gsm = self.text.encode("gsm0338") + if len(self.text_gsm) <= consts.SEVENBIT_SIZE: + message_pdu = [pack_8bits_to_7bits(self.text_gsm)] + else: + message_pdu = self._split_sms_message(self.text_gsm) + elif self.fmt == 0x04: + if len(self.text) <= consts.EIGHTBIT_SIZE: + message_pdu = [pack_8bits_to_8bit(self.text)] + else: + message_pdu = self._split_sms_message(self.text) + elif self.fmt == 0x08: + if len(self.text) <= consts.UCS2_SIZE: + message_pdu = [pack_8bits_to_ucs2(self.text)] + else: + message_pdu = self._split_sms_message(self.text) + else: + raise ValueError("Unknown data coding scheme: %d" % self.fmt) + + ret = [] + for msg in message_pdu: + ret.append(dcs_pdu + msgvp_pdu + msg) + + return ret + + def _split_sms_message(self, text): + if self.fmt == 0x00: + len_without_udh = consts.SEVENBIT_MP_SIZE + limit = consts.SEVENBIT_SIZE + packing_func = pack_8bits_to_7bits + total_len = len(self.text_gsm) + + elif self.fmt == 0x04: + len_without_udh = consts.EIGHTBIT_MP_SIZE + limit = consts.EIGHTBIT_SIZE + packing_func = pack_8bits_to_8bit + total_len = len(self.text) + + elif self.fmt == 0x08: + len_without_udh = consts.UCS2_MP_SIZE + limit = consts.UCS2_SIZE + packing_func = pack_8bits_to_ucs2 + total_len = len(self.text) + + msgs = [] + pi, pe = 0, len_without_udh + + while pi < total_len: + if text[pi:pe][-1] == '\x1b': + pe -= 1 + + msgs.append(text[pi:pe]) + pi = pe + pe += len_without_udh + + pdu_msgs = [] + + udh_len = 0x05 + mid = 0x00 + data_len = 0x03 + + sms_ref = self._get_rand_id() if self.rand_id is None else self.rand_id + sms_ref &= 0xFF + + for i, msg in enumerate(msgs): + i += 1 + total_parts = len(msgs) + if limit == consts.SEVENBIT_SIZE: + udh = (chr(udh_len) + chr(mid) + chr(data_len) + + chr(sms_ref) + chr(total_parts) + chr(i)) + padding = " " + else: + udh = (unichr(int("%04x" % ((udh_len << 8) | mid), 16)) + + unichr(int("%04x" % ((data_len << 8) | sms_ref), 16)) + + unichr(int("%04x" % ((total_parts << 8) | i), 16))) + padding = "" + + pdu_msgs.append(packing_func(padding + msg, udh)) + + return pdu_msgs + + def _get_rand_id(self): + if not self.id_list: + self.id_list = range(0, 255) + + return list(self.id_list).pop(0) diff --git a/messaging/sms/udh.py b/messaging/sms/udh.py new file mode 100644 index 0000000..eecfa23 --- /dev/null +++ b/messaging/sms/udh.py @@ -0,0 +1,79 @@ +# See LICENSE + + +class PortAddress(object): + + def __init__(self, dest_port, orig_port, eight_bits): + self.dest_port = dest_port + self.orig_port = orig_port + self.eight_bits = eight_bits + + def __repr__(self): + args = (self.dest_port, self.orig_port) + return "" % args + + +class ConcatReference(object): + + def __init__(self, ref, cnt, seq, eight_bits): + self.ref = ref + self.cnt = cnt + self.seq = seq + self.eight_bits = eight_bits + + def __repr__(self): + args = (self.ref, self.cnt, self.seq) + return "" % args + + +class UserDataHeader(object): + + def __init__(self): + self.concat = None + self.ports = None + self.headers = {} + + def __repr__(self): + args = (self.headers, self.concat, self.ports) + return "" % args + + @classmethod + def from_status_report_ref(cls, ref): + udh = cls() + udh.concat = ConcatReference(ref, 0, 0, True) + return udh + + @classmethod + def from_bytes(cls, data): + udh = cls() + while len(data): + iei = data.pop(0) + ie_len = data.pop(0) + ie_data = data[:ie_len] + data = data[ie_len:] + udh.headers[iei] = ie_data + + if iei == 0x00: + # process SM concatenation 8bit ref. + ref, cnt, seq = ie_data + udh.concat = ConcatReference(ref, cnt, seq, True) + + if iei == 0x08: + # process SM concatenation 16bit ref. + ref = ie_data[0] << 8 | ie_data[1] + cnt = ie_data[2] + seq = ie_data[3] + udh.concat = ConcatReference(ref, cnt, seq, False) + + elif iei == 0x04: + # process App port addressing 8bit + dest_port, orig_port = ie_data + udh.ports = PortAddress(dest_port, orig_port, False) + + elif iei == 0x05: + # process App port addressing 16bit + dest_port = ie_data[0] << 8 | ie_data[1] + orig_port = ie_data[2] << 8 | ie_data[3] + udh.ports = PortAddress(dest_port, orig_port, False) + + return udh diff --git a/messaging/sms/wap.py b/messaging/sms/wap.py new file mode 100644 index 0000000..46611ab --- /dev/null +++ b/messaging/sms/wap.py @@ -0,0 +1,38 @@ +# See LICENSE + +from array import array + +from messaging.mms.mms_pdu import MMSDecoder + + +def is_a_wap_push_notification(s): + if not isinstance(s, str): + raise TypeError("data must be an array.array serialised to string") + + data = array("B", s) + + try: + return data[1] == 0x06 + except IndexError: + return False + + +def extract_push_notification(s): + data = array("B", s) + + wap_push, offset = data[1:3] + assert wap_push == 0x06 + + offset += 3 + data = data[offset:] + + # XXX: Not all WAP pushes are MMS + return MMSDecoder().decode_data(data) + + +def is_mms_notification(push): + # XXX: Pretty poor, but until we decode generic WAP pushes + # it will have to suffice. Ideally we would read the + # content-type from the WAP push header and test + return (push.headers.get('From') is not None and + push.headers.get('Content-Location') is not None) diff --git a/messaging/test/__init__.py b/messaging/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/messaging/test/mms-data/27d0a048cd79555de05283a22372b0eb.mms b/messaging/test/mms-data/27d0a048cd79555de05283a22372b0eb.mms new file mode 100644 index 0000000..574825b Binary files /dev/null and b/messaging/test/mms-data/27d0a048cd79555de05283a22372b0eb.mms differ diff --git a/messaging/test/mms-data/BTMMS.MMS b/messaging/test/mms-data/BTMMS.MMS new file mode 100644 index 0000000..478f505 Binary files /dev/null and b/messaging/test/mms-data/BTMMS.MMS differ diff --git a/messaging/test/mms-data/NOWMMS.MMS b/messaging/test/mms-data/NOWMMS.MMS new file mode 100644 index 0000000..51056ab Binary files /dev/null and b/messaging/test/mms-data/NOWMMS.MMS differ diff --git a/messaging/test/mms-data/SEC-SGHS300M.mms b/messaging/test/mms-data/SEC-SGHS300M.mms new file mode 100644 index 0000000..99f9feb Binary files /dev/null and b/messaging/test/mms-data/SEC-SGHS300M.mms differ diff --git a/messaging/test/mms-data/SIMPLE.MMS b/messaging/test/mms-data/SIMPLE.MMS new file mode 100644 index 0000000..11a0cc1 Binary files /dev/null and b/messaging/test/mms-data/SIMPLE.MMS differ diff --git a/messaging/test/mms-data/SonyEricssonT310-R201.mms b/messaging/test/mms-data/SonyEricssonT310-R201.mms new file mode 100644 index 0000000..6034994 Binary files /dev/null and b/messaging/test/mms-data/SonyEricssonT310-R201.mms differ diff --git a/messaging/test/mms-data/TOMSLOT.MMS b/messaging/test/mms-data/TOMSLOT.MMS new file mode 100644 index 0000000..0ef5898 Binary files /dev/null and b/messaging/test/mms-data/TOMSLOT.MMS differ diff --git a/messaging/test/mms-data/gallery2test.mms b/messaging/test/mms-data/gallery2test.mms new file mode 100644 index 0000000..8f53007 Binary files /dev/null and b/messaging/test/mms-data/gallery2test.mms differ diff --git a/messaging/test/mms-data/iPhone.mms b/messaging/test/mms-data/iPhone.mms new file mode 100644 index 0000000..a2f7019 Binary files /dev/null and b/messaging/test/mms-data/iPhone.mms differ diff --git a/messaging/test/mms-data/images_are_cut_off_debug.mms b/messaging/test/mms-data/images_are_cut_off_debug.mms new file mode 100644 index 0000000..dc6f500 Binary files /dev/null and b/messaging/test/mms-data/images_are_cut_off_debug.mms differ diff --git a/messaging/test/mms-data/m.mms b/messaging/test/mms-data/m.mms new file mode 100644 index 0000000..2b7fa27 Binary files /dev/null and b/messaging/test/mms-data/m.mms differ diff --git a/messaging/test/mms-data/openwave.mms b/messaging/test/mms-data/openwave.mms new file mode 100644 index 0000000..d99d4b6 Binary files /dev/null and b/messaging/test/mms-data/openwave.mms differ diff --git a/messaging/test/mms-data/projekt_exempel.mms b/messaging/test/mms-data/projekt_exempel.mms new file mode 100644 index 0000000..4b1f8fd Binary files /dev/null and b/messaging/test/mms-data/projekt_exempel.mms differ diff --git a/messaging/test/test_gsm_encoding.py b/messaging/test/test_gsm_encoding.py new file mode 100644 index 0000000..11c201c --- /dev/null +++ b/messaging/test/test_gsm_encoding.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +# Copyright (C) 2011 Sphere Systems Ltd +# Author: Andrew Bird +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +"""Unittests for the gsm encoding/decoding module""" + +import unittest +import messaging.sms.gsm0338 # imports GSM7 codec + +# Reversed from: ftp://ftp.unicode.org/Public/MAPPINGS/ETSI/GSM0338.TXT +MAP = { +# unichr(0x0000): (0x0000, 0x00), # Null + '@': (0x0040, 0x00), + '£': (0x00a3, 0x01), + '$': (0x0024, 0x02), + '¥': (0x00a5, 0x03), + 'è': (0x00e8, 0x04), + 'é': (0x00e9, 0x05), + 'ù': (0x00f9, 0x06), + 'ì': (0x00ec, 0x07), + 'ò': (0x00f2, 0x08), + 'Ç': (0x00c7, 0x09), # LATIN CAPITAL LETTER C WITH CEDILLA + chr(0x000a): (0x000a, 0x0a), # Linefeed + 'Ø': (0x00d8, 0x0b), + 'ø': (0x00f8, 0x0c), + chr(0x000d): (0x000d, 0x0d), # Carriage return + 'Å': (0x00c5, 0x0e), + 'å': (0x00e5, 0x0f), + 'Δ': (0x0394, 0x10), + '_': (0x005f, 0x11), + 'Φ': (0x03a6, 0x12), + 'Γ': (0x0393, 0x13), + 'Λ': (0x039b, 0x14), + 'Ω': (0x03a9, 0x15), + 'Π': (0x03a0, 0x16), + 'Ψ': (0x03a8, 0x17), + 'Σ': (0x03a3, 0x18), + 'Θ': (0x0398, 0x19), + 'Ξ': (0x039e, 0x1a), + chr(0x00a0): (0x00a0, 0x1b), # Escape to extension table (displayed + # as NBSP, on decode of invalid escape + # sequence) + 'Æ': (0x00c6, 0x1c), + 'æ': (0x00e6, 0x1d), + 'ß': (0x00df, 0x1e), + 'É': (0x00c9, 0x1f), + ' ': (0x0020, 0x20), + '!': (0x0021, 0x21), + '"': (0x0022, 0x22), + '#': (0x0023, 0x23), + '¤': (0x00a4, 0x24), + '%': (0x0025, 0x25), + '&': (0x0026, 0x26), + '\'': (0x0027, 0x27), + '{': (0x007b, 0x1b28), + '}': (0x007d, 0x1b29), + '*': (0x002a, 0x2a), + '+': (0x002b, 0x2b), + ',': (0x002c, 0x2c), + '-': (0x002d, 0x2d), + '.': (0x002e, 0x2e), + '\\': (0x005c, 0x1b2f), + '0': (0x0030, 0x30), + '1': (0x0031, 0x31), + '2': (0x0032, 0x32), + '3': (0x0033, 0x33), + '4': (0x0034, 0x34), + '5': (0x0035, 0x35), + '6': (0x0036, 0x36), + '7': (0x0037, 0x37), + '8': (0x0038, 0x38), + '9': (0x0039, 0x39), + ':': (0x003a, 0x3a), + ';': (0x003b, 0x3b), + '[': (0x005b, 0x1b3c), + chr(0x000c): (0x000c, 0x1b0a), # Formfeed + ']': (0x005d, 0x1b3e), + '?': (0x003f, 0x3f), + '|': (0x007c, 0x1b40), + 'A': (0x0041, 0x41), + 'B': (0x0042, 0x42), + 'C': (0x0043, 0x43), + 'D': (0x0044, 0x44), + 'E': (0x0045, 0x45), + 'F': (0x0046, 0x46), + 'G': (0x0047, 0x47), + 'H': (0x0048, 0x48), + 'I': (0x0049, 0x49), + 'J': (0x004a, 0x4a), + 'K': (0x004b, 0x4b), + 'L': (0x004c, 0x4c), + 'M': (0x004d, 0x4d), + 'N': (0x004e, 0x4e), + 'O': (0x004f, 0x4f), + 'P': (0x0050, 0x50), + 'Q': (0x0051, 0x51), + 'R': (0x0052, 0x52), + 'S': (0x0053, 0x53), + 'T': (0x0054, 0x54), + 'U': (0x0055, 0x55), + 'V': (0x0056, 0x56), + 'W': (0x0057, 0x57), + 'X': (0x0058, 0x58), + 'Y': (0x0059, 0x59), + 'Z': (0x005a, 0x5a), + 'Ä': (0x00c4, 0x5b), + 'Ö': (0x00d6, 0x5c), + 'Ñ': (0x00d1, 0x5d), + 'Ü': (0x00dc, 0x5e), + '§': (0x00a7, 0x5f), + '¿': (0x00bf, 0x60), + 'a': (0x0061, 0x61), + 'b': (0x0062, 0x62), + 'c': (0x0063, 0x63), + 'd': (0x0064, 0x64), + '€': (0x20ac, 0x1b65), + 'f': (0x0066, 0x66), + 'g': (0x0067, 0x67), + 'h': (0x0068, 0x68), + '<': (0x003c, 0x3c), + 'j': (0x006a, 0x6a), + 'k': (0x006b, 0x6b), + 'l': (0x006c, 0x6c), + 'm': (0x006d, 0x6d), + 'n': (0x006e, 0x6e), + '~': (0x007e, 0x1b3d), + 'p': (0x0070, 0x70), + 'q': (0x0071, 0x71), + 'r': (0x0072, 0x72), + 's': (0x0073, 0x73), + 't': (0x0074, 0x74), + '>': (0x003e, 0x3e), + 'v': (0x0076, 0x76), + 'i': (0x0069, 0x69), + 'x': (0x0078, 0x78), + '^': (0x005e, 0x1b14), + 'z': (0x007a, 0x7a), + 'ä': (0x00e4, 0x7b), + 'ö': (0x00f6, 0x7c), + 'ñ': (0x00f1, 0x7d), + 'ü': (0x00fc, 0x7e), + 'à': (0x00e0, 0x7f), + '¡': (0x00a1, 0x40), + '/': (0x002f, 0x2f), + 'o': (0x006f, 0x6f), + 'u': (0x0075, 0x75), + 'w': (0x0077, 0x77), + 'y': (0x0079, 0x79), + 'e': (0x0065, 0x65), + '=': (0x003d, 0x3d), + '(': (0x0028, 0x28), + ')': (0x0029, 0x29), +} + +GREEK_MAP = { # Note: these might look like Latin uppercase, but they aren't + 'Α': (0x0391, 0x41), + 'Β': (0x0392, 0x42), + 'Ε': (0x0395, 0x45), + 'Η': (0x0397, 0x48), + 'Ι': (0x0399, 0x49), + 'Κ': (0x039a, 0x4b), + 'Μ': (0x039c, 0x4d), + 'Ν': (0x039d, 0x4e), + 'Ο': (0x039f, 0x4f), + 'Ρ': (0x03a1, 0x50), + 'Τ': (0x03a4, 0x54), + 'Χ': (0x03a7, 0x58), + 'Υ': (0x03a5, 0x59), + 'Ζ': (0x0396, 0x5a), +} + +QUIRK_MAP = { + 'ç': (0x00e7, 0x09), +} + +BAD = -1 + + +class TestEncodingFunctions(unittest.TestCase): + + def test_encoding_supported_unicode_gsm(self): + + for key in list(MAP.keys()): + # Use 'ignore' so that we see the code tested, not an exception + s_gsm = key.encode('gsm0338', 'ignore') + + if len(s_gsm) == 1: + i_gsm = ord(s_gsm) + elif len(s_gsm) == 2: + i_gsm = (ord(s_gsm[0]) << 8) + ord(s_gsm[1]) + else: + i_gsm = BAD # so we see the comparison, not an exception + + # We shouldn't generate an invalid escape sequence + if key == chr(0x00a0): + self.assertEqual(BAD, i_gsm) + else: + self.assertEqual(MAP[key][1], i_gsm) + + def test_encoding_supported_greek_unicode_gsm(self): + # Note: Conversion is one way, hence no corresponding decode test + + for key in list(GREEK_MAP.keys()): + # Use 'replace' so that we trigger the mapping + s_gsm = key.encode('gsm0338', 'replace') + + if len(s_gsm) == 1: + i_gsm = ord(s_gsm) + else: + i_gsm = BAD # so we see the comparison, not an exception + + self.assertEqual(GREEK_MAP[key][1], i_gsm) + + def test_encoding_supported_quirk_unicode_gsm(self): + # Note: Conversion is one way, hence no corresponding decode test + + for key in list(QUIRK_MAP.keys()): + # Use 'replace' so that we trigger the mapping + s_gsm = key.encode('gsm0338', 'replace') + + if len(s_gsm) == 1: + i_gsm = ord(s_gsm) + else: + i_gsm = BAD # so we see the comparison, not an exception + + self.assertEqual(QUIRK_MAP[key][1], i_gsm) + + def test_decoding_supported_unicode_gsm(self): + for key in list(MAP.keys()): + i_gsm = MAP[key][1] + if i_gsm <= 0xff: + s_gsm = chr(i_gsm) + elif i_gsm <= 0xffff: + s_gsm = chr((i_gsm & 0xff00) >> 8) + s_gsm += chr(i_gsm & 0x00ff) + + s_unicode = s_gsm.decode('gsm0338', 'strict') + self.assertEqual(MAP[key][0], ord(s_unicode)) + + def test_is_gsm_text_true(self): + for key in list(MAP.keys()): + if key == chr(0x00a0): + continue + self.assertEqual(messaging.sms.gsm0338.is_gsm_text(key), True) + + def test_is_gsm_text_false(self): + self.assertEqual( + messaging.sms.gsm0338.is_gsm_text(chr(0x00a0)), False) + + for i in range(1, 0xffff + 1): + if chr(i) not in MAP: + # Note: it's a little odd, but on error we want to see values + if messaging.sms.gsm0338.is_gsm_text(chr(i)) is not False: + self.assertEqual(BAD, i) diff --git a/messaging/test/test_mms.py b/messaging/test/test_mms.py new file mode 100644 index 0000000..2cf6bf1 --- /dev/null +++ b/messaging/test/test_mms.py @@ -0,0 +1,378 @@ +# -*- coding: utf-8 -*- +from array import array +import datetime +import os +import unittest + +from messaging.mms.message import MMSMessage + +# test data extracted from heyman's +# http://github.com/heyman/mms-decoder +DATA_DIR = os.path.join(os.path.dirname(__file__), 'mms-data') + + +class TestMmsDecoding(unittest.TestCase): + + def test_decoding_from_data(self): + path = os.path.join(DATA_DIR, 'iPhone.mms') + data = array("B", open(path, 'rb').read()) + mms = MMSMessage.from_data(data) + headers = { + 'From': '', 'Transaction-Id': '1262957356-3', + 'MMS-Version': '1.2', 'To': '1337/TYPE=PLMN', + 'Message-Type': 'm-send-req', + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '0.smil', 'Type': 'application/smil'}), + } + self.assertEqual(mms.headers, headers) + + def test_decoding_iPhone_mms(self): + path = os.path.join(DATA_DIR, 'iPhone.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'From': '', 'Transaction-Id': '1262957356-3', + 'MMS-Version': '1.2', 'To': '1337/TYPE=PLMN', + 'Message-Type': 'm-send-req', + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '0.smil', 'Type': 'application/smil'}), + } + smil_data = '\n\n\n \n\n\n\n\n\n\n\n\n\n\n' + self.assertEqual(mms.headers, headers) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(len(mms.data_parts), 2) + self.assertEqual(mms.data_parts[0].content_type, 'application/smil') + self.assertEqual(mms.data_parts[0].data, smil_data) + self.assertEqual(mms.data_parts[1].content_type, 'image/jpeg') + self.assertEqual(mms.data_parts[1].content_type_parameters, + {'Name': 'IMG_6807.jpg'}) + + def test_decoding_SIMPLE_mms(self): + path = os.path.join(DATA_DIR, 'SIMPLE.MMS') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'Transaction-Id': '1234', 'MMS-Version': '1.0', + 'Message-Type': 'm-retrieve-conf', + 'Date': datetime.datetime(2002, 12, 20, 21, 26, 56), + 'Content-Type': ('application/vnd.wap.multipart.related', {}), + 'Subject': 'Simple message', + } + text_data = "This is a simple MMS message with a single text body part." + self.assertEqual(mms.headers, headers) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(len(mms.data_parts), 1) + self.assertEqual(mms.data_parts[0].content_type, 'text/plain') + self.assertEqual(mms.data_parts[0].data, text_data) + + def test_decoding_BTMMS_mms(self): + path = os.path.join(DATA_DIR, 'BTMMS.MMS') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'Transaction-Id': '1234', 'MMS-Version': '1.0', + 'Message-Type': 'm-retrieve-conf', + 'Date': datetime.datetime(2003, 1, 21, 1, 57, 4), + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'BT Ignite MMS', + } + smil_data = '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n' + text_data = 'BT Ignite\r\n\r\nMMS Services' + self.assertEqual(mms.headers, headers) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(len(mms.data_parts), 4) + self.assertEqual(mms.data_parts[0].content_type, 'application/smil') + self.assertEqual(mms.data_parts[0].data, smil_data) + self.assertEqual(mms.data_parts[1].content_type, 'image/gif') + self.assertEqual(mms.data_parts[2].content_type, 'audio/amr') + self.assertEqual(mms.data_parts[3].content_type, 'text/plain') + self.assertEqual(mms.data_parts[3].data, text_data) + + def test_decoding_TOMSLOT_mms(self): + path = os.path.join(DATA_DIR, 'TOMSLOT.MMS') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'From': '616c6c616e40746f6d736c6f742e636f6d'.decode('hex'), + 'Transaction-Id': '1234', + 'MMS-Version': '1.0', 'Message-Type': 'm-retrieve-conf', + 'Date': datetime.datetime(2003, 2, 16, 3, 48, 33), + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'Tom Slot Band', + } + smil_data = '\r\n\t\r\n\t\t\r\n\t\t\r\n\t\t\r\n\t\t\t\r\n\t\t\t\r\n\t\t\t\r\n\t\t\r\n\t\r\n\t\r\n\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\t\r\n\t\t\r\n\t\t\r\n\t\t\t\r\n\t\t\t\r\n\t\t\t\t\r\n\t\t\t\r\n\t\t\t\r\n\t\r\n\r\n' + text_data = 'Presented by NowMMS\r\n' + self.assertEqual(mms.headers, headers) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(len(mms.data_parts), 8) + self.assertEqual(mms.data_parts[0].content_type, 'application/smil') + self.assertEqual(mms.data_parts[0].data, smil_data) + self.assertEqual(mms.data_parts[1].content_type, 'image/jpeg') + self.assertEqual(mms.data_parts[2].content_type, 'image/jpeg') + self.assertEqual(mms.data_parts[3].content_type, 'image/jpeg') + self.assertEqual(mms.data_parts[4].content_type, 'image/jpeg') + self.assertEqual(mms.data_parts[5].content_type, 'image/jpeg') + self.assertEqual(mms.data_parts[6].content_type, 'text/plain') + self.assertEqual(mms.data_parts[6].data, text_data) + self.assertEqual(mms.data_parts[7].content_type, 'audio/amr') + + def test_decoding_images_are_cut_off_debug_mms(self): + path = os.path.join(DATA_DIR, 'images_are_cut_off_debug.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'From': '', 'Read-Reply': False, + 'Transaction-Id': '2112410527', 'MMS-Version': '1.0', + 'To': '7464707440616a616a672e63646d'.decode('hex'), + 'Delivery-Report': False, + 'Message-Type': 'm-send-req', + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'Picture3', + } + smil_data = '' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 2) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(mms.data_parts[0].content_type, 'image/jpeg') + self.assertEqual(mms.data_parts[0].content_type_parameters, + {'Name': 'Picture3.jpg'}) + self.assertEqual(mms.data_parts[1].content_type, 'application/smil') + self.assertEqual(mms.data_parts[1].data, smil_data) + + def test_decoding_openwave_mms(self): + path = os.path.join(DATA_DIR, 'openwave.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'From': '2b31363530353535303030302f545950453d504c4d4e'.decode('hex'), + 'Message-Class': 'Personal', + 'Transaction-Id': '1067263672', 'MMS-Version': '1.0', + 'Priority': 'Normal', 'To': '112/TYPE=PLMN', + 'Delivery-Report': False, 'Message-Type': 'm-send-req', + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'rubrik', + } + smil_data = '\n \n \n \n \n \n \n \n \n \n \n \n \n\n' + text_data = 'rubrik' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 2) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(mms.data_parts[0].content_type, 'application/smil') + self.assertEqual(mms.data_parts[0].data, smil_data) + self.assertEqual(mms.data_parts[1].data, text_data) + + def test_decoding_SonyEricssonT310_R201_mms(self): + path = os.path.join(DATA_DIR, 'SonyEricssonT310-R201.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'Sender-Visibility': 'Show', 'From': '', + 'Read-Reply': False, 'Message-Class': 'Personal', + 'Transaction-Id': '1-8db', 'MMS-Version': '1.0', + 'Priority': 'Normal', 'To': '55225/TYPE=PLMN', + 'Delivery-Report': False, 'Message-Type': 'm-send-req', + 'Date': datetime.datetime(2004, 3, 18, 7, 30, 34), + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + } + text_data = 'Hej hopp' + smil_data = '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 4) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(mms.data_parts[0].content_type, 'image/gif') + self.assertEqual(mms.data_parts[0].content_type_parameters, + {'Name': 'Tony.gif'}) + self.assertEqual(mms.data_parts[1].content_type, 'text/plain') + self.assertEqual(mms.data_parts[1].data, text_data) + self.assertEqual(mms.data_parts[2].content_type, 'audio/midi') + self.assertEqual(mms.data_parts[2].content_type_parameters, + {'Name': 'OldhPhone.mid'}) + self.assertEqual(mms.data_parts[3].content_type, 'application/smil') + self.assertEqual(mms.data_parts[3].data, smil_data) + + def test_decoding_gallery2test_mms(self): + path = os.path.join(DATA_DIR, 'gallery2test.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'From': '2b31363530353535303030302f545950453d504c4d4e'.decode('hex'), + 'Message-Class': 'Personal', + 'Transaction-Id': '1118775337', 'MMS-Version': '1.0', + 'Priority': 'Normal', 'To': 'Jg', 'Delivery-Report': False, + 'Message-Type': 'm-send-req', + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'Jgj', + } + text_data = 'Jgj' + smil_data = '\n \n \n \n \n \n \n \n \n \n gnu-head\n \n \n \n\n' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 3) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(mms.data_parts[0].content_type, 'application/smil') + self.assertEqual(mms.data_parts[0].data, smil_data) + self.assertEqual(mms.data_parts[1].content_type, 'text/plain') + self.assertEqual(mms.data_parts[1].data, text_data) + self.assertEqual(mms.data_parts[2].content_type, 'image/jpeg') + # XXX: Shouldn't it be 'Name' instead ? + self.assertEqual(mms.data_parts[2].content_type_parameters, + {'name': 'gnu-head.jpg'}) + + def test_decoding_projekt_exempel_mms(self): + path = os.path.join(DATA_DIR, 'projekt_exempel.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'Sender-Visibility': 'Show', 'From': '', + 'Read-Reply': False, 'Message-Class': 'Personal', + 'Transaction-Id': '4-fc60', 'MMS-Version': '1.0', + 'Priority': 'Normal', 'To': '12345/TYPE=PLMN', + 'Delivery-Report': False, 'Message-Type': 'm-send-req', + 'Date': datetime.datetime(2004, 5, 23, 15, 13, 40), + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'Hej', + } + smil_data = '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n' + text_data = 'Jonatan \xc3\xa4r en GNU' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 3) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(mms.data_parts[0].content_type, 'text/plain') + self.assertEqual(mms.data_parts[0].data, text_data) + self.assertEqual(mms.data_parts[1].content_type, 'image/gif') + self.assertEqual(mms.data_parts[2].content_type, 'application/smil') + self.assertEqual(mms.data_parts[2].data, smil_data) + self.assertEqual(mms.data_parts[2].content_type_parameters, + {'Charset': 'utf-8', 'Name': 'mms.smil'}) + + def test_decoding_m_mms(self): + path = os.path.join(DATA_DIR, 'm.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'From': '676f6c64706f737440686f746d61696c2e636f6d'.decode('hex'), + 'Transaction-Id': '0000000001', + 'MMS-Version': '1.0', 'Message-Type': 'm-retrieve-conf', + 'Date': datetime.datetime(2002, 8, 9, 13, 8, 2), + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'GOLD', + } + text_data1 = 'Audio' + text_data2 = 'Text +' + text_data3 = 'tagtag.com/gold\r\n' + text_data4 = 'globalisierunglobalisierunglobalisierunglobalisierunglobalisierunglobalisierunglobalisierungnureisilabolg' + text_data5 = 'KLONE\r\nKLONE\r\n' + text_data6 = 'pr\xe4sentiert..' + text_data7 = 'GOLD' + smil_data = '\r\n\r\n\r\n\r\n\r\n\r\n\r\n \r\n \r\n\r\n\r\n\r\n\r\n \r\n \r\n\r\n\r\n\r\n\r\n \r\n \r\n\r\n\r\n\r\n\r\n \r\n \r\n\r\n\r\n\r\n\r\n\r\n \r\n \r\n\r\n\r\n\r\n\r\n \r\n \r\n\r\n\r\n\r\n\r\n\r\n \r\n \r\n\r\n\r\n' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 9) + self.assertEqual(mms.content_type, 'application/vnd.wap.multipart.related') + self.assertEqual(mms.data_parts[0].content_type, 'text/plain') + self.assertEqual(mms.data_parts[0].data, text_data1) + self.assertEqual(mms.data_parts[0].content_type_parameters, + {'Charset': 'us-ascii'}) + self.assertEqual(mms.data_parts[1].content_type, 'application/smil') + self.assertEqual(mms.data_parts[1].data, smil_data) + self.assertEqual(mms.data_parts[1].content_type_parameters, + {'Charset': 'us-ascii'}) + self.assertEqual(mms.data_parts[2].content_type, 'text/plain') + self.assertEqual(mms.data_parts[2].data, text_data2) + self.assertEqual(mms.data_parts[2].content_type_parameters, + {'Charset': 'us-ascii'}) + self.assertEqual(mms.data_parts[3].content_type, 'text/plain') + self.assertEqual(mms.data_parts[3].data, text_data3) + self.assertEqual(mms.data_parts[3].content_type_parameters, + {'Charset': 'us-ascii'}) + self.assertEqual(mms.data_parts[4].content_type, 'audio/amr') + self.assertEqual(mms.data_parts[5].content_type, 'text/plain') + self.assertEqual(mms.data_parts[5].data, text_data4) + self.assertEqual(mms.data_parts[5].content_type_parameters, + {'Charset': 'us-ascii'}) + self.assertEqual(mms.data_parts[6].content_type, 'text/plain') + self.assertEqual(mms.data_parts[6].data, text_data5) + self.assertEqual(mms.data_parts[6].content_type_parameters, + {'Charset': 'us-ascii'}) + self.assertEqual(mms.data_parts[7].content_type, 'text/plain') + self.assertEqual(mms.data_parts[7].data, text_data6) + self.assertEqual(mms.data_parts[7].content_type_parameters, + {'Charset': 'us-ascii'}) + self.assertEqual(mms.data_parts[8].content_type, 'text/plain') + self.assertEqual(mms.data_parts[8].data, text_data7) + self.assertEqual(mms.data_parts[8].content_type_parameters, + {'Charset': 'us-ascii'}) + + def test_decoding_27d0a048cd79555de05283a22372b0eb_mms(self): + path = os.path.join(DATA_DIR, '27d0a048cd79555de05283a22372b0eb.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'Sender-Visibility': 'Show', 'From': '', + 'Read-Reply': False, 'Message-Class': 'Personal', + 'Transaction-Id': '3-31cb', 'MMS-Version': '1.0', + 'Priority': 'Normal', 'To': '123/TYPE=PLMN', + 'Delivery-Report': False, 'Message-Type': 'm-send-req', + 'Date': datetime.datetime(2004, 5, 23, 14, 14, 58), + 'Content-Type': ('application/vnd.wap.multipart.related', {'Start': '', 'Type': 'application/smil'}), + 'Subject': 'Angående art-tillhörighet', + #'Subject': 'Ang\xc3\xa5ende art-tillh\xc3\xb6righet', + } + smil_data = '\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n' + text_data = 'Jonatan \xc3\xa4r en gnu.' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 3) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.related') + self.assertEqual(mms.data_parts[0].content_type, 'image/vnd.wap.wbmp') + self.assertEqual(mms.data_parts[0].content_type_parameters, + {'Name': 'Rain.wbmp'}) + self.assertEqual(mms.data_parts[1].content_type, 'text/plain') + self.assertEqual(mms.data_parts[1].data, text_data) + self.assertEqual(mms.data_parts[1].content_type_parameters, + {'Charset': 'utf-8', 'Name': 'mms.txt'}) + self.assertEqual(mms.data_parts[2].content_type, 'application/smil') + self.assertEqual(mms.data_parts[2].data, smil_data) + self.assertEqual(mms.data_parts[2].content_type_parameters, + {'Charset': 'utf-8', 'Name': 'mms.smil'}) + + def test_decoding_SEC_SGHS300M(self): + path = os.path.join(DATA_DIR, 'SEC-SGHS300M.mms') + mms = MMSMessage.from_file(path) + self.assertTrue(isinstance(mms, MMSMessage)) + headers = { + 'Sender-Visibility': 'Show', 'From': '', + 'Read-Reply': False, 'Message-Class': 'Personal', + 'Transaction-Id': '31887', 'MMS-Version': '1.0', + 'To': '303733383334353636342f545950453d504c4d4e'.decode('hex'), + 'Delivery-Report': False, + 'Message-Type': 'm-send-req', 'Subject': 'IL', + 'Content-Type': ('application/vnd.wap.multipart.mixed', {}), + } + text_data = 'HV' + self.assertEqual(mms.headers, headers) + self.assertEqual(len(mms.data_parts), 1) + self.assertEqual(mms.content_type, + 'application/vnd.wap.multipart.mixed') + self.assertEqual(mms.data_parts[0].content_type, 'text/plain') + self.assertEqual(mms.data_parts[0].data, text_data) + self.assertEqual(mms.data_parts[0].content_type_parameters, + {'Charset': 'utf-8'}) + + def test_encoding_m_sendnotifyresp_ind(self): + message = MMSMessage() + message.headers['Transaction-Id'] = 'NOK5AIdhfTMYSG4JeIgAAsHtp72AGAAAAAAAA' + message.headers['Message-Type'] = 'm-notifyresp-ind' + message.headers['Status'] = 'Retrieved' + data = [ + 140, 131, 152, 78, 79, 75, 53, 65, 73, 100, 104, 102, 84, 77, + 89, 83, 71, 52, 74, 101, 73, 103, 65, 65, 115, 72, 116, 112, + 55, 50, 65, 71, 65, 65, 65, 65, 65, 65, 65, 65, 0, 141, 144, + 149, 129, 132, 163, 1, 35, 129] + + self.assertEqual(list(message.encode()[:50]), data) diff --git a/messaging/test/test_sms.py b/messaging/test/test_sms.py new file mode 100644 index 0000000..c3ff7b5 --- /dev/null +++ b/messaging/test/test_sms.py @@ -0,0 +1,481 @@ +# -*- coding: utf-8 -*- +from datetime import datetime, timedelta + +try: + import unittest2 as unittest +except ImportError: + import unittest + +from messaging.sms import SmsSubmit, SmsDeliver +from messaging.utils import (timedelta_to_relative_validity as to_relative, + datetime_to_absolute_validity as to_absolute, + FixedOffset) + + +class TestEncodingFunctions(unittest.TestCase): + + def test_converting_timedelta_to_validity(self): + self.assertRaises(ValueError, to_relative, timedelta(minutes=4)) + self.assertRaises(ValueError, to_relative, timedelta(weeks=64)) + + self.assertTrue(isinstance(to_relative(timedelta(hours=6)), int)) + self.assertTrue(isinstance(to_relative(timedelta(hours=18)), int)) + self.assertTrue(isinstance(to_relative(timedelta(days=15)), int)) + self.assertTrue(isinstance(to_relative(timedelta(weeks=31)), int)) + + self.assertEqual(to_relative(timedelta(minutes=5)), 0) + self.assertEqual(to_relative(timedelta(minutes=6)), 0) + self.assertEqual(to_relative(timedelta(minutes=10)), 1) + + self.assertEqual(to_relative(timedelta(hours=12)), 143) + self.assertEqual(to_relative(timedelta(hours=13)), 145) + self.assertEqual(to_relative(timedelta(hours=24)), 167) + + self.assertEqual(to_relative(timedelta(days=2)), 168) + self.assertEqual(to_relative(timedelta(days=30)), 196) + + def test_converting_datetime_to_validity(self): + # http://www.dreamfabric.com/sms/scts.html + # 12. Feb 1999 05:57:30 GMT+3 + when = datetime(1999, 2, 12, 5, 57, 30, 0, + FixedOffset(3 * 60, "GMT+3")) + expected = [0x99, 0x20, 0x21, 0x50, 0x75, 0x03, 0x21] + self.assertEqual(to_absolute(when, "GMT+3"), expected) + + when = datetime(1999, 2, 12, 5, 57, 30, 0) + expected = [0x99, 0x20, 0x21, 0x50, 0x75, 0x03, 0x0] + self.assertEqual(to_absolute(when, "UTC"), expected) + + when = datetime(1999, 2, 12, 5, 57, 30, 0, + FixedOffset(-3 * 60, "GMT-3")) + expected = [0x99, 0x20, 0x21, 0x50, 0x75, 0x03, 0x29] + self.assertEqual(to_absolute(when, "GMT-3"), expected) + + +class TestSmsSubmit(unittest.TestCase): + + def test_encoding_validity(self): + # no validity + number = '2b3334363136353835313139'.decode('hex') + text = "hola" + expected = "0001000B914316565811F9000004E8373B0C" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + # absolute validity + number = '2b3334363136353835313139'.decode('hex') + text = "hola" + expected = "0019000B914316565811F900000170520251930004E8373B0C" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.validity = datetime(2010, 7, 25, 20, 15, 39) + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + # relative validity + number = '2b3334363136353835313139'.decode('hex') + text = "hola" + expected = "0011000B914316565811F90000AA04E8373B0C" + expected_len = 18 + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.validity = timedelta(days=4) + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + self.assertEqual(pdu.length, expected_len) + + def test_encoding_csca(self): + number = '2b3334363136353835313139'.decode('hex') + text = "hola" + csca = "+34646456456" + expected = "07914346466554F601000B914316565811F9000004E8373B0C" + expected_len = 17 + + sms = SmsSubmit(number, text) + sms.csca = csca + sms.ref = 0x0 + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + self.assertEqual(pdu.length, expected_len) + self.assertEqual(pdu.cnt, 1) + self.assertEqual(pdu.seq, 1) + + def test_encoding_class(self): + number = '2b3334363534313233343536'.decode('hex') + text = "hey yo" + expected_0 = "0001000B914356143254F6001006E8721E947F03" + expected_1 = "0001000B914356143254F6001106E8721E947F03" + expected_2 = "0001000B914356143254F6001206E8721E947F03" + expected_3 = "0001000B914356143254F6001306E8721E947F03" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.klass = 0 + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected_0) + + sms.klass = 1 + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected_1) + + sms.klass = 2 + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected_2) + + sms.klass = 3 + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected_3) + + def test_encoding_request_status(self): + # tested with pduspy.exe and http://www.rednaxela.net/pdu.php + number = '2b3334363534313233343536'.decode('hex') + text = "hey yo" + expected = "0021000B914356143254F6000006E8721E947F03" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.request_status = True + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + def test_encoding_message_with_latin1_chars(self): + # tested with pduspy.exe + number = '2b3334363534313233343536'.decode('hex') + text = u"Hölä" + expected = "0011000B914356143254F60000AA04483E7B0F" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.validity = timedelta(days=4) + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + # tested with pduspy.exe + number = '2b3334363534313233343536'.decode('hex') + text = u"BÄRÇA äñ@" + expected = "0001000B914356143254F6000009C2AD341104EDFB00" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + def test_encoding_8bit_message(self): + number = "01000000000" + csca = "+44000000000" + text = "Hi there..." + expected = "07914400000000F001000B811000000000F000040B48692074686572652E2E2E" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.csca = csca + sms.fmt = 0x04 # 8 bits + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + def test_encoding_ucs2_message(self): + number = '2b3334363136353835313139'.decode('hex') + text = u'あ叶葉' + csca = '+34646456456' + expected = "07914346466554F601000B914316565811F9000806304253F68449" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.csca = csca + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + text = u"Русский" + number = '363535333435363738'.decode('hex') + expected = "001100098156355476F80008AA0E0420044304410441043A04380439" + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.validity = timedelta(days=4) + + pdu = sms.to_pdu()[0] + self.assertEqual(pdu.pdu, expected) + + def test_encoding_multipart_7bit(self): + # text encoded with umts-tools + text = "Or walk with Kings - nor lose the common touch, if neither foes nor loving friends can hurt you, If all men count with you, but none too much; If you can fill the unforgiving minute With sixty seconds' worth of distance run, Yours is the Earth and everything thats in it, And - which is more - you will be a Man, my son" + number = '363535333435363738'.decode('hex') + expected = [ + "005100098156355476F80000AAA00500038803019E72D03DCC5E83EE693A1AB44CBBCF73500BE47ECB41ECF7BC0CA2A3CBA0F1BBDD7EBB41F4777D8C6681D26690BB9CA6A3CB7290F95D9E83DC6F3988FDB6A7DD6790599E2EBBC973D038EC06A1EB723A28FFAEB340493328CC6683DA653768FCAEBBE9A07B9A8E06E5DF7516485CA783DC6F7719447FBF41EDFA18BD0325CDA0FCBB0E1A87DD", + "005100098156355476F80000AAA005000388030240E6349B0DA2A3CBA0BADBFC969FD3F6B4FB0C6AA7DD757A19744DD3D1A0791A4FCF83E6E5F1DB4D9E9F40F7B79C8E06BDCD20727A4E0FBBC76590BCEE6681B2EFBA7C0E4ACF41747419540CCBE96850D84D0695ED65799E8E4EBBCF203A3A4C9F83D26E509ACE0205DD64500B7447A7C768507A0E6ABFE565500B947FD741F7349B0D129741", + "005100098156355476F80000AA14050003880303C2A066D8CD02B5F3A0F9DB0D", + ] + + sms = SmsSubmit(number, text) + sms.ref = 0x0 + sms.rand_id = 136 + sms.validity = timedelta(days=4) + + ret = sms.to_pdu() + cnt = len(ret) + for i, pdu in enumerate(ret): + self.assertEqual(pdu.pdu, expected[i]) + self.assertEqual(pdu.seq, i + 1) + self.assertEqual(pdu.cnt, cnt) + + def test_encoding_bad_number_raises_error(self): + self.assertRaises(ValueError, SmsSubmit, "032BADNUMBER", "text") + + def test_encoding_bad_csca_raises_error(self): + sms = SmsSubmit("54342342", "text") + self.assertRaises(ValueError, setattr, sms, 'csca', "1badcsca") + + +class TestSubmitPduCounts(unittest.TestCase): + + DEST = "+3530000000" + GSM_CHAR = "x" + EGSM_CHAR = u"€" + UNICODE_CHAR = u"ő" + + def test_gsm_1(self): + sms = SmsSubmit(self.DEST, self.GSM_CHAR * 160) + self.assertEqual(len(sms.to_pdu()), 1) + + def test_gsm_2(self): + sms = SmsSubmit(self.DEST, self.GSM_CHAR * 161) + self.assertEqual(len(sms.to_pdu()), 2) + + def test_gsm_3(self): + sms = SmsSubmit(self.DEST, self.GSM_CHAR * 153 * 2) + self.assertEqual(len(sms.to_pdu()), 2) + + def test_gsm_4(self): + sms = SmsSubmit(self.DEST, + self.GSM_CHAR * 153 * 2 + self.GSM_CHAR) + self.assertEqual(len(sms.to_pdu()), 3) + + def test_gsm_5(self): + sms = SmsSubmit(self.DEST, self.GSM_CHAR * 153 * 3) + self.assertEqual(len(sms.to_pdu()), 3) + + def test_gsm_6(self): + sms = SmsSubmit(self.DEST, + self.GSM_CHAR * 153 * 3 + self.GSM_CHAR) + self.assertEqual(len(sms.to_pdu()), 4) + + def test_egsm_1(self): + sms = SmsSubmit(self.DEST, self.EGSM_CHAR * 80) + self.assertEqual(len(sms.to_pdu()), 1) + + def test_egsm_2(self): + sms = SmsSubmit(self.DEST, + self.EGSM_CHAR * 79 + self.GSM_CHAR) + self.assertEqual(len(sms.to_pdu()), 1) + + def test_egsm_3(self): + sms = SmsSubmit(self.DEST, self.EGSM_CHAR * 153) # 306 septets + self.assertEqual(len(sms.to_pdu()), 3) + + def test_egsm_4(self): + sms = SmsSubmit(self.DEST, + self.EGSM_CHAR * 229 + self.GSM_CHAR) # 459 septets + self.assertEqual(len(sms.to_pdu()), 4) + + def test_unicode_1(self): + sms = SmsSubmit(self.DEST, self.UNICODE_CHAR * 70) + self.assertEqual(len(sms.to_pdu()), 1) + + def test_unicode_2(self): + sms = SmsSubmit(self.DEST, self.UNICODE_CHAR * 70 + self.GSM_CHAR) + self.assertEqual(len(sms.to_pdu()), 2) + + def test_unicode_3(self): + sms = SmsSubmit(self.DEST, self.UNICODE_CHAR * 67 * 2) + self.assertEqual(len(sms.to_pdu()), 2) + + def test_unicode_4(self): + sms = SmsSubmit(self.DEST, self.UNICODE_CHAR * 67 * 2 + self.GSM_CHAR) + self.assertEqual(len(sms.to_pdu()), 3) + + def test_unicode_5(self): + sms = SmsSubmit(self.DEST, self.UNICODE_CHAR * 67 * 3) + self.assertEqual(len(sms.to_pdu()), 3) + + def test_unicode_6(self): + sms = SmsSubmit(self.DEST, self.UNICODE_CHAR * 67 * 3 + self.GSM_CHAR) + self.assertEqual(len(sms.to_pdu()), 4) + + +class TestSmsDeliver(unittest.TestCase): + + def test_decoding_7bit_pdu(self): + pdu = "07911326040000F0040B911346610089F60000208062917314080CC8F71D14969741F977FD07" + text = "How are you?" + csca = "+31624000000" + number = '2b3331363431363030393836'.decode('hex') + + sms = SmsDeliver(pdu) + self.assertEqual(sms.text, text) + self.assertEqual(sms.csca, csca) + self.assertEqual(sms.number, number) + + def test_decoding_ucs2_pdu(self): + pdu = "07914306073011F0040B914316709807F2000880604290224080084E2D5174901A8BAF" + text = u"中兴通讯" + csca = "+34607003110" + number = '2b3334363130373839373032'.decode('hex') + + sms = SmsDeliver(pdu) + self.assertEqual(sms.text, text) + self.assertEqual(sms.csca, csca) + self.assertEqual(sms.number, number) + + def test_decoding_7bit_pdu_data(self): + pdu = "07911326040000F0040B911346610089F60000208062917314080CC8F71D14969741F977FD07" + text = "How are you?" + csca = "+31624000000" + number = '2b3331363431363030393836'.decode('hex') + + data = SmsDeliver(pdu).data + self.assertEqual(data['text'], text) + self.assertEqual(data['csca'], csca) + self.assertEqual(data['number'], number) + self.assertEqual(data['pid'], 0) + self.assertEqual(data['fmt'], 0) + self.assertEqual(data['date'], datetime(2002, 8, 26, 19, 37, 41)) + + def test_decoding_datetime_gmtplusone(self): + pdu = "0791447758100650040C914497716247010000909010711423400A2050EC468B81C4733A" + text = " 1741 bst" + number = '2b343437393137323637343130'.decode('hex') + date = datetime(2009, 9, 1, 16, 41, 32) + + sms = SmsDeliver(pdu) + self.assertEqual(sms.text, text) + self.assertEqual(sms.number, number) + self.assertEqual(sms.date, date) + + def test_decoding_datetime_gmtminusthree(self): + pdu = "0791553001000001040491578800000190115101112979CF340B342F9FEBE536E83D0791C3E4F71C440E83E6F53068FE66A7C7697A781C7EBB4050F99BFE1EBFD96F1D48068BC16030182E66ABD560B41988FC06D1D3F03768FA66A7C7697A781C7E83CCEF34282C2ECBE96F50B90D8AC55EB0DC4B068BC140B1994E16D3D1622E" + date = datetime(2010, 9, 11, 18, 10, 11) # 11/09/10 15:10 GMT-3.00 + + sms = SmsDeliver(pdu) + self.assertEqual(sms.date, date) + + def test_decoding_number_alphanumeric(self): + # Odd length test + pdu = "07919471060040340409D0C6A733390400009060920173018093CC74595C96838C4F6772085AD6DDE4320B444E9741D4B03C6D7EC3E9E9B71B9474D3CB727799DEA286CFE5B9991DA6CBC3F432E85E9793CBA0F09A9EB6A7CB72BA0B9474D3CB727799DE72D6E9FABAFB0CBAA7E56490BA4CD7D34170F91BE4ACD3F575F7794E0F9F4161F1B92C2F8FD1EE32DD054AA2E520E3D3991C82A8E5701B" + number = "FONIC" + text = "Lieber FONIC Kunde, die Tarifoption Internet-Tagesflatrate wurde aktiviert. Internet-Nutzung wird jetzt pro Nutzungstag abgerechnet. Ihr FONIC Team" + csca = "+491760000443" + + sms = SmsDeliver(pdu) + self.assertEqual(sms.text, text) + self.assertEqual(sms.csca, csca) + self.assertEqual(sms.number, number) + + # Even length test + pdu = "07919333852804000412D0F7FBDD454FB75D693A0000903002801153402BCD301E9F0605D9E971191483C140412A35690D52832063D2F9040599A058EE05A3BD6430580E" + number = "www.tim.it" + text = 'Maxxi Alice 100 ATTIVATA FINO AL 19/04/2009' + csca = '+393358824000' + + sms = SmsDeliver(pdu) + self.assertEqual(sms.text, text) + self.assertEqual(sms.csca, csca) + self.assertEqual(sms.number, number) + + def test_decode_sms_confirmation(self): + pdu = "07914306073011F006270B913426565711F7012081111345400120811174054043" + csca = "+34607003110" + date = datetime(2010, 2, 18, 11, 31, 54) + number = "SR-UNKNOWN" + # XXX: the number should be +344626575117, is the prefix flipped ? + text = "+43626575117|10/02/18 11:31:54|" + + sms = SmsDeliver(pdu) + self.assertEqual(sms.text, text) + self.assertEqual(sms.csca, csca) + self.assertEqual(sms.number, number) + self.assertEqual(sms.date, date) + + def test_decode_weird_multipart_german_pdu(self): + pdus = [ + "07919471227210244405852122F039F101506271217180A005000319020198E9B2B82C0759DFE4B0F9ED2EB7967537B9CC02B5D37450122D2FCB41EE303DFD7687D96537881A96A7CD6F383DFD7683F46134BBEC064DD36550DA0D22A7CBF3721BE42CD3F5A0198B56036DCA20B8FC0D6A0A4170767D0EAAE540433A082E7F83A6E5F93CFD76BB40D7B2DB0D9AA6CB2072BA3C2F83926EF31BE44E8FD17450BB8C9683CA", + "07919471227210244405852122F039F1015062712181804F050003190202E4E8309B5E7683DAFC319A5E76B340F73D9A5D7683A6E93268FD9ED3CB6EF67B0E5AD172B19B2C2693C9602E90355D6683A6F0B007946E8382F5393BEC26BB00", + ] + texts = [ + u"Lieber Vodafone-Kunde, mit Ihrer nationalen Tarifoption zahlen Sie in diesem Netz 3,45 € pro MB plus 59 Ct pro Session. Wenn Sie diese Info nicht mehr e", + u"rhalten möchten, wählen Sie kostenlos +4917212220. Viel Spaß im Ausland.", + ] + + for i, sms in enumerate(map(SmsDeliver, pdus)): + self.assertEqual(sms.text, texts[i]) + self.assertEqual(sms.udh.concat.cnt, len(pdus)) + self.assertEqual(sms.udh.concat.seq, i + 1) + self.assertEqual(sms.udh.concat.ref, 25) + + def test_decoding_odd_length_pdu_strict_raises_valueerror(self): + # same pdu as in test_decoding_number_alpha1 minus last char + pdu = "07919471060040340409D0C6A733390400009060920173018093CC74595C96838C4F6772085AD6DDE4320B444E9741D4B03C6D7EC3E9E9B71B9474D3CB727799DEA286CFE5B9991DA6CBC3F432E85E9793CBA0F09A9EB6A7CB72BA0B9474D3CB727799DE72D6E9FABAFB0CBAA7E56490BA4CD7D34170F91BE4ACD3F575F7794E0F9F4161F1B92C2F8FD1EE32DD054AA2E520E3D3991C82A8E5701" + self.assertRaises(ValueError, SmsDeliver, pdu) + + def test_decoding_odd_length_pdu_no_strict(self): + # same pdu as in test_decoding_number_alpha1 minus last char + pdu = "07919471060040340409D0C6A733390400009060920173018093CC74595C96838C4F6772085AD6DDE4320B444E9741D4B03C6D7EC3E9E9B71B9474D3CB727799DEA286CFE5B9991DA6CBC3F432E85E9793CBA0F09A9EB6A7CB72BA0B9474D3CB727799DE72D6E9FABAFB0CBAA7E56490BA4CD7D34170F91BE4ACD3F575F7794E0F9F4161F1B92C2F8FD1EE32DD054AA2E520E3D3991C82A8E5701" + text = "Lieber FONIC Kunde, die Tarifoption Internet-Tagesflatrate wurde aktiviert. Internet-Nutzung wird jetzt pro Nutzungstag abgerechnet. Ihr FONIC Tea" + + sms = SmsDeliver(pdu, strict=False) + self.assertEqual(sms.text, text) + + def test_decoding_delivery_status_report(self): + pdu = "0791538375000075061805810531F1019082416500400190824165004000" + sr = { + 'status': 0, + 'scts': datetime(2010, 9, 28, 14, 56), + 'dt': datetime(2010, 9, 28, 14, 56), + 'recipient': '50131' + } + + sms = SmsDeliver(pdu) + self.assertEqual(sms.csca, "+353857000057") + data = sms.data + self.assertEqual(data['ref'], 24) + self.assertEqual(sms.sr, sr) + + def test_decoding_delivery_status_report_without_smsc_address(self): + pdu = "00060505810531F1010150610000400101506100004000" + sr = { + 'status': 0, + 'scts': datetime(2010, 10, 5, 16, 0), + 'dt': datetime(2010, 10, 5, 16, 0), + 'recipient': '50131' + } + + sms = SmsDeliver(pdu) + self.assertEqual(sms.csca, None) + data = sms.data + self.assertEqual(data['ref'], 5) + self.assertEqual(sms.sr, sr) + +# XXX: renable when support added +# def test_decoding_submit_status_report(self): +# # sent from SMSC to indicate submission failed or additional info +# pdu = "07914306073011F001000B914306565711F9000007F0B2FC0DCABF01" +# csca = "+34607003110" +# number = "SR-UNKNOWN" +# +# sms = SmsDeliver(pdu) +# self.assertEqual(sms.csca, csca) +# self.assertEqual(sms.number, number) diff --git a/messaging/test/test_udh.py b/messaging/test/test_udh.py new file mode 100644 index 0000000..9496ff6 --- /dev/null +++ b/messaging/test/test_udh.py @@ -0,0 +1,24 @@ +import unittest + +from messaging.sms.udh import UserDataHeader +from messaging.utils import to_array + + +class TestUserDataHeader(unittest.TestCase): + + def test_user_data_header(self): + data = to_array("08049f8e020105040b8423f0") + udh = UserDataHeader.from_bytes(data) + + self.assertEqual(udh.concat.seq, 1) + self.assertEqual(udh.concat.cnt, 2) + self.assertEqual(udh.concat.ref, 40846) + self.assertEqual(udh.ports.dest_port, 2948) + self.assertEqual(udh.ports.orig_port, 9200) + + data = to_array("0003190201") + udh = UserDataHeader.from_bytes(data) + + self.assertEqual(udh.concat.seq, 1) + self.assertEqual(udh.concat.cnt, 2) + self.assertEqual(udh.concat.ref, 25) diff --git a/messaging/test/test_wap.py b/messaging/test/test_wap.py new file mode 100644 index 0000000..84224a1 --- /dev/null +++ b/messaging/test/test_wap.py @@ -0,0 +1,140 @@ +# -*- coding: utf-8 -*- +from array import array +import unittest + +from messaging.sms import SmsDeliver +from messaging.sms.wap import (is_a_wap_push_notification as is_push, + is_mms_notification, + extract_push_notification) + + +def list_to_str(l): + a = array("B", l) + return a.tostring() + + +class TestSmsWapPush(unittest.TestCase): + + data = [1, 6, 34, 97, 112, 112, 108, 105, 99, 97, 116, 105, 111, + 110, 47, 118, 110, 100, 46, 119, 97, 112, 46, 109, 109, 115, 45, + 109, 101, 115, 115, 97, 103, 101, 0, 175, 132, 140, 130, 152, 78, + 79, 75, 53, 67, 105, 75, 99, 111, 84, 77, 89, 83, 71, 52, 77, 66, + 83, 119, 65, 65, 115, 75, 118, 49, 52, 70, 85, 72, 65, 65, 65, 65, + 65, 65, 65, 65, 0, 141, 144, 137, 25, 128, 43, 52, 52, 55, 55, 56, + 53, 51, 52, 50, 55, 52, 57, 47, 84, 89, 80, 69, 61, 80, 76, 77, 78, + 0, 138, 128, 142, 2, 116, 0, 136, 5, 129, 3, 1, 25, 64, 131, 104, + 116, 116, 112, 58, 47, 47, 112, 114, 111, 109, 109, 115, 47, 115, + 101, 114, 118, 108, 101, 116, 115, 47, 78, 79, 75, 53, 67, 105, 75, + 99, 111, 84, 77, 89, 83, 71, 52, 77, 66, 83, 119, 65, 65, 115, 75, + 118, 49, 52, 70, 85, 72, 65, 65, 65, 65, 65, 65, 65, 65, 0] + + def test_is_a_wap_push_notification(self): + self.assertTrue(is_push(list_to_str(self.data))) + self.assertTrue(is_push(list_to_str([1, 6, 57, 92, 45]))) + self.assertFalse(is_push(list_to_str([4, 5, 57, 92, 45]))) + self.assertRaises(TypeError, is_push, 1) + + def test_decoding_m_notification_ind(self): + pdus = [ + "0791447758100650400E80885810000000810004016082415464408C0C08049F8E020105040B8423F00106226170706C69636174696F6E2F766E642E7761702E6D6D732D6D65737361676500AF848C82984E4F4B3543694B636F544D595347344D4253774141734B7631344655484141414141414141008D908919802B3434373738353334323734392F545950453D504C4D4E008A808E0274008805810301194083687474703A2F", + "0791447758100650440E8088581000000081000401608241547440440C08049F8E020205040B8423F02F70726F6D6D732F736572766C6574732F4E4F4B3543694B636F544D595347344D4253774141734B763134465548414141414141414100", + ] + number = '3838383530313030303030303138'.decode('hex') + csca = "+447785016005" + data = "" + + sms = SmsDeliver(pdus[0]) + self.assertEqual(sms.udh.concat.ref, 40846) + self.assertEqual(sms.udh.concat.cnt, 2) + self.assertEqual(sms.udh.concat.seq, 1) + self.assertEqual(sms.number, number) + self.assertEqual(sms.csca, csca) + data += sms.text + + sms = SmsDeliver(pdus[1]) + self.assertEqual(sms.udh.concat.ref, 40846) + self.assertEqual(sms.udh.concat.cnt, 2) + self.assertEqual(sms.udh.concat.seq, 2) + self.assertEqual(sms.number, number) + data += sms.text + + mms = extract_push_notification(data) + self.assertEqual(is_mms_notification(mms), True) + + self.assertEqual(mms.headers['Message-Type'], 'm-notification-ind') + self.assertEqual(mms.headers['Transaction-Id'], + 'NOK5CiKcoTMYSG4MBSwAAsKv14FUHAAAAAAAA') + self.assertEqual(mms.headers['MMS-Version'], '1.0') + self.assertEqual(mms.headers['From'], + '2b3434373738353334323734392f545950453d504c4d4e'.decode('hex')) + self.assertEqual(mms.headers['Message-Class'], 'Personal') + self.assertEqual(mms.headers['Message-Size'], 29696) + self.assertEqual(mms.headers['Expiry'], 72000) + self.assertEqual(mms.headers['Content-Location'], + 'http://promms/servlets/NOK5CiKcoTMYSG4MBSwAAsKv14FUHAAAAAAAA') + + pdus = [ + "0791447758100650400E80885810000000800004017002314303408C0C0804DFD3020105040B8423F00106226170706C69636174696F6E2F766E642E7761702E6D6D732D6D65737361676500AF848C82984E4F4B3541315A6446544D595347344F3356514141734A763934476F4E4141414141414141008D908919802B3434373731373237353034392F545950453D504C4D4E008A808E0274008805810303F47F83687474703A2F", + "0791447758100650440E8088581000000080000401700231431340440C0804DFD3020205040B8423F02F70726F6D6D732F736572766C6574732F4E4F4B3541315A6446544D595347344F3356514141734A763934476F4E414141414141414100", + ] + + number = "88850100000008" + data = "" + + sms = SmsDeliver(pdus[0]) + self.assertEqual(sms.udh.concat.ref, 57299) + self.assertEqual(sms.udh.concat.cnt, 2) + self.assertEqual(sms.udh.concat.seq, 1) + self.assertEqual(sms.number, number) + data += sms.text + + sms = SmsDeliver(pdus[1]) + self.assertEqual(sms.udh.concat.ref, 57299) + self.assertEqual(sms.udh.concat.cnt, 2) + self.assertEqual(sms.udh.concat.seq, 2) + self.assertEqual(sms.number, number) + data += sms.text + + mms = extract_push_notification(data) + self.assertEqual(is_mms_notification(mms), True) + + self.assertEqual(mms.headers['Message-Type'], 'm-notification-ind') + self.assertEqual(mms.headers['Transaction-Id'], + 'NOK5A1ZdFTMYSG4O3VQAAsJv94GoNAAAAAAAA') + self.assertEqual(mms.headers['MMS-Version'], '1.0') + self.assertEqual(mms.headers['From'], + '2b3434373731373237353034392f545950453d504c4d4e'.decode('hex')) + self.assertEqual(mms.headers['Message-Class'], 'Personal') + self.assertEqual(mms.headers['Message-Size'], 29696) + self.assertEqual(mms.headers['Expiry'], 259199) + self.assertEqual(mms.headers['Content-Location'], + 'http://promms/servlets/NOK5A1ZdFTMYSG4O3VQAAsJv94GoNAAAAAAAA') + + def test_decoding_generic_wap_push(self): + pdus = [ + "0791947122725014440C8500947122921105F5112042519582408C0B05040B8423F0000396020101060B03AE81EAC3958D01A2B48403056A0A20566F6461666F6E650045C60C037761702E6D65696E63616C6C79612E64652F000801035A756D206B6F7374656E6C6F73656E20506F7274616C20224D65696E0083000322202D2065696E66616368206175662064656E20666F6C67656E64656E204C696E6B206B6C69636B656E", + "0791947122725014440C8500947122921105F5112042519592403C0B05040B8423F00003960202206F6465722064696520536569746520646972656B7420617566727566656E2E2049687200830003205465616D000101", + ] + number = '303034393137323232393131'.decode('hex') + csca = "+491722270541" + data = "" + + sms = SmsDeliver(pdus[0]) + self.assertEqual(sms.udh.concat.ref, 150) + self.assertEqual(sms.udh.concat.cnt, 2) + self.assertEqual(sms.udh.concat.seq, 1) + self.assertEqual(sms.number, number) + self.assertEqual(sms.csca, csca) + data += sms.text + + sms = SmsDeliver(pdus[1]) + self.assertEqual(sms.udh.concat.ref, 150) + self.assertEqual(sms.udh.concat.cnt, 2) + self.assertEqual(sms.udh.concat.seq, 2) + self.assertEqual(sms.number, number) + data += sms.text + + self.assertEqual(data, '\x01\x06\x0b\x03\xae\x81\xea\xc3\x95\x8d\x01\xa2\xb4\x84\x03\x05j\n Vodafone\x00E\xc6\x0c\x03wap.meincallya.de/\x00\x08\x01\x03Zum kostenlosen Portal "Mein\x00\x83\x00\x03" - einfach auf den folgenden Link klicken oder die Seite direkt aufrufen. Ihr\x00\x83\x00\x03 Team\x00\x01\x01') + + push = extract_push_notification(data) + self.assertEqual(is_mms_notification(push), False) diff --git a/messaging/utils.py b/messaging/utils.py new file mode 100644 index 0000000..9544a4d --- /dev/null +++ b/messaging/utils.py @@ -0,0 +1,267 @@ +from array import array +from datetime import timedelta, tzinfo +from math import floor +import sys + + +class FixedOffset(tzinfo): + """Fixed offset in minutes east from UTC.""" + + def __init__(self, offset, name): + if isinstance(offset, timedelta): + self.offset = offset + else: + self.offset = timedelta(minutes=offset) + + self.__name = name + + @classmethod + def from_timezone(cls, tz_str, name): + # no timezone, GMT+3, GMT-3 + # '', '+0330', '-0300' + if not tz_str: + return cls(timedelta(0), name) + + sign = 1 if '+' in tz_str else -1 + offset = tz_str.replace('+', '').replace('-', '') + hours, minutes = int(offset[:2]), int(offset[2:]) + minutes += hours * 60 + + if sign == 1: + td = timedelta(minutes=minutes) + elif sign == -1: + td = timedelta(days=-1, minutes=minutes) + + return cls(td, name) + + def utcoffset(self, dt): + return self.offset + + def tzname(self, dt): + return self.__name + + def dst(self, dt): + return timedelta(0) + + +def bytes_to_str(b): + if sys.version_info >= (3,): + return b.decode('latin1') + + return b + + +def to_array(pdu): + return array('B', [int(pdu[i:i + 2], 16) for i in range(0, len(pdu), 2)]) + + +def to_bytes(s): + if sys.version_info >= (3,): + return bytes(s) + + return ''.join(map(chr, s)) + + +def debug(s): + # set this to True if you want to poke at PDU encoding/decoding + if False: + print(s) + + +def swap(s): + """Swaps ``s`` according to GSM 23.040""" + what = s[:] + for n in range(1, len(what), 2): + what[n - 1], what[n] = what[n], what[n - 1] + + return what + + +def swap_number(n): + data = swap(list(n.replace('f', ''))) + return ''.join(data) + + +def clean_number(n): + return n.strip().replace(' ', '') + + +def encode_str(s): + """Returns the hexadecimal representation of ``s``""" + return ''.join(["%02x" % ord(n) for n in s]) + + +def encode_bytes(b): + return ''.join(["%02x" % n for n in b]) + + +def pack_8bits_to_7bits(message, udh=None): + pdu = "" + txt = bytes_to_str(message) + + if udh is None: + tl = len(txt) + txt += '\x00' + msgl = int(len(txt) * 7 / 8) + op = [-1] * msgl + c = shift = 0 + + for n in range(msgl): + if shift == 6: + c += 1 + + shift = n % 7 + lb = ord(txt[c]) >> shift + hb = (ord(txt[c + 1]) << (7 - shift) & 255) + op[n] = lb + hb + c += 1 + + pdu = chr(tl) + ''.join(map(chr, op)) + else: + txt = "\x00\x00\x00\x00\x00\x00" + txt + tl = len(txt) + + txt += '\x00' + msgl = int(len(txt) * 7 / 8) + op = [-1] * msgl + c = shift = 0 + + for n in range(msgl): + if shift == 6: + c += 1 + + shift = n % 7 + lb = ord(txt[c]) >> shift + hb = (ord(txt[c + 1]) << (7 - shift) & 255) + op[n] = lb + hb + c += 1 + + for i, char in enumerate(udh): + op[i] = ord(char) + + pdu = chr(tl) + ''.join(map(chr, op)) + + return encode_str(pdu) + + +def pack_8bits_to_8bit(message, udh=None): + text = message + if udh is not None: + text = udh + text + + mlen = len(text) + message = chr(mlen) + message + return encode_str(message) + + +def pack_8bits_to_ucs2(message, udh=None): + # XXX: This does not control the size respect to UDH + text = message + nmesg = '' + + if udh is not None: + text = udh + text + + for n in text: + nmesg += chr(ord(n) >> 8) + chr(ord(n) & 0xFF) + + mlen = len(text) * 2 + message = chr(mlen) + nmesg + return encode_str(message) + + +def unpack_msg(pdu): + """Unpacks ``pdu`` into septets and returns the decoded string""" + # Taken/modified from Dave Berkeley's pysms package + count = last = 0 + result = [] + + for i in range(0, len(pdu), 2): + byte = int(pdu[i:i + 2], 16) + mask = 0x7F >> count + out = ((byte & mask) << count) + last + last = byte >> (7 - count) + result.append(out) + + if len(result) >= 0xa0: + break + + if count == 6: + result.append(last) + last = 0 + + count = (count + 1) % 7 + + return to_bytes(result) + + +def unpack_msg2(pdu): + """Unpacks ``pdu`` into septets and returns the decoded string""" + # Taken/modified from Dave Berkeley's pysms package + count = last = 0 + result = [] + + for byte in pdu: + mask = 0x7F >> count + out = ((byte & mask) << count) + last + last = byte >> (7 - count) + result.append(out) + + if len(result) >= 0xa0: + break + + if count == 6: + result.append(last) + last = 0 + + count = (count + 1) % 7 + + return to_bytes(result) + + +def timedelta_to_relative_validity(t): + """ + Convert ``t`` to its relative validity period + + In case the resolution of ``t`` is too small for a time unit, + it will be floor-rounded to the previous sane value + + :type t: datetime.timedelta + + :return int + """ + if t < timedelta(minutes=5): + raise ValueError("Min resolution is five minutes") + + if t > timedelta(weeks=63): + raise ValueError("Max validity is 63 weeks") + + if t <= timedelta(hours=12): + return int(floor(t.seconds / (60 * 5))) - 1 + + if t <= timedelta(hours=24): + t -= timedelta(hours=12) + return int(floor(t.seconds / (60 * 30))) + 143 + + if t <= timedelta(days=30): + return t.days + 166 + + if t <= timedelta(weeks=63): + return int(floor(t.days / 7)) + 192 + + +def datetime_to_absolute_validity(d, tzname='Unknown'): + """Convert ``d`` to its integer representation""" + n = d.strftime("%y %m %d %H %M %S %z").split(" ") + # compute offset + offset = FixedOffset.from_timezone(n[-1], tzname).offset + # one unit is 15 minutes + s = "%02d" % int(floor(offset.seconds / (60 * 15))) + + if offset.days < 0: + # set MSB to 1 + s = "%02x" % ((int(s[0]) << 4) | int(s[1]) | 0x80) + + n[-1] = s + + return [int(c[::-1], 16) for c in n] diff --git a/requirements.txt b/requirements.txt index 8eb8388..0969dc0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,3 +18,4 @@ pinax-theme-bootstrap django-bootstrap3 requests webdavclient +pyst2 diff --git a/systemd_units/djing_dial.service b/systemd_units/djing_dial.service new file mode 100644 index 0000000..a3adbf6 --- /dev/null +++ b/systemd_units/djing_dial.service @@ -0,0 +1,12 @@ +[Unit] +Description=Dialing inbox sms unit + +[Service] +Type=simple +ExecStart=/usr/bin/python3 dialing.py +WorkingDirectory=/srv/http/djing +User=http +Group=http + +[Install] +WantedBy=multi-user.target