From c97cb24f90c71041b79b261355187cfb3a590ff4 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Fri, 29 Oct 2010 11:35:42 -0700 Subject: [PATCH 1/2] Change user channel subscriptions to track connections A user channel subscription tracks the connections that have initiated the subscription - when all connections associated with the subscription have been removed, the user is unsubscribed from the channel. The connection that first successfully subscribes the user to a channel is associated with the user channel subscription. Subsequent connections that attempt to subscribe the user to the channel are also tracked (note, however, that the webhook is not called for these subsequent connections, since it is assumed that the first connection's success extends to them as well), so the first connection may be closed without unsubscribing the user, so long as a later connection remains open. --- hookbox/channel.py | 14 +++----------- hookbox/user.py | 34 ++++++++++++++++++++++------------ 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/hookbox/channel.py b/hookbox/channel.py index 3207a1d..9ee856b 100644 --- a/hookbox/channel.py +++ b/hookbox/channel.py @@ -45,15 +45,7 @@ def __init__(self, server, name, **options): #print 'self._options is', self._options self.state = {} self.update_options(**self._options) - self.update_options(**options) - - def user_disconnected(self, user): - # TODO: remove this pointless check, it should never happen, right? - if user not in self.subscribers: - return - self.unsubscribe(user, needs_auth=True, force_auth=True) - - + self.update_options(**options) def set_history(self, history): self.history = history @@ -192,8 +184,8 @@ def publish(self, user, payload, needs_auth=True, conn=None, **kwargs): self.prune_history() def subscribe(self, user, conn=None, needs_auth=True): - if user in self.subscribers: + user.channel_subscribed(self, conn=conn) return has_initial_data = False @@ -214,7 +206,7 @@ def subscribe(self, user, conn=None, needs_auth=True): user.send_frame('CHANNEL_INIT', frame) self.subscribers.append(user) - user.channel_subscribed(self) + user.channel_subscribed(self, conn=conn) _now = get_now() frame = {"channel_name": self.name, "user": user.get_name(), "datetime": _now} self.server.admin.channel_event('subscribe', self.name, frame) diff --git a/hookbox/user.py b/hookbox/user.py index 9d8073d..483aed5 100644 --- a/hookbox/user.py +++ b/hookbox/user.py @@ -14,7 +14,7 @@ def __init__(self, server, name): self.server = server self.name = name self.connections = [] - self.channels = [] + self.channels = {} self._temp_cookie = "" def serialize(self): return { @@ -36,23 +36,33 @@ def _send_initial_subscriptions(self, conn): def remove_connection(self, conn): self.connections.remove(conn) + + # Remove the connection from the channels it was subscribed to, + # unsubscribing the user from any channels which they no longer + # have open connections to + for (channel, channel_connections) in self.channels.items(): + if conn not in channel_connections: + continue + self.channels[channel].remove(conn) + if not self.channels[channel]: + channel.unsubscribe(self, needs_auth=True, force_auth=True) + if not self.connections: - # each call to user_disconnected might result in an immediate call - # to self.channel_unsubscribed, thus modifying self.channels and - # messing up our loop. So we loop over a copy of self.channels... - - for channel in self.channels[:]: - channel.user_disconnected(self) -# print 'tell server to remove user...' + for (channel, connections) in self.channels.items(): + channel.unsubscribe(self, needs_auth=True, force_auth=True) # so the disconnect callback has a cookie self._temp_cookie = conn.get_cookie() self.server.remove_user(self.name) - def channel_subscribed(self, channel): - self.channels.append(channel) + def channel_subscribed(self, channel, conn=None): + if channel not in self.channels: + self.channels[channel] = [ conn ] + elif conn not in self.channels[channel]: + self.channels[channel].append(conn) def channel_unsubscribed(self, channel): - self.channels.remove(channel) + if channel in self.channels: + del self.channels[channel] def get_name(self): return self.name @@ -87,4 +97,4 @@ def send_message(self, recipient, payload, conn=None, needs_auth=True): if recipient.name != self.name: self.send_frame('MESSAGE', frame) - + From 0fdd8f7bcd6bf41103ea743e69d08d55baf32747 Mon Sep 17 00:00:00 2001 From: Jordan Phillips Date: Sun, 7 Nov 2010 20:12:31 -0800 Subject: [PATCH 2/2] Add user option for per-connection-subscriptions --- docs/source/web.rst | 81 +++++++++++++++++++++++++++++++++++++++++++ hookbox/user.py | 83 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/docs/source/web.rst b/docs/source/web.rst index 5047a12..958ef7f 100644 --- a/docs/source/web.rst +++ b/docs/source/web.rst @@ -354,3 +354,84 @@ Server Replies: The callback host is now set to ``1.2.3.4`` and the port is now ``80``. + + +get_user_info +================ + +Returns all settings and attributes of a user. + +Required Form Variables: + +* ``security_token``: The password specified in the config as ``-r`` or ``--api-security-token``. +* ``user_name``: The target user. + +Returns json: + +[ success (boolean) , details (object) ] + +Example: + +Client Requests URL: + +.. sourcecode:: none + + /web/get_user_info?security_token=yo&user_name=mcarter + + +Server Replies: + + +.. sourcecode:: javascript + + [ + true, + { + "channels": [ + "testing" + ], + "connections": [ + "467412414c294f1a9d1759ace01455d9" + ], + "name": "mcarter", + "options": { + "reflective": true, + "moderated_message": true, + "per_connection_subscriptions": false + } + } + ] + + +set_user_options +=================== + +Set the options for a user. + +Required Form Variables: + +* ``security_token``: The password specified in the config as ``-r`` or ``--api-security-token``. +* ``user_name``: The target user. + +Optional Form Variables: + +* ``reflective``: json boolean - if true, private messages sent by this user will also be sent back to the user +* ``moderated_message``: json boolean - if true, private messages sent by this user will call the message webhook +* ``per_connection_subscriptions``: json boolean - if true, only the user connection (or connections) that sends a subscribe frame will be subscribed to the specified channel. Otherwise, all of a user's connections will share channel subscriptions established by any of the connections. + +Example: + +Client Requests URL: + +.. sourcecode:: none + + /web/set_user_options?security_token=yo&user_name=mcarter&reflective=false + + +Server Replies: + +.. sourcecode:: javascript + + [ true, {} ] + +The ``reflective`` of the user is now `false`. diff --git a/hookbox/user.py b/hookbox/user.py index 483aed5..d599ba4 100644 --- a/hookbox/user.py +++ b/hookbox/user.py @@ -10,18 +10,69 @@ def get_now(): return datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S') class User(object): - def __init__(self, server, name): + _options = { + 'reflective': True, + 'moderated_message': True, + 'per_connection_subscriptions': False, + } + + def __init__(self, server, name, **options): self.server = server self.name = name self.connections = [] self.channels = {} self._temp_cookie = "" + self.update_options(**self._options) + self.update_options(**options) + def serialize(self): return { 'channels': [ chan.name for chan in self.channels ], 'connections': [ conn.id for conn in self.connections ], - 'name': self.name + 'name': self.name, + 'options': dict([ (key, getattr(self, key)) for key in self._options]) } + + def update_options(self, **options): + # TODO: this can't remain so generic forever. At some point we need + # better checks on values, such as the list of dictionaries + # for history, or the polling options. + # TODO: add support for lists (we only have dicts now) + # TODO: Probably should make this whole function recursive... though + # we only really have one level of nesting now. + # TODO: most of this function is duplicated from Channel#update_options + # (including the TODOs above), could be a lot DRYer + for key, val in options.items(): + if key not in self._options: + raise ValueError("Invalid keyword argument %s" % (key)) + default = self._options[key] + cls = default.__class__ + if cls in (unicode, str): + cls = basestring + if not isinstance(val, cls): + raise ValueError("Invalid type for %s (should be %s)" % (key, default.__class__)) + if key == 'state': + self.state_replace(val) + continue + if isinstance(val, dict): + for _key, _val in val.items(): + if _key not in self._options[key]: + raise ValueError("Invalid keyword argument %s" % (_key)) + default = self._options[key][_key] + cls = default.__class__ + if isinstance(default, float) and isinstance(_val, int): + _val = float(_val) + if cls in (unicode, str): + cls = basestring + if not isinstance(_val, cls): + raise ValueError("%s is Invalid type for %s (should be %s)" % (_val, _key, default.__class__)) + # two loops forces exception *before* any of the options are set. + for key, val in options.items(): + # this should create copies of any dicts or lists that are options + if isinstance(val, dict) and hasattr(self, key): + getattr(self, key).update(val) + else: + setattr(self, key, val.__class__(val)) def add_connection(self, conn): self.connections.append(conn) @@ -44,7 +95,7 @@ def remove_connection(self, conn): if conn not in channel_connections: continue self.channels[channel].remove(conn) - if not self.channels[channel]: + if not self.channels[channel] and self.per_connection_subscriptions: channel.unsubscribe(self, needs_auth=True, force_auth=True) if not self.connections: @@ -67,8 +118,12 @@ def channel_unsubscribed(self, channel): def get_name(self): return self.name - def send_frame(self, name, args={}, omit=None): - for conn in self.connections: + def send_frame(self, name, args={}, omit=None, channel=None): + if not self.per_connection_subscriptions: + channel = None + if channel and channel not in self.channels: + return + for conn in (self.channels[channel] if channel else self.connections): if conn is not omit: conn.send_frame(name, args) @@ -78,23 +133,29 @@ def get_cookie(self, conn=None): return self._temp_cookie or "" - def send_message(self, recipient, payload, conn=None, needs_auth=True): + def send_message(self, recipient_name, payload, conn=None, needs_auth=True): try: encoded_payload = json.loads(payload) except: raise ExpectedException("Invalid json for payload") payload = encoded_payload - if needs_auth: - form = { 'sender': self.get_name(), 'recipient': recipient.get_name(), 'payload': json.dumps(payload) } + if needs_auth and self.moderated_message: + form = { 'sender': self.get_name(), 'recipient': recipient_name, 'recipient_exists': self.server.exists_user(recipient_name), 'payload': json.dumps(payload) } success, options = self.server.http_request('message', self.get_cookie(conn), form, conn=conn) self.server.maybe_auto_subscribe(self, options, conn=conn) if not success: raise ExpectedException(options.get('error', 'Unauthorized')) payload = options.get('override_payload', payload) + recipient_name = options.get('override_recipient_name', recipient_name) + elif not self.server.exists_user(recipient_name): + raise ExpectedException('Invalid user name') + + recipient = self.server.get_user(recipient_name) if self.server.exists_user(recipient_name) else None - frame = {"sender": self.get_name(), "recipient": recipient.get_name(), "payload": payload, "datetime": get_now()} - recipient.send_frame('MESSAGE', frame) - if recipient.name != self.name: + frame = {"sender": self.get_name(), "recipient": recipient.get_name() if recipient else "null", "payload": payload, "datetime": get_now()} + if recipient: + recipient.send_frame('MESSAGE', frame) + if self.reflective and (not recipient or recipient.name != self.name): self.send_frame('MESSAGE', frame)