├── .gitignore ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── sentry_twilio ├── __init__.py └── models.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | 3 | # Packages 4 | *.egg 5 | *.egg-info 6 | dist 7 | build 8 | eggs 9 | parts 10 | bin 11 | var 12 | sdist 13 | develop-eggs 14 | .installed.cfg 15 | 16 | # Installer logs 17 | pip-log.txt 18 | 19 | # Unit test / coverage reports 20 | .coverage 21 | .tox 22 | nosetests.xml 23 | 24 | #Translations 25 | *.mo 26 | 27 | #Mr Developer 28 | .mr.developer.cfg 29 | 30 | sentry.conf.py 31 | 32 | sentry.db 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012, Matt Robenolt 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 7 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 8 | 9 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include setup.py README.md MANIFEST.in LICENSE 2 | global-exclude *~ 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | publish: 2 | python setup.py sdist upload 3 | 4 | clean: 5 | rm -rf *.egg-info 6 | rm -rf dist 7 | 8 | .PHONY: publish clean -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sentry-twilio 2 | A plugin for [Sentry](https://www.getsentry.com/) that sends SMS notifications via [Twilio](http://www.twilio.com/) 3 | 4 | **Note**: Only works with US numbers, mostly because I'm too lazy to think about international phone numbers and what to do with them. Feel free to submit a pull request. 5 | 6 | ## Installation 7 | `$ pip install sentry-twilio` 8 | 9 | Sentry will automagically detect that it has been installed. 10 | 11 | ## Configuration 12 | `sentry-twilio` needs 4 pieces of information to set this up correctly. 13 | 14 | ### Account SID & Auth Token 15 | The Account SID and Auth Token can both be found on your [Twilio account dashboard](https://www.twilio.com/user/account). 16 | ![](http://i.imgur.com/Km3cI.png) 17 | 18 | ### SMS From # 19 | This is the number that was purchased through Twilio. [Twilio documentation for more information](https://www.twilio.com/help/faq/phone-numbers). 20 | 21 | Examples: 22 | ``` 23 | +13305093095 24 | // or 25 | 5551234567 26 | ``` 27 | 28 | ### SMS To #'s 29 | A list of phone numbers to send to separated by commas. 30 | 31 | Example: 32 | ``` 33 | +13305093095, 5551234567 34 | ``` 35 | -------------------------------------------------------------------------------- /sentry_twilio/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | sentry_twilio 3 | ~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2012 by Matt Robenolt. 6 | :license: BSD, see LICENSE for more details. 7 | """ 8 | 9 | try: 10 | VERSION = __import__('pkg_resources') \ 11 | .get_distribution('sentry-twilio').version 12 | except Exception as e: 13 | VERSION = 'unknown' 14 | -------------------------------------------------------------------------------- /sentry_twilio/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | sentry_twilio.models 3 | ~~~~~~~~~~~~~~~~~~~~ 4 | 5 | :copyright: (c) 2016 by Matt Robenolt. 6 | :license: BSD, see LICENSE for more details. 7 | """ 8 | 9 | import re 10 | import phonenumbers 11 | 12 | from django import forms 13 | from django.utils.translation import ugettext_lazy as _ 14 | 15 | from sentry import http 16 | from sentry.plugins.bases.notify import NotificationPlugin 17 | 18 | import sentry_twilio 19 | 20 | DEFAULT_REGION = 'US' 21 | MAX_SMS_LENGTH = 160 22 | 23 | twilio_sms_endpoint = 'https://api.twilio.com/2010-04-01/Accounts/{0}/SMS/Messages.json' 24 | 25 | 26 | def validate_phone(phone): 27 | try: 28 | p = phonenumbers.parse(phone, DEFAULT_REGION) 29 | except phonenumbers.NumberParseException: 30 | return False 31 | if not phonenumbers.is_possible_number(p): 32 | return False 33 | if not phonenumbers.is_valid_number(p): 34 | return False 35 | return True 36 | 37 | 38 | def clean_phone(phone): 39 | # This could raise, but should have been checked with validate_phone first 40 | return phonenumbers.format_number( 41 | phonenumbers.parse(phone, DEFAULT_REGION), 42 | phonenumbers.PhoneNumberFormat.E164, 43 | ) 44 | 45 | 46 | def basic_auth(user, password): 47 | return 'Basic ' + (user + ':' + password).encode('base64').replace('\n', '') 48 | 49 | 50 | def split_sms_to(data): 51 | return set(filter(bool, re.split(r'\s*,\s*|\s+', data))) 52 | 53 | 54 | class TwilioConfigurationForm(forms.Form): 55 | account_sid = forms.CharField(label=_('Account SID'), required=True, 56 | widget=forms.TextInput(attrs={'class': 'span6'})) 57 | auth_token = forms.CharField(label=_('Auth Token'), required=True, 58 | widget=forms.PasswordInput(render_value=True, attrs={'class': 'span6'})) 59 | sms_from = forms.CharField(label=_('SMS From #'), required=True, 60 | help_text=_('Digits only'), 61 | widget=forms.TextInput(attrs={'placeholder': 'e.g. 3305093095'})) 62 | sms_to = forms.CharField(label=_('SMS To #s'), required=True, 63 | help_text=_('Recipient(s) phone numbers separated by commas or lines'), 64 | widget=forms.Textarea(attrs={'placeholder': 'e.g. 3305093095, 5555555555'})) 65 | 66 | def clean_sms_from(self): 67 | data = self.cleaned_data['sms_from'] 68 | if not validate_phone(data): 69 | raise forms.ValidationError('{0} is not a valid phone number.'.format(data)) 70 | return clean_phone(data) 71 | 72 | def clean_sms_to(self): 73 | data = self.cleaned_data['sms_to'] 74 | phones = split_sms_to(data) 75 | if len(phones) > 10: 76 | raise forms.ValidationError('Max of 10 phone numbers, {0} were given.'.format(len(phones))) 77 | for phone in phones: 78 | if not validate_phone(phone): 79 | raise forms.ValidationError('{0} is not a valid phone number.'.format(phone)) 80 | return ','.join(sorted(map(clean_phone, phones))) 81 | 82 | def clean(self): 83 | # TODO: Ping Twilio and check credentials (?) 84 | return self.cleaned_data 85 | 86 | 87 | class TwilioPlugin(NotificationPlugin): 88 | author = 'Matt Robenolt' 89 | author_url = 'https://github.com/mattrobenolt' 90 | version = sentry_twilio.VERSION 91 | description = 'A plugin for Sentry which sends SMS notifications via Twilio' 92 | resource_links = ( 93 | ('Documentation', 'https://github.com/mattrobenolt/sentry-twilio/blob/master/README.md'), 94 | ('Bug Tracker', 'https://github.com/mattrobenolt/sentry-twilio/issues'), 95 | ('Source', 'https://github.com/mattrobenolt/sentry-twilio'), 96 | ('Twilio', 'https://www.twilio.com/'), 97 | ) 98 | 99 | slug = 'twilio' 100 | title = _('Twilio (SMS)') 101 | conf_title = title 102 | conf_key = 'twilio' 103 | project_conf_form = TwilioConfigurationForm 104 | 105 | def is_configured(self, project, **kwargs): 106 | return all([self.get_option(o, project) for o in ( 107 | 'account_sid', 'auth_token', 'sms_from', 'sms_to')]) 108 | 109 | def get_send_to(self, *args, **kwargs): 110 | # This doesn't depend on email permission... stuff. 111 | return True 112 | 113 | def notify_users(self, group, event, **kwargs): 114 | project = group.project 115 | 116 | body = 'Sentry [{0}] {1}: {2}'.format( 117 | project.name.encode('utf-8'), 118 | event.get_level_display().upper().encode('utf-8'), 119 | event.error().encode('utf-8').splitlines()[0] 120 | ) 121 | body = body[:MAX_SMS_LENGTH] 122 | 123 | account_sid = self.get_option('account_sid', project) 124 | auth_token = self.get_option('auth_token', project) 125 | sms_from = clean_phone(self.get_option('sms_from', project)) 126 | endpoint = twilio_sms_endpoint.format(account_sid) 127 | 128 | sms_to = self.get_option('sms_to', project) 129 | if not sms_to: 130 | return 131 | sms_to = split_sms_to(sms_to) 132 | 133 | headers = { 134 | 'Authorization': basic_auth(account_sid, auth_token), 135 | } 136 | 137 | errors = [] 138 | 139 | for phone in sms_to: 140 | if not phone: 141 | continue 142 | try: 143 | phone = clean_phone(phone) 144 | http.safe_urlopen( 145 | endpoint, 146 | method='POST', 147 | headers=headers, 148 | data={ 149 | 'From': sms_from, 150 | 'To': phone, 151 | 'Body': body, 152 | }, 153 | ).raise_for_status() 154 | except Exception as e: 155 | errors.append(e) 156 | 157 | if errors: 158 | if len(errors) == 1: 159 | raise errors[0] 160 | 161 | # TODO: multi-exception 162 | raise Exception(errors) 163 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | sentry-twilio 4 | ============= 5 | 6 | A plugin for Sentry which sends SMS notifications via Twilio. 7 | 8 | :copyright: (c) 2012 by Matt Robenolt 9 | :license: BSD, see LICENSE for more details. 10 | """ 11 | from setuptools import setup, find_packages 12 | 13 | 14 | install_requires = [ 15 | 'sentry>=7.0.0', 16 | 17 | # We don't need full `phonenumbers` library 18 | 'phonenumberslite<8.0', 19 | ] 20 | 21 | setup( 22 | name='sentry-twilio', 23 | version='0.1.0', 24 | author='Matt Robenolt', 25 | author_email='matt@ydekproductons.com', 26 | url='https://github.com/mattrobenolt/sentry-twilio', 27 | description='A plugin for Sentry which sends SMS notifications via Twilio', 28 | long_description=__doc__, 29 | license='BSD', 30 | packages=find_packages(exclude=['tests']), 31 | zip_safe=False, 32 | install_requires=install_requires, 33 | include_package_data=True, 34 | entry_points={ 35 | 'sentry.apps': [ 36 | 'twilio = sentry_twilio', 37 | ], 38 | 'sentry.plugins': [ 39 | 'twilio = sentry_twilio.models:TwilioPlugin', 40 | ] 41 | }, 42 | classifiers=[ 43 | 'Framework :: Django', 44 | 'Intended Audience :: Developers', 45 | 'Intended Audience :: System Administrators', 46 | 'Operating System :: OS Independent', 47 | 'Topic :: Software Development' 48 | ], 49 | ) 50 | --------------------------------------------------------------------------------