├── .gitignore ├── LICENSE ├── README.md ├── app.yaml ├── example.py ├── example_django_files ├── gae_python_gcm │ ├── urls.py │ └── views.py ├── settings.py └── urls.py ├── gae_python_gcm ├── __init__.py └── gcm.py └── queue.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | # Lines that start with '#' are comments. 2 | *~ 3 | .project 4 | .settings 5 | .pydevproject 6 | *.pyc 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | # 2 | # Licensed under the Apache License, Version 2.0 (the "License"); 3 | # you may not use this file except in compliance with the License. 4 | # You may obtain a copy of the License at 5 | # 6 | # http://www.apache.org/licenses/LICENSE-2.0 7 | # 8 | # Unless required by applicable law or agreed to in writing, software 9 | # distributed under the License is distributed on an "AS IS" BASIS, 10 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | # See the License for the specific language governing permissions and 12 | # limitations under the License. 13 | # -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | gae-python-gcm is a simple implementation of [Google Cloud Messaging](http://developer.android.com/google/gcm/index.html) for [Google App Engine](https://developers.google.com/appengine/docs/python/overview) in Python. 4 | 5 | This module is designed to take care of everything you have to think about when working with Android GCM messages on the server: 6 | 7 | * Takes advantage of App Engine's task queues to make retries asyncronous 8 | * Uses App Engine's memcache to collect error statistics 9 | * Provides two hook functions, **delete_bad_token** and **update_token**, which can be overridden or configured from a settings files to implement these actions in your environent. 10 | * Example django settings and testing / debug handlers. 11 | 12 | # Implementation with / without django 13 | 14 | gae-python-gcm can be used with or without [Django](https://www.djangoproject.com). The provided example was taken from a [django-nonrel](https://github.com/django-nonrel/django-nonrel) enviroment and is based on the way we use it at [Pulse](http://www.pulse.me). If you want to use it with App Engine's built-in Django or without Django at all, it should be relatively simple to take the core functionality in /gae-python-gcm/gcm.py and leave the rest. Feel free to contact me if you have any quesitons ([@gregbayer](https://twitter.com/gregbayer)). 15 | 16 | # Why not use Google's GCM Server referance implmentation 17 | 18 | We prefer to keep everything in Python instead of using the [GCM server referance implmentation](http://developer.android.com/google/gcm/demo.html) in Java. 19 | 20 | # Example 21 | 22 | ```python 23 | from gae_python_gcm.gcm import GCMMessage, GCMConnection 24 | 25 | gcm_message = GCMMessage(push_token, android_payload) 26 | gcm_conn = GCMConnection() 27 | gcm_conn.notify_device(gcm_message) 28 | ``` 29 | 30 | # Getting started 31 | 32 | To add gae-python-gcm to your AppEngine project without Django: 33 | 34 | 1. git clone git://github.com/gregbayer/gae-python-gcm.git 35 | 2. Add entries to your app.yaml and queue.yaml based on included files. 36 | 3. Copy the gae-python-gcm directory into your appengine project 37 | 4. Make sure you set YOUR-GCM-API-KEY in /gae_python_gcm/gcm.py or in the settings module. 38 | 39 | 40 | To add gae-python-gcm to your AppEngine project with Django: 41 | 42 | 1. git clone git://github.com/gregbayer/gae-python-gcm.git 43 | 2. Add entries to your app.yaml and queue.yaml based on included files. 44 | 3. Copy the gae-python-gcm directory into your appengine project 45 | 4. Configure your project as appropriate. You may find the urls.py and settings.py examples in the example_django_files directories useful. 46 | 5. Make sure you set YOUR-GCM-API-KEY in /gae_python_gcm/gcm.py or in the settings module. 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | application: gae-django-gcm 2 | version: 1 3 | runtime: python27 4 | api_version: 1 5 | threadsafe: false 6 | 7 | builtins: 8 | - remote_api: on 9 | 10 | inbound_services: 11 | - warmup 12 | 13 | handlers: 14 | 15 | # Optional django handlers 16 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | 2 | from gae_python_gcm.gcm import GCMMessage, GCMConnection 3 | 4 | push_token = 'YOUR_PUSH_TOKEN' 5 | android_payload = {'your-key': 'your-value'} 6 | 7 | gcm_message = GCMMessage(push_token, android_payload) 8 | gcm_conn = GCMConnection() 9 | logging.info("Attempting to send Android push notification %s to push_token %s." % (repr(android_payload), repr(push_token))) 10 | gcm_conn.notify_device(gcm_message) -------------------------------------------------------------------------------- /example_django_files/gae_python_gcm/urls.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # gae_python_gcm/urls.py 3 | # Greg Bayer 4 | ################################################################################ 5 | 6 | from django.conf.urls.defaults import patterns 7 | 8 | urlpatterns = patterns('gae_python_gcm.views', 9 | (r'^send_request', 'send_request_handler'), 10 | (r'^debug', 'debug_handler'), 11 | ) 12 | 13 | -------------------------------------------------------------------------------- /example_django_files/gae_python_gcm/views.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # gae_python_gcm/views.py 3 | # Greg Bayer 4 | ################################################################################ 5 | 6 | import logging 7 | from django.http import HttpResponse 8 | 9 | from gae_python_gcm.gcm import GCMConnection, GCMMessage 10 | 11 | 12 | # Try sending a single queued message (called by task queue for every queued message) 13 | def send_request_handler(request): 14 | try: 15 | device_tokens = request.REQUEST.get('device_tokens') 16 | notification = request.REQUEST.get('notification') 17 | collapse_key = request.REQUEST.get('collapse_key') 18 | delay_while_idle = request.REQUEST.get('delay_while_idle') 19 | time_to_live = request.REQUEST.get('time_to_live') 20 | 21 | 22 | if ',' in device_tokens: 23 | device_tokens = device_tokens.split(',') 24 | else: 25 | device_tokens = [device_tokens] 26 | message = GCMMessage(device_tokens, notification, collapse_key, delay_while_idle, time_to_live) 27 | 28 | gcm_connection = GCMConnection() 29 | gcm_connection._send_request(message) 30 | except: 31 | logging.exception('Error in send_request_handler') 32 | logging.info('message: ' + repr(message)) 33 | 34 | return HttpResponse() 35 | 36 | 37 | # Get debug stats 38 | def debug_handler(request): 39 | try: 40 | gcm_connection = GCMConnection() 41 | output = gcm_connection.debug('stats') 42 | output = output.replace('\n', '
\n') 43 | return HttpResponse(output) 44 | except: 45 | logging.exception('Error in debug_handler') 46 | 47 | return HttpResponse() 48 | 49 | 50 | -------------------------------------------------------------------------------- /example_django_files/settings.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # settings.py 3 | # Greg Bayer . 4 | ################################################################################ 5 | 6 | ############################################# 7 | # Calculate LOCALHOST flag 8 | ############################################# 9 | LOCALHOST = False 10 | try: 11 | if socket.gethostname().find('local') != -1: 12 | LOCALHOST = True 13 | else: 14 | LOCALHOST = False 15 | except: 16 | if ('Development' in os.environ['SERVER_SOFTWARE']): 17 | LOCALHOST = True 18 | else: 19 | LOCALHOST = False 20 | 21 | 22 | GCM_CONFIG = {'gcm_api_key': '', 23 | # 'delete_bad_token_callback_func': 'EXAMPLE_MANAGE_TOKENS_MODULE.delete_bad_gcm_token', 24 | # 'update_token_callback_func': 'EXAMPLE_MANAGE_TOKENS_MODULE.update_gcm_token', 25 | } 26 | 27 | 28 | -------------------------------------------------------------------------------- /example_django_files/urls.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # urls.py 3 | # Greg Bayer . 4 | ################################################################################ 5 | 6 | import os, logging 7 | from django.conf.urls.defaults import * 8 | 9 | urlpatterns = patterns('', 10 | # All urls from gae_python_gcm.urls are mapped to /gae_python_gcm/* 11 | (r'^gae_python_gcm/', include('gae_python_gcm.urls')), 12 | ) 13 | 14 | -------------------------------------------------------------------------------- /gae_python_gcm/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregbayer/gae-python-gcm/00c2d80791b740702a7a839a4e565a8af0328d34/gae_python_gcm/__init__.py -------------------------------------------------------------------------------- /gae_python_gcm/gcm.py: -------------------------------------------------------------------------------- 1 | ################################################################################ 2 | # gae_python_gcm/gcm.py 3 | # 4 | # In Python, for Google App Engine 5 | # Originally ported from https://github.com/Instagram/node2dm 6 | # Extended to support new GCM API. 7 | # Greg Bayer 8 | ################################################################################ 9 | 10 | from datetime import datetime, timedelta 11 | import logging 12 | import re 13 | import urllib, urllib2 14 | try: 15 | import json 16 | except ImportError: 17 | import simplejson as json 18 | 19 | from google.appengine.api import taskqueue ## Google App Engine specific 20 | 21 | from django.core.cache import cache 22 | from django.utils import importlib 23 | 24 | LOCALHOST = False 25 | GCM_CONFIG = {'gcm_api_key': '', 26 | # 'delete_bad_token_callback_func': 'EXAMPLE_MANAGE_TOKENS_MODULE.delete_bad_gcm_token', 27 | # 'update_token_callback_func': 'EXAMPLE_MANAGE_TOKENS_MODULE.update_gcm_token', 28 | } 29 | try: 30 | from settings import LOCALHOST, GCM_CONFIG 31 | except: 32 | logging.info('GCM settings module not found. Using defaults.') 33 | pass 34 | 35 | GOOGLE_LOGIN_URL = 'https://www.google.com/accounts/ClientLogin' 36 | # Can't use https on localhost due to Google cert bug 37 | GOOGLE_GCM_SEND_URL = 'http://android.apis.google.com/gcm/send' if LOCALHOST \ 38 | else 'https://android.apis.google.com/gcm/send' 39 | GOOGLE_GCM_SEND_URL = 'http://android.googleapis.com/gcm/send' if LOCALHOST \ 40 | else 'https://android.googleapis.com/gcm/send' 41 | 42 | GCM_QUEUE_NAME = 'gcm-retries' 43 | GCM_QUEUE_CALLBACK_URL = '/gae_python_gcm/send_request' 44 | 45 | # Memcache config 46 | MEMCACHE_PREFIX = 'GCMConnection:' 47 | # Memcached vars 48 | RETRY_AFTER = 'retry_after' 49 | TOTAL_ERRORS = 'total_errors' 50 | TOTAL_MESSAGES = 'total_messages' 51 | 52 | 53 | class GCMMessage: 54 | device_tokens = None 55 | notification = None 56 | collapse_key = None 57 | delay_while_idle = None 58 | time_to_live = None 59 | 60 | def __init__(self, device_tokens, notification, collapse_key=None, delay_while_idle=None, time_to_live=None): 61 | if isinstance(device_tokens, list): 62 | self.device_tokens = device_tokens 63 | else: 64 | self.device_tokens = [device_tokens] 65 | 66 | self.notification = notification 67 | self.collapse_key = collapse_key 68 | self.delay_while_idle = delay_while_idle 69 | self.time_to_live = time_to_live 70 | 71 | def __unicode__(self): 72 | return "%s:%s:%s:%s:%s" % (repr(self.device_tokens), repr(self.notification), repr(self.collapse_key), repr(self.delay_while_idle), repr(self.time_to_live)) 73 | 74 | def json_string(self): 75 | 76 | if not self.device_tokens or not isinstance(self.device_tokens, list): 77 | logging.error('GCMMessage generate_json_string error. Invalid device tokens: ' + repr(self)) 78 | raise Exception('GCMMessage generate_json_string error. Invalid device tokens.') 79 | 80 | json_dict = {} 81 | json_dict['registration_ids'] = self.device_tokens 82 | 83 | # If message is a dict, send each key individually 84 | # Else, send entire message under data key 85 | if isinstance(self.notification, dict): 86 | json_dict['data'] = self.notification 87 | else: 88 | json_dict['data'] = {'data': self.notification} 89 | 90 | if self.collapse_key: 91 | json_dict['collapse_key'] = self.collapse_key 92 | if self.delay_while_idle: 93 | json_dict['delay_while_idle'] = self.delay_while_idle 94 | if self.time_to_live: 95 | json_dict['time_to_live'] = self.time_to_live 96 | 97 | json_str = json.dumps(json_dict) 98 | return json_str 99 | 100 | # Instantiate to send GCM message. No initialization required. 101 | class GCMConnection: 102 | 103 | ################################ Config ############################### 104 | # settings.py 105 | # 106 | # GCM_CONFIG = {'gcm_api_key': '', 107 | # 'delete_bad_token_callback_func': lambda x: x, 108 | # 'update_token_callback_func': lambda x: x} 109 | ############################################################################## 110 | 111 | # Call this to send a push notification 112 | def notify_device(self, message, deferred=False): 113 | self._incr_memcached(TOTAL_MESSAGES, 1) 114 | self._submit_message(message, deferred=deferred) 115 | 116 | 117 | ##### Public Utils ##### 118 | 119 | def debug(self, option): 120 | if option == "help": 121 | return "Commands: help stats\n" 122 | 123 | elif option == "stats": 124 | output = '' 125 | # resp += "uptime: " + elapsed + " seconds\n" 126 | output += "messages_sent: " + str(self._get_memcached(TOTAL_MESSAGES)) + "\n" 127 | # resp += "messages_in_queue: " + str(self.pending_messages.length) + "\n" 128 | output += "backing_off_retry_after: " + str(self._get_memcached(RETRY_AFTER)) + "\n" 129 | output += "total_errors: " + str(self._get_memcached(TOTAL_ERRORS)) + "\n" 130 | 131 | return output 132 | 133 | else: 134 | return "Invalid command\nCommands: help stats\n" 135 | 136 | 137 | ##### Hooks - Override to change functionality ##### 138 | 139 | def delete_bad_token(self, bad_device_token): 140 | logging.info('delete_bad_token(): ' + repr(bad_device_token)) 141 | if 'delete_bad_token_callback_func' in GCM_CONFIG: 142 | bad_token_callback_func_path = GCM_CONFIG['delete_bad_token_callback_func'] 143 | mod_path, func_name = bad_token_callback_func_path.rsplit('.', 1) 144 | mod = importlib.import_module(mod_path) 145 | 146 | logging.info('delete_bad_token_callback_func: ' + repr((mod_path, func_name, mod))) 147 | 148 | bad_token_callback_func = getattr(mod, func_name) 149 | 150 | bad_token_callback_func(bad_device_token) 151 | 152 | 153 | def update_token(self, old_device_token, new_device_token): 154 | logging.info('update_token(): ' + repr((old_device_token, new_device_token))) 155 | if 'update_token_callback_func' in GCM_CONFIG: 156 | bad_token_callback_func_path = GCM_CONFIG['update_token_callback_func'] 157 | mod_path, func_name = bad_token_callback_func_path.rsplit('.', 1) 158 | mod = importlib.import_module(mod_path) 159 | 160 | logging.info('update_token_callback_func: ' + repr((mod_path, func_name, mod))) 161 | 162 | bad_token_callback_func = getattr(mod, func_name) 163 | 164 | bad_token_callback_func(old_device_token, new_device_token) 165 | 166 | 167 | # Currently unused 168 | def login_complete(self): 169 | # Retries are handled by the gae task queue 170 | # self.retry_pending_messages() 171 | pass 172 | 173 | 174 | ##### Helper functions ##### 175 | 176 | def _gcm_connection_memcache_key(self, variable_name): 177 | return 'GCMConnection:' + variable_name 178 | 179 | 180 | def _get_memcached(self, variable_name): 181 | memcache_key = self._gcm_connection_memcache_key(variable_name) 182 | return cache.get(memcache_key) 183 | 184 | 185 | def _set_memcached(self, variable_name, value, timeout=None): 186 | memcache_key = self._gcm_connection_memcache_key(variable_name) 187 | return cache.set(memcache_key, value, timeout=timeout) 188 | 189 | 190 | def _incr_memcached(self, variable_name, increment): 191 | memcache_key = self._gcm_connection_memcache_key(variable_name) 192 | try: 193 | return cache.incr(memcache_key, increment) 194 | except ValueError: 195 | return cache.set(memcache_key, increment) 196 | 197 | 198 | # Add message to queue 199 | def _requeue_message(self, message): 200 | taskqueue.add(queue_name=GCM_QUEUE_NAME, url=GCM_QUEUE_CALLBACK_URL, params={'device_token': message.device_tokens, 'collapse_key': message.collapse_key, 'notification': message.notification}) 201 | 202 | 203 | # If send message now or add it to the queue 204 | def _submit_message(self, message, deferred=False): 205 | if deferred: 206 | self._requeue_message(message) 207 | else: 208 | self._send_request(message) 209 | 210 | 211 | # Try sending message now 212 | def _send_request(self, message): 213 | if message.device_tokens == None or message.notification == None: 214 | logging.error('Message must contain device_tokens and notification.') 215 | return False 216 | 217 | # Check for resend_after 218 | retry_after = self._get_memcached(RETRY_AFTER) 219 | if retry_after != None and retry_after > datetime.now(): 220 | logging.warning('RETRY_AFTER: ' + repr(retry_after) + ', requeueing message: ' + repr(message)) 221 | self._requeue_message(message) 222 | return 223 | 224 | 225 | # Build request 226 | headers = { 227 | 'Authorization': 'key=' + GCM_CONFIG['gcm_api_key'], 228 | 'Content-Type': 'application/json' 229 | } 230 | 231 | gcm_post_json_str = '' 232 | try: 233 | gcm_post_json_str = message.json_string() 234 | except: 235 | logging.exception('Error generating json string for message: ' + repr(message)) 236 | return 237 | 238 | logging.info('Sending gcm_post_body: ' + repr(gcm_post_json_str)) 239 | 240 | request = urllib2.Request(GOOGLE_GCM_SEND_URL, gcm_post_json_str, headers) 241 | 242 | # Post 243 | try: 244 | resp = urllib2.urlopen(request) 245 | resp_json_str = resp.read() 246 | resp_json = json.loads(resp_json_str) 247 | logging.info('_send_request() resp_json: ' + repr(resp_json)) 248 | 249 | # multicast_id = resp_json['multicast_id'] 250 | # success = resp_json['success'] 251 | failure = resp_json['failure'] 252 | canonical_ids = resp_json['canonical_ids'] 253 | results = resp_json['results'] 254 | 255 | # If the value of failure and canonical_ids is 0, it's not necessary to parse the remainder of the response. 256 | if failure == 0 and canonical_ids == 0: 257 | # Success, nothing to do 258 | return 259 | else: 260 | # Process result messages for each token (result index matches original token index from message) 261 | result_index = 0 262 | for result in results: 263 | 264 | if 'message_id' in result and 'registration_id' in result: 265 | # Update device token 266 | try: 267 | old_device_token = message.device_tokens[result_index] 268 | new_device_token = result['registration_id'] 269 | self.update_token(old_device_token, new_device_token) 270 | except: 271 | logging.exception('Error updating device token') 272 | 273 | elif 'error' in result: 274 | # Handle GCM error 275 | error_msg = result.get('error') 276 | try: 277 | device_token = message.device_tokens[result_index] 278 | self._on_error(device_token, error_msg, message) 279 | except: 280 | logging.exception('Error handling GCM error: ' + repr(error_msg)) 281 | 282 | result_index += 1 283 | 284 | except urllib2.HTTPError, e: 285 | self._incr_memcached(TOTAL_ERRORS, 1) 286 | 287 | if e.code == 400: 288 | logging.error('400, Invalid GCM JSON message: ' + repr(gcm_post_json_str)) 289 | elif e.code == 401: 290 | logging.error('401, Error authenticating with GCM. Retrying message. Might need to fix auth key!') 291 | self._requeue_message(message) 292 | elif e.code == 500: 293 | logging.error('500, Internal error in the GCM server while trying to send message: ' + repr(gcm_post_json_str)) 294 | elif e.code == 503: 295 | retry_seconds = int(resp.headers.get('Retry-After')) or 10 296 | logging.error('503, Throttled. Retry after delay. Requeuing message. Delay in seconds: ' + str(retry_seconds)) 297 | retry_timestamp = datetime.now() + timedelta(seconds=retry_seconds) 298 | self._set_memcached(RETRY_AFTER, retry_timestamp) 299 | self._requeue_message(message) 300 | else: 301 | logging.exception('Unexpected HTTPError: ' + str(e.code) + " " + e.msg + " " + e.read()) 302 | 303 | 304 | def _on_error(self, device_token, error_msg, message): 305 | self._incr_memcached(TOTAL_ERRORS, 1) 306 | 307 | if error_msg == "MissingRegistration": 308 | logging.error('ERROR: GCM message sent without device token. This should not happen!') 309 | 310 | elif error_msg == "InvalidRegistration": 311 | self.delete_bad_token(device_token) 312 | 313 | elif error_msg == "MismatchSenderId": 314 | logging.error('ERROR: Device token is tied to a different sender id: ' + repr(device_token)) 315 | self.delete_bad_token(device_token) 316 | 317 | elif error_msg == "NotRegistered": 318 | self.delete_bad_token(device_token) 319 | 320 | elif error_msg == "MessageTooBig": 321 | logging.error("ERROR: GCM message too big (max 4096 bytes).") 322 | 323 | elif error_msg == "InvalidTtl": 324 | logging.error("ERROR: GCM Time to Live field must be an integer representing a duration in seconds between 0 and 2,419,200 (4 weeks).") 325 | 326 | elif error_msg == "MessageTooBig": 327 | logging.error("ERROR: GCM message too big (max 4096 bytes).") 328 | 329 | elif error_msg == "Unavailable": 330 | retry_seconds = 10 331 | logging.error('ERROR: GCM Unavailable. Retry after delay. Requeuing message. Delay in seconds: ' + str(retry_seconds)) 332 | retry_timestamp = datetime.now() + timedelta(seconds=retry_seconds) 333 | self._set_memcached(RETRY_AFTER, retry_timestamp) 334 | self._requeue_message(message) 335 | 336 | elif error_msg == "InternalServerError": 337 | logging.error("ERROR: Internal error in the GCM server while trying to send message: " + repr(message)) 338 | 339 | else: 340 | logging.error("Unknown error: %s for device token: %s" % (repr(error_msg), repr(device_token))) 341 | 342 | 343 | 344 | 345 | -------------------------------------------------------------------------------- /queue.yaml: -------------------------------------------------------------------------------- 1 | queue: 2 | - name: gcm-retries 3 | rate: 500/s 4 | bucket_size: 100 5 | retry_parameters: 6 | min_backoff_seconds: 10 7 | max_backoff_seconds: 4000 8 | max_doublings: 8 9 | task_age_limit: 1d 10 | --------------------------------------------------------------------------------