├── .gitignore ├── README.md ├── hacknotify ├── __init__.py ├── bandwidthapi.py ├── config-example.py ├── notify.py ├── plivoapi.py ├── sheetsapi.py └── twilioapi.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | venv/ 3 | client_secret.json 4 | config.py 5 | *.egg-info -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HackNotify 2 | 3 | > An SMS notification system built for HackNC 4 | 5 | This project leverages the following APIs: 6 | 7 | * Google Sheets 8 | * Plivo, Bandwidth SDK, or Twilio 9 | 10 | # Installation 11 | 12 | ### Requirements 13 | 14 | * Python 3 15 | * A google account with access to a correctly formatted google sheet. 16 | * API Credit with a Bandwidth.com, Plivo, or Twilio account. (Bandwidth recommended) 17 | 18 | ### Google API setup 19 | 20 | This step is necessary because each account must enable google sheets api. 21 | 22 | Visit https://developers.google.com/sheets/quickstart/python and follow Step 1. 23 | 24 | Instead of putting `client_secret.json` in your development directory, do this: 25 | 26 | ``` 27 | mkdir ~/.credentials 28 | mv /path/to/client_secret_download.json ~/.credentials/client_secret.json 29 | ``` 30 | 31 | ### Python Install 32 | 33 | ``` 34 | git clone git@github.com:hacknc/hacknotify 35 | cd hacknotify 36 | ``` 37 | 38 | Create a copy of `config-example.py` called `config.py` and get the necessary information and API keys from your chosen service. 39 | 40 | ``` 41 | pip3 install -e . 42 | ``` 43 | 44 | You're done! 45 | 46 | # Running the client 47 | 48 | `usage: hacknotify [-h] [--group GROUP] [--subject SUBJECT] [--message MESSAGE]` 49 | 50 | ### Examples: 51 | 52 | Fully automate send: 53 | 54 | `echo "y" | hacknotify --message "This was a triumph" --subject [TEST] --group hackers` 55 | 56 | Automate input, but allow interactive confirm: 57 | 58 | `hacknotify -m "I'm making a note here - Huge success" -s [TEST] -g hackers` 59 | 60 | Do it all from a python interpreter: 61 | 62 | ``` 63 | >>> from hacknotify import notify 64 | >>> group = notify.get_group("group_name") 65 | >>> notify.do_send(group, "[TEST2]", "SPAAAAACE") 66 | True 67 | ``` 68 | 69 | Example console output: 70 | ``` 71 | -------------------------------------- 72 | | SMS Notification Platform | 73 | -------------------------------------- 74 | [?] Groups available: 75 | - red 76 | - blue 77 | - hackers 78 | - sponsors 79 | - mentors 80 | Group.....: mentors 81 | [*] Loading from group... 82 | [*] Group "mentors" loaded with 1 recipient(s) 83 | Message...: THIS WAS A TRIUMPH 84 | -------------------------------------- 85 | [*] Sending to mentors: 86 | 87 | [HackNC] THIS WAS A TRIUMPH 88 | 89 | Count : 1 90 | Cost : 0.005 91 | -------------------------------------- 92 | OK? y/[n] : y 93 | [*] Queueing... 94 | [*] Send Queued! 95 | ``` -------------------------------------------------------------------------------- /hacknotify/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HackNC/hacknotify/02c6187fd558ad65ef09f31f6c5487d130a162d2/hacknotify/__init__.py -------------------------------------------------------------------------------- /hacknotify/bandwidthapi.py: -------------------------------------------------------------------------------- 1 | import math 2 | import bandwidth 3 | from . import config 4 | 5 | def chunks(l, n): 6 | """Yield successive n-sized chunks from l.""" 7 | for i in range(0, len(l), n): 8 | yield l[i:i + n] 9 | 10 | def _send_50(api, send_from_number, num_list, message): 11 | 12 | sendlist = [] 13 | 14 | for num in num_list: 15 | # Bandwidth likes the leading '+' 16 | if len(num) == 11: 17 | sendlist.append({ 18 | "from": send_from_number, 19 | "to": num, 20 | "text": message 21 | }) 22 | 23 | results = api.send_messages(sendlist) 24 | time.sleep(2) 25 | 26 | 27 | def trigger_send(num_list, message): 28 | """ 29 | Queues the messages. 30 | Returns the success/fail status. 31 | """ 32 | api = bandwidth.client('catapult', config.BW_USERNAME, config.BW_ID, config.BW_KEY) 33 | 34 | send_from_number = config.BW_SRC 35 | if not '+' in send_from_number: 36 | send_from_number = '+' + send_from_number 37 | 38 | chks = chunks(num_list, 40) 39 | 40 | for l in chks: 41 | _send_50(api, send_from_number, l, message) 42 | 43 | return True 44 | 45 | def calculate_cost(count, message): 46 | return math.ceil(len(message) / config.SMS_MAX_CHARS) * count * config.BW_SEND_RATE -------------------------------------------------------------------------------- /hacknotify/config-example.py: -------------------------------------------------------------------------------- 1 | # ============================ READ ME ================================ 2 | # STEP 1: Create a COPY in the same directory called config.py 3 | # STEP 2: Configure this application by changing the "CHANGE ME" fields 4 | # STEP 3: RUN `pip install -e .` 5 | # This will allow config changes to take immediate effect. 6 | # TROUBLESHOOTING: Review README.md, then open a github issue 7 | # ===================================================================== 8 | 9 | # Pick any one of ["plivo", "twilio", "bandwidth"] 10 | PROVIDER="CHANGE ME" 11 | 12 | # GOOGLE SHEET API INFO 13 | # Visit https://developers.google.com/sheets/quickstart/python and follow Step 1 14 | SHEET_APP_NAME = 'CHANGE ME: MY SHEET APP NAME' 15 | # If your sheet URL is https://docs.google.com/spreadsheets/d/qwertyuiopasdfghjkl1234567890/edit 16 | # then your SHEET_ID is 'qwertyuiopasdfghjkl1234567890' 17 | SHEET_ID = 'CHANGE ME: qwertyuiopasdfghjkl1234567890' 18 | 19 | # Pair your google sheet data with your group names. 20 | GROUPS = { 21 | # key: any arbitrary name for the group 22 | # value: a valid Google Sheets selection, including the TAB name and row/col query 23 | "hackers": "Hackers!A1:B", 24 | "sponsors": "Sponsors!A1:B", 25 | "mentors": "Mentors!A1:B", 26 | "blue": "Blue!A1:B", 27 | "red": "Red!A1:B", 28 | # A1:B means the entire column of A, not including any of B. 29 | # Please note that this is not introspective. This list does not automatically populate from your google sheet. 30 | } 31 | 32 | # PLIVO API INFO 33 | # Create a PLIVO account to get this info. 34 | PLIVO_ID="CHANGE ME" 35 | PLIVO_KEY="CHANGE ME" 36 | PLIVO_SRC="CHANGE ME" # Your plivo phone number (starting with country code) 37 | PLIVO_SEND_RATE=0.0035 # Price per message in USD 38 | 39 | # TWILIO API INFO 40 | # Create a TWILIO account to get this info. 41 | TWILIO_ID="CHANGE ME" 42 | TWILIO_KEY="CHANGE ME" 43 | TWILIO_SRC="CHANGE ME" # Your twilio phone number (starting with country code) 44 | TWILIO_SEND_RATE=0.0075 # Price per message in USD 45 | 46 | # BANDWIDTH API INFO 47 | # Create a Bandwidth account to get this info. 48 | BW_USERNAME="CHANGE ME" 49 | BW_ID="CHANGE ME" # Token 50 | BW_KEY="CHANGE ME" # Key 51 | BW_SRC="CHANGE ME" # Your bandwidth phone number (starting with + country code) 52 | BW_SEND_RATE=0.005 # Price per message in USD 53 | 54 | # For python phonenumbers library 55 | DEFAULT_REGION = "US" 56 | 57 | # EVENT_NAME is used as the message subject line 58 | EVENT_NAME = "CHANGE ME: HackNC" 59 | DEFAULT_SUBJECT = "["+EVENT_NAME+"]" 60 | 61 | # The largest numbers an SMS message can have. 62 | # Any more than this, and the message will be broken into 2 (or more) 63 | SMS_MAX_CHARS=160 -------------------------------------------------------------------------------- /hacknotify/notify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | import phonenumbers 5 | 6 | from . import sheetsapi 7 | from . import plivoapi 8 | from . import bandwidthapi 9 | from . import twilioapi 10 | from . import config 11 | 12 | 13 | def get_provider(): 14 | providers = { 15 | "plivo":plivoapi, 16 | "bandwidth":bandwidthapi, 17 | "twilio":twilioapi 18 | } 19 | return providers[config.PROVIDER] 20 | 21 | 22 | def make_parser(): 23 | parser = argparse.ArgumentParser(description="Send notifications to a list @ HackNC") 24 | parser.add_argument("--group", "-g", required=False, help="The list from config to send notification to") 25 | parser.add_argument("--subject", "-s", required=False, default=config.DEFAULT_SUBJECT, help="a subject to prepend to the message body") 26 | parser.add_argument("--message", "-m", required=False, default=None, help="Message body") 27 | args = parser.parse_args() 28 | return args 29 | 30 | 31 | def safe_input(string): 32 | """ 33 | Input function with safety for 34 | ^C (CTRL C) 35 | ^D (CTRL D) 36 | """ 37 | try: 38 | reply = input(string) 39 | return reply 40 | except EOFError: 41 | print("\nEOF") 42 | exit(1) 43 | except KeyboardInterrupt: 44 | print("") 45 | exit(1) 46 | 47 | 48 | def get_groups(): 49 | """ 50 | Return a list of the possible groups to send to 51 | """ 52 | return config.GROUPS.keys() 53 | 54 | 55 | def get_group(group_name): 56 | """ 57 | Get the actual list of numbers in a group 58 | """ 59 | return sheetsapi.get_phone_list(group_name) 60 | 61 | 62 | def do_send(provider, dirty_list, subject, message): 63 | """ 64 | API call for sending from code 65 | :param group: a string group name from config 66 | :param subject: a subject line string 67 | :param message: message body string 68 | :return: True/False for success/fail 69 | """ 70 | valid_list = do_number_parse(dirty_list) 71 | return provider.trigger_send(valid_list, subject + " " + message) 72 | 73 | 74 | def do_number_parse(numlist): 75 | """ 76 | Preprocess the list making sure formatting is correct 77 | Return a valid list 78 | """ 79 | invalid_list = [] 80 | valid_list = [] 81 | 82 | for number in numlist: 83 | 84 | try: 85 | 86 | possible_number = number[0] 87 | parsed_number = phonenumbers.parse(possible_number, config.DEFAULT_REGION) 88 | 89 | if phonenumbers.is_possible_number(parsed_number): 90 | unicode_number = phonenumbers.format_number(parsed_number, phonenumbers.PhoneNumberFormat.INTERNATIONAL) 91 | unicode_number = unicode_number.replace(' ', '') # remove the spaces 92 | unicode_number = unicode_number.replace('+', '') # remove the + 93 | unicode_number = unicode_number.replace('-', '') # remove the - 94 | valid_list.append(unicode_number) 95 | else: 96 | invalid_list.append(number) 97 | 98 | except IndexError: 99 | invalid_list.append(number) 100 | 101 | return valid_list 102 | 103 | 104 | def enter_interactive_send(args): 105 | """ 106 | Walk the user through creating and sending a message 107 | """ 108 | 109 | # Set up the messaging provider. 110 | provider = get_provider() 111 | 112 | print("--------------------------------------") 113 | print("| SMS Notification Platform |") 114 | print("--------------------------------------") 115 | 116 | # Determine group to send to 117 | group = None 118 | if args.group: 119 | group = args.group 120 | else: 121 | tries = 0 122 | while group not in config.GROUPS.keys(): 123 | 124 | if tries > 0: 125 | print("[!] Group invalid.") 126 | tries += 1 127 | 128 | print("[?] Groups available:") 129 | for g in config.GROUPS.keys(): 130 | print(" - " + g) 131 | group = safe_input("Group.....: ") 132 | 133 | print("[*] Loading from group... ") 134 | plist = get_group(group) 135 | count = len(plist) 136 | print("[*] Group \"{grp}\" loaded with {n} recipient(s)".format(grp=group, n=count)) 137 | 138 | if count <= 0: 139 | print("[!] No entries in list") 140 | print("[!] Terminating") 141 | exit(1) 142 | 143 | # Build the message 144 | if args.message: 145 | message = args.message 146 | else: 147 | message = safe_input("Message...: ") 148 | 149 | subject = args.subject 150 | 151 | print("--------------------------------------") 152 | print("""[*] Sending to {grp}: 153 | 154 | {subject} {message} 155 | """.format( 156 | n=count, 157 | grp=group, 158 | subject=subject, 159 | message=message)) 160 | 161 | cost = provider.calculate_cost(count, message) 162 | 163 | print("Count : " + str(count)) 164 | print("Cost : " + str(cost)) 165 | print("--------------------------------------") 166 | 167 | # Trigger send 168 | confirm = safe_input("OK? y/[n] : ") or "n" 169 | 170 | if confirm.lower() == "y": 171 | 172 | print("[*] Queueing...") 173 | success = do_send(provider, plist, subject, message) 174 | 175 | if success: 176 | print("[*] Send Queued!") 177 | else: 178 | print("[!] Send Failed!") 179 | else: 180 | print("[!] Terminating") 181 | exit(1) 182 | 183 | 184 | def main(): 185 | """ 186 | The entry point for 'hacknotify' command 187 | """ 188 | args = make_parser() 189 | enter_interactive_send(args) 190 | 191 | 192 | if __name__ == "__main__": 193 | main() 194 | -------------------------------------------------------------------------------- /hacknotify/plivoapi.py: -------------------------------------------------------------------------------- 1 | import plivo 2 | import math 3 | from . import config 4 | 5 | def _parse_list(num_list): 6 | """ 7 | takes a list of valid parsed numbers 8 | returns a string formatted for plivo 9 | separated by < 10 | """ 11 | list_string = "" 12 | for num in num_list: 13 | list_string += num + "<" 14 | 15 | # remove the final '<' 16 | return list_string[:-1] 17 | 18 | def trigger_send(num_list, message): 19 | """ 20 | Queues the messages. 21 | Returns the success/fail status. 22 | """ 23 | auth_id = config.PLIVO_ID 24 | auth_token = config.PLIVO_KEY 25 | 26 | p = plivo.RestAPI(auth_id, auth_token) 27 | 28 | params = { 29 | 'src': config.PLIVO_SRC, # Sender's phone number with country code 30 | 'dst' : _parse_list(num_list), # Receivers' phone numbers with country code. The numbers are separated by "<" delimiter. 31 | 'text' : message # Your SMS Text Message 32 | } 33 | 34 | # response = p.send_message(params) 35 | 36 | if response[0] >= 200 and response[0] <= 299: 37 | # 2XX Message. Probably ok. 38 | return True 39 | else: 40 | print(response) 41 | return False 42 | 43 | def calculate_cost(count, message): 44 | return math.ceil(len(message) / 160) * count * config.PLIVO_SEND_RATE -------------------------------------------------------------------------------- /hacknotify/sheetsapi.py: -------------------------------------------------------------------------------- 1 | from __future__ import print_function 2 | import httplib2 3 | import os 4 | from . import config 5 | 6 | from apiclient import discovery 7 | from oauth2client import client 8 | from oauth2client import tools 9 | from oauth2client.file import Storage 10 | 11 | # If modifying these scopes, delete your previously saved credentials 12 | # at ~/.credentials/sheets.googleapis.com-python-quickstart.json 13 | SCOPES = 'https://www.googleapis.com/auth/spreadsheets.readonly' 14 | CLIENT_SECRET_FILE = 'client_secret.json' 15 | APPLICATION_NAME = config.SHEET_APP_NAME 16 | 17 | 18 | def get_credentials(): 19 | """Gets valid user credentials from storage. 20 | 21 | If nothing has been stored, or if the stored credentials are invalid, 22 | the OAuth2 flow is completed to obtain the new credentials. 23 | 24 | Returns: 25 | Credentials, the obtained credential. 26 | """ 27 | home_dir = os.path.expanduser('~') 28 | credential_dir = os.path.join(home_dir, '.credentials') 29 | if not os.path.exists(credential_dir): 30 | os.makedirs(credential_dir) 31 | credential_path = os.path.join(credential_dir, 32 | 'sheets.googleapis.com-python.json') 33 | 34 | store = Storage(credential_path) 35 | credentials = store.get() 36 | credentials_secret_file_fullpath = os.path.join(credential_dir, CLIENT_SECRET_FILE) 37 | if not credentials or credentials.invalid: 38 | flow = client.flow_from_clientsecrets(credentials_secret_file_fullpath, SCOPES) 39 | flow.user_agent = APPLICATION_NAME 40 | credentials = tools.run_flow(flow, store, None) 41 | print('Storing credentials to ' + credential_path) 42 | return credentials 43 | 44 | def get_phone_list(list_name): 45 | """ 46 | Get a list of phone numbers by their name. 47 | """ 48 | credentials = get_credentials() 49 | http = credentials.authorize(httplib2.Http()) 50 | discoveryUrl = ('https://sheets.googleapis.com/$discovery/rest?' 51 | 'version=v4') 52 | service = discovery.build('sheets', 'v4', http=http, 53 | discoveryServiceUrl=discoveryUrl) 54 | # Load these from config. 55 | spreadsheetId = config.SHEET_ID 56 | rangeName = config.GROUPS[list_name] 57 | 58 | result = service.spreadsheets().values().get( 59 | spreadsheetId=spreadsheetId, range=rangeName).execute() 60 | 61 | values = result.get('values', []) 62 | 63 | if not values: 64 | print("No data found") 65 | return None 66 | else: 67 | return values -------------------------------------------------------------------------------- /hacknotify/twilioapi.py: -------------------------------------------------------------------------------- 1 | import math 2 | from twilio.rest import TwilioRestClient 3 | from . import config 4 | 5 | 6 | def trigger_send(num_list, message): 7 | """ 8 | Queues the messages. 9 | Returns the success/fail status. 10 | """ 11 | 12 | # To find these visit https://www.twilio.com/user/account 13 | client = TwilioRestClient(config.TWILIO_ID, config.TWILIO_KEY) 14 | 15 | send_from_number = config.TWILIO_SRC 16 | if not '+' in send_from_number: 17 | send_from_number = '+' + send_from_number 18 | 19 | # Twilio does not allow bulk creation. Iterate over all. 20 | 21 | for num in num_list: 22 | message = client.messages.create( 23 | body=message, 24 | to="+" + num, 25 | from_=send_from_number, 26 | ) 27 | return True 28 | 29 | # API Docs are bad. No idea how to detect failures, so I'll just let those go to 30 | # console and hope they are helpful... 31 | 32 | 33 | def calculate_cost(count, message): 34 | return math.ceil(len(message) / config.SMS_MAX_CHARS) * count * config.TWILIO_SEND_RATE -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup(name='HackNC Notify', 4 | version='1.0', 5 | description='HackNC Notification System', 6 | author='Brandon Davis', 7 | author_email='bd@unc.edu', 8 | url='https://hacknc.com/', 9 | entry_points={ 10 | 'console_scripts': ['hacknotify=hacknotify.notify:main'] 11 | }, 12 | packages=['hacknotify'], 13 | install_requires=[ 14 | 'google-api-python-client==1.5.3', 15 | 'httplib2==0.9.2', 16 | 'lxml==3.7.2', 17 | 'oauth2client==3.0.0', 18 | 'packaging==16.8', 19 | 'phonenumbers==7.7.2', 20 | 'plivo==0.11.1', 21 | 'pyasn1==0.1.9', 22 | 'pyasn1-modules==0.0.8', 23 | 'pyparsing==2.1.10', 24 | 'python-dateutil==2.6.0', 25 | 'requests==2.11.1', 26 | 'rsa==3.4.2', 27 | 'simplejson==3.8.2', 28 | 'six==1.10.0', 29 | 'uritemplate==0.6', 30 | 'pysocks==1.6.6', 31 | 'pytz==2016.10', 32 | 'twilio==5.7.0', 33 | 'bandwidth-sdk==2.0.0b0' 34 | ], 35 | ) --------------------------------------------------------------------------------