diff --git a/README.md b/README.md index 9d122d3..868e312 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,17 @@ These methods can be called on the server you started the server on. Be sure you Array(Array(Datetime(time_expired), String(token)), ...) +### blacklist + + Arguments + app_id String the application id to retrieve + retrieve blacklisted + (invalid) tokens for + + Returns + Array(String(token), ...) + + ### The Python API pyapns also provides a Python API that makes the use of pyapns even simpler. The Python API must be configured before use but configuration files make it easier. The pyapns `client` module currently supports configuration from Django settings and Pylons config. To configure using Django, the following must be present in your settings file: @@ -193,6 +204,21 @@ Each of these functions can be called synchronously and asynchronously. To make Returns: List of feedback tuples like [(datetime_expired, token_str), ...] +### `pyapns.client.blacklist(app_id, async=False, callback=None, errback=None)` + + Retrieves a list of blacklisted (invalid) tokens from the pyapns server. + + Arguments: + app_id the app_id to query + async pass something truthy to execute the request in + a background thread + callback a function to be executed with the result + errback a function to be executed with the error if there + is one during the request + + Returns: + List of blacklisted token strings like [String(token), ...] + ## The Ruby API diff --git a/pyapns/__init__.py b/pyapns/__init__.py index aa3eb86..79f6769 100644 --- a/pyapns/__init__.py +++ b/pyapns/__init__.py @@ -1 +1 @@ -from .client import notify, provision, feedback, configure __version__ = "0.4.0" __author__ = "Samuel Sutch" __license__ = "MIT" __copyright__ = "Copyrighit 2012 Samuel Sutch" \ No newline at end of file +from .client import notify, provision, feedback, blacklist, configure __version__ = "0.4.0" __author__ = "Samuel Sutch" __license__ = "MIT" __copyright__ = "Copyrighit 2012 Samuel Sutch" diff --git a/pyapns/client.py b/pyapns/client.py index ca106a2..8d25c42 100644 --- a/pyapns/client.py +++ b/pyapns/client.py @@ -110,6 +110,18 @@ def feedback(app_id, async=False, callback=None, errback=None): t.daemon = True t.start() + +@default_callback +@reprovision_and_retry +def blacklist(app_id, async=False, callback=None, errback=None): + args = [app_id] + f_args = ['blacklist', args, callback, errback] + if not async: + return _xmlrpc_thread(*f_args) + t = threading.Thread(target=_xmlrpc_thread, args=f_args) + t.daemon = True + t.start() + def _xmlrpc_thread(method, args, callback, errback=None): if not configure({}): raise APNSNotConfigured('APNS Has not been configured.') diff --git a/pyapns/server.py b/pyapns/server.py index 3c021bd..0d57895 100644 --- a/pyapns/server.py +++ b/pyapns/server.py @@ -3,6 +3,7 @@ import struct import binascii import datetime +import time from StringIO import StringIO as _StringIO from OpenSSL import SSL, crypto from twisted.internet import reactor, defer @@ -22,8 +23,12 @@ FEEDBACK_SERVER_SANDBOX_HOSTNAME = "feedback.sandbox.push.apple.com" FEEDBACK_SERVER_HOSTNAME = "feedback.push.apple.com" FEEDBACK_SERVER_PORT = 2196 +MAX_TOKEN_ID = 1024 -app_ids = {} # {'app_id': APNSService()} +token_id = 0 +current_tokens = {} # dict to hold current token ids +app_ids = {} # {'app_id': APNSService()} +bad_tokens = {} # {'app_id': []} class StringIO(_StringIO): """Add context management protocol to StringIO @@ -69,17 +74,25 @@ def getContext(self): class APNSProtocol(Protocol): + def __init__(self): + self.appId = None def connectionMade(self): log.msg('APNSProtocol connectionMade') self.factory.addClient(self) def sendMessage(self, msg): - log.msg('APNSProtocol sendMessage msg=%s' % binascii.hexlify(msg)) - return self.transport.write(msg) + self.appId = msg['appid'] + log.msg('APNSProtocol sendMessage msg=%s' % binascii.hexlify(msg['msg'])) + return self.transport.write(msg['msg']) def connectionLost(self, reason): log.msg('APNSProtocol connectionLost') self.factory.removeClient(self) + + def dataReceived(self, data): + fmt = '!BBI' + message = struct.unpack(fmt, data) + self.factory.addToBlacklist( self.appId, current_tokens[message[2]], message[1]) class APNSFeedbackHandler(LineReceiver): @@ -87,7 +100,7 @@ class APNSFeedbackHandler(LineReceiver): def connectionMade(self): log.msg('feedbackHandler connectionMade') - + def rawDataReceived(self, data): log.msg('feedbackHandler rawDataReceived %s' % binascii.hexlify(data)) self.io.write(data) @@ -148,6 +161,13 @@ def removeClient(self, p): def startedConnecting(self, connector): log.msg('APNSClientFactory startedConnecting') + def addToBlacklist(self, appId, token, reason): + log.msg('WARNING - blacklisting AppId: %s token: %s reason: %s' % (appId, token, reason)) + global bad_tokens + if not bad_tokens.get(appId, None): + bad_tokens[appId] = [] + bad_tokens[appId].append(token) + def buildProtocol(self, addr): self.resetDelay() p = self.protocol() @@ -273,7 +293,7 @@ def xmlrpc_provision(self, app_id, path_to_cert_or_cert, environment, timeout=15 # log.msg('provisioning ' + app_id + ' environment ' + environment) self.app_ids[app_id] = APNSService(path_to_cert_or_cert, environment, timeout) - def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list): + def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list, expiry_or_expiry_list=None): """ Sends push notifications to the Apple APNS server. Multiple notifications can be sent by sending pairing the token/notification arguments in lists [token1, token2], [notification1, notification2]. @@ -281,7 +301,8 @@ def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list): Arguments: app_id provisioned app_id to send to token_or_token_list token to send the notification or a list of tokens - aps_dict_or_list notification dicts or a list of notifications + aps_dict_or_list notification dicts or a list of notifications + expiry_or_expiry_list epoch expiry timestamp or list of timestamps, default now Returns: None """ @@ -290,7 +311,7 @@ def xmlrpc_notify(self, app_id, token_or_token_list, aps_dict_or_list): [t.replace(' ', '') for t in token_or_token_list] if (type(token_or_token_list) is list) else token_or_token_list.replace(' ', ''), - aps_dict_or_list)) + aps_dict_or_list, expiry_or_expiry_list, app_id)) if d: def _finish_err(r): # so far, the only error that could really become of this @@ -312,24 +333,65 @@ def xmlrpc_feedback(self, app_id): return self.apns_service(app_id).read().addCallback( lambda r: decode_feedback(r)) + + def xmlrpc_blacklist(self, app_id): + """ Queries blacklisted (invalid) tokens. Returns a list of token Strings. The List in pyapns will be cleared. + + Arguments: + app_id the app_id to query + Returns: + Blacklisted token Strings as List + """ + + if bad_tokens.get(app_id, None): + return bad_tokens.pop(app_id) + else: + return [] - -def encode_notifications(tokens, notifications): +def encode_notifications(tokens, notifications, expirys, appId): """ Returns the encoded bytes of tokens and notifications tokens a list of tokens or a string of only one token - notifications a list of notifications or a dictionary of only one + notifications a list of notifications or a dictionary of only one + timeouts a list of timeout values """ - fmt = "!BH32sH%ds" - structify = lambda t, p: struct.pack(fmt % len(p), 0, 32, t, len(p), p) + fmt = "!BIIH32sH%ds" + structify = lambda t, p, e, ti: struct.pack(fmt % len(p), 1, ti, e, 32, t, len(p), p) binaryify = lambda t: t.decode('hex') if type(notifications) is dict and type(tokens) in (str, unicode): tokens, notifications = ([tokens], [notifications]) - if type(notifications) is list and type(tokens) is list: - return ''.join(map(lambda y: structify(*y), ((binaryify(t), json.dumps(p, separators=(',',':'))) - for t, p in zip(tokens, notifications)))) - + if expirys is None: + expirys = time.time() + expirys = [expirys]*len(notifications) + log.msg('expirys: %s' % expirys) + log.msg('tokens: %s' % tokens) + log.msg('notifications: %s' % notifications) + if type(notifications) is list and type(tokens) is list and type(expirys) is list: + ids = [] + global token_id + global MAX_TOKEN_ID + global bad_tokens + for t in tokens: + if bad_tokens.get(appId, None): + if t in bad_tokens[appId]: + log.msg('WARNING - ignoring bad token: %s for appId: %s' % (t, appId)) + index = tokens.index(t) + tokens.pop(index) + notifications.pop(index) + expirys.pop(index) + break + if token_id == MAX_TOKEN_ID: + token_id = 0 + token_id += 1 + current_tokens[token_id] = t + ids.append(token_id) + messages = zip(tokens, notifications, expirys, ids) + log.msg('messages: %s' % messages) + return {'appid': appId, + 'msg': ''.join(map(lambda y: structify(*y), ((binaryify(t), json.dumps(n, separators=(',',':')), int(e), i) + for t, n, e, i in messages)))} + def decode_feedback(binary_tuples): """ Returns a list of tuples in (datetime, token_str) format diff --git a/ruby-client/pyapns/lib/pyapns.rb b/ruby-client/pyapns/lib/pyapns.rb index 66767b3..3c791af 100644 --- a/ruby-client/pyapns/lib/pyapns.rb +++ b/ruby-client/pyapns/lib/pyapns.rb @@ -15,8 +15,8 @@ module PYAPNS ## PYAPNS::Client ## There's python in my ruby! ## - ## This is a class used to send notifications, provision applications and - ## retrieve feedback using the Apple Push Notification Service. + ## This is a class used to send notifications, provision applications query invalid tokens + ## and retrieve feedback using the Apple Push Notification Service. ## ## PYAPNS is a multi-application APS provider, meaning it is possible to send ## notifications to any number of different applications from the same application @@ -107,12 +107,27 @@ module PYAPNS ## searching for or comparing the token received in the hexadecimal form returned, ## note that it's lowercase hex. ## + ## Retrieving Blacklist + ## + ## The pyapns maintains a blacklist that stores invalid tokens (i.e. sandbox + ## tokens in production) Such invalid tokens cause a drop of the APNS connection. + ## PYAPNS will detect the invalid tokens causing a connection drop and add them + ## to a blacklist on an Application basis. The blacklist is used to filter out + ## invalid tokens in order to prevent connection drops + ## PYAPNS will return an Array of tokens: + ## + ## blacklist = client.blacklist 'cf' + ## => ['token', ... ] + ## + ## Note that once you query the blacklist, it will be removed so you need to ensure + ## that this tokens get de-provisioned from your push token list + ## ## Asynchronous Calls ## ## PYAPNS::Client will, by default, perform no funny stuff and operate entirely ## within the calling thread. This means that certain applications may hang when, ## say, sending a notification, if only for a fraction of a second. Obviously - ## not a desirable trait, all `provision`, `feedback` and `notify` + ## not a desirable trait, all `provision`, `feedback`, `blacklist` and `notify` ## methods also take a block, which indicates to the method you want to call ## PYAPNS asynchronously, and it will be done so handily in another thread, calling ## back your block with a single argument when finished. Note that `notify` and `provision` @@ -159,6 +174,10 @@ def notify(*args, &block) def feedback(*args, &block) perform_call :feedback, args, :app_id, &block end + + def blacklist(*args, &block) + perform_call :blacklist, args, :app_id, &block + end def perform_call(method, splat, *args, &block) if !configured?