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/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..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.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) @@ -36,29 +87,43 @@ 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] and self.per_connection_subscriptions: + 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 - 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) @@ -68,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) - +