summary refs log tree commit diff
path: root/cmdclient/console.py
diff options
context:
space:
mode:
Diffstat (limited to 'cmdclient/console.py')
-rwxr-xr-xcmdclient/console.py699
1 files changed, 699 insertions, 0 deletions
diff --git a/cmdclient/console.py b/cmdclient/console.py
new file mode 100755
index 0000000000..595ce429cd
--- /dev/null
+++ b/cmdclient/console.py
@@ -0,0 +1,699 @@
+#! /usr/bin/env python
+""" Starts a synapse client console. """
+
+from twisted.internet import reactor, defer, threads
+from http import TwistedHttpClient
+
+import argparse
+import cmd
+import getpass
+import json
+import shlex
+import sys
+import time
+import urllib
+import urlparse
+
+import nacl.signing
+import nacl.encoding
+
+from syutil.crypto.jsonsign import verify_signed_json, SignatureVerifyException
+
+CONFIG_JSON = "cmdclient_config.json"
+
+TRUSTED_ID_SERVERS = [
+    'localhost:8001'
+]
+
+class SynapseCmd(cmd.Cmd):
+
+    """Basic synapse command-line processor.
+
+    This processes commands from the user and calls the relevant HTTP methods.
+    """
+
+    def __init__(self, http_client, server_url, identity_server_url, username, token):
+        cmd.Cmd.__init__(self)
+        self.http_client = http_client
+        self.http_client.verbose = True
+        self.config = {
+            "url": server_url,
+            "identityServerUrl": identity_server_url,
+            "user": username,
+            "token": token,
+            "verbose": "on",
+            "complete_usernames": "on",
+            "send_delivery_receipts": "on"
+        }
+        self.path_prefix = "/matrix/client/api/v1"
+        self.event_stream_token = "START"
+        self.prompt = ">>> "
+
+    def do_EOF(self, line):  # allows CTRL+D quitting
+        return True
+
+    def emptyline(self):
+        pass  # else it repeats the previous command
+
+    def _usr(self):
+        return self.config["user"]
+
+    def _tok(self):
+        return self.config["token"]
+
+    def _url(self):
+        return self.config["url"] + self.path_prefix
+
+    def _identityServerUrl(self):
+        return self.config["identityServerUrl"]
+
+    def _is_on(self, config_name):
+        if config_name in self.config:
+            return self.config[config_name] == "on"
+        return False
+
+    def _domain(self):
+        return self.config["user"].split(":")[1]
+
+    def do_config(self, line):
+        """ Show the config for this client: "config"
+        Edit a key value mapping: "config key value" e.g. "config token 1234"
+        Config variables:
+            user: The username to auth with.
+            token: The access token to auth with.
+            url: The url of the server.
+            verbose: [on|off] The verbosity of requests/responses.
+            complete_usernames: [on|off] Auto complete partial usernames by
+            assuming they are on the same homeserver as you.
+            E.g. name >> @name:yourhost
+            send_delivery_receipts: [on|off] Automatically send receipts to
+            messages when performing a 'stream' command.
+        Additional key/values can be added and can be substituted into requests
+        by using $. E.g. 'config roomid room1' then 'raw get /rooms/$roomid'.
+        """
+        if len(line) == 0:
+            print json.dumps(self.config, indent=4)
+            return
+
+        try:
+            args = self._parse(line, ["key", "val"], force_keys=True)
+
+            # make sure restricted config values are checked
+            config_rules = [  # key, valid_values
+                ("verbose", ["on", "off"]),
+                ("complete_usernames", ["on", "off"]),
+                ("send_delivery_receipts", ["on", "off"])
+            ]
+            for key, valid_vals in config_rules:
+                if key == args["key"] and args["val"] not in valid_vals:
+                    print "%s value must be one of %s" % (args["key"],
+                                                          valid_vals)
+                    return
+
+            # toggle the http client verbosity
+            if args["key"] == "verbose":
+                self.http_client.verbose = "on" == args["val"]
+
+            # assign the new config
+            self.config[args["key"]] = args["val"]
+            print json.dumps(self.config, indent=4)
+
+            save_config(self.config)
+        except Exception as e:
+            print e
+
+    def do_register(self, line):
+        """Registers for a new account: "register <userid> <noupdate>"
+        <userid> : The desired user ID
+        <noupdate> : Do not automatically clobber config values.
+        """
+        args = self._parse(line, ["userid", "noupdate"])
+        path = "/register"
+
+        password = None
+        pwd = None
+        pwd2 = "_"
+        while pwd != pwd2:
+            pwd = getpass.getpass("(Optional) Type a password for this user: ")
+            if len(pwd) == 0:
+                print "Not using a password for this user."
+                break
+            pwd2 = getpass.getpass("Retype the password: ")
+            if pwd != pwd2:
+                print "Password mismatch."
+            else:
+                password = pwd
+
+        body = {}
+        if "userid" in args:
+            body["user_id"] = args["userid"]
+        if password:
+            body["password"] = password
+
+        reactor.callFromThread(self._do_register, "POST", path, body,
+                               "noupdate" not in args)
+
+    @defer.inlineCallbacks
+    def _do_register(self, method, path, data, update_config):
+        url = self._url() + path
+        json_res = yield self.http_client.do_request(method, url, data=data)
+        print json.dumps(json_res, indent=4)
+        if update_config and "user_id" in json_res:
+            self.config["user"] = json_res["user_id"]
+            self.config["token"] = json_res["access_token"]
+            save_config(self.config)
+
+    def do_login(self, line):
+        """Login as a specific user: "login @bob:localhost"
+        You MAY be prompted for a password, or instructed to visit a URL.
+        """
+        try:
+            args = self._parse(line, ["user_id"], force_keys=True)
+            can_login = threads.blockingCallFromThread(
+                reactor,
+                self._check_can_login)
+            if can_login:
+                p = getpass.getpass("Enter your password: ")
+                user = args["user_id"]
+                if self._is_on("complete_usernames") and not user.startswith("@"):
+                    user = "@" + user + ":" + self._domain()
+
+                reactor.callFromThread(self._do_login, user, p)
+                print " got %s " % p
+        except Exception as e:
+            print e
+
+    @defer.inlineCallbacks
+    def _do_login(self, user, password):
+        path = "/login"
+        data = {
+            "user": user,
+            "password": password,
+            "type": "m.login.password"
+        }
+        url = self._url() + path
+        json_res = yield self.http_client.do_request("POST", url, data=data)
+        print json_res
+
+        if "access_token" in json_res:
+            self.config["user"] = user
+            self.config["token"] = json_res["access_token"]
+            save_config(self.config)
+            print "Login successful."
+
+    @defer.inlineCallbacks
+    def _check_can_login(self):
+        path = "/login"
+        # ALWAYS check that the home server can handle the login request before
+        # submitting!
+        url = self._url() + path
+        json_res = yield self.http_client.do_request("GET", url)
+        print json_res
+
+        if ("type" not in json_res or "m.login.password" != json_res["type"] or
+                "stages" in json_res):
+            fallback_url = self._url() + "/login/fallback"
+            print ("Unable to login via the command line client. Please visit "
+                "%s to login." % fallback_url)
+            defer.returnValue(False)
+        defer.returnValue(True)
+
+    def do_3pidrequest(self, line):
+        """Requests the association of a third party identifier
+        <medium> The medium of the identifer (currently only 'email')
+        <address> The address of the identifer (ie. the email address)
+        """
+        args = self._parse(line, ['medium', 'address'])
+
+        if not args['medium'] == 'email':
+            print "Only email is supported currently"
+            return
+
+        postArgs = {'email': args['address'], 'clientSecret': '____'}
+
+        reactor.callFromThread(self._do_3pidrequest, postArgs)
+
+    @defer.inlineCallbacks
+    def _do_3pidrequest(self, args):
+        url = self._identityServerUrl()+"/matrix/identity/api/v1/validate/email/requestToken"
+
+        json_res = yield self.http_client.do_request("POST", url, data=urllib.urlencode(args), jsonreq=False,
+                                                     headers={'Content-Type': ['application/x-www-form-urlencoded']})
+        print json_res
+        if 'tokenId' in json_res:
+            print "Token ID %s sent" % (json_res['tokenId'])
+
+    def do_3pidvalidate(self, line):
+        """Validate and associate a third party ID
+        <medium> The medium of the identifer (currently only 'email')
+        <tokenId> The identifier iof the token given in 3pidrequest
+        <token> The token sent to your third party identifier address
+        """
+        args = self._parse(line, ['medium', 'tokenId', 'token'])
+
+        if not args['medium'] == 'email':
+            print "Only email is supported currently"
+            return
+
+        postArgs = { 'tokenId' : args['tokenId'], 'token' : args['token'] }
+        postArgs['mxId'] = self.config["user"]
+
+        reactor.callFromThread(self._do_3pidvalidate, postArgs)
+
+    @defer.inlineCallbacks
+    def _do_3pidvalidate(self, args):
+        url = self._identityServerUrl()+"/matrix/identity/api/v1/validate/email/submitToken"
+
+        json_res = yield self.http_client.do_request("POST", url, data=urllib.urlencode(args), jsonreq=False,
+                                                     headers={'Content-Type': ['application/x-www-form-urlencoded']})
+        print json_res
+
+    def do_join(self, line):
+        """Joins a room: "join <roomid>" """
+        try:
+            args = self._parse(line, ["roomid"], force_keys=True)
+            self._do_membership_change(args["roomid"], "join", self._usr())
+        except Exception as e:
+            print e
+
+    def do_joinalias(self, line):
+        try:
+            args = self._parse(line, ["roomname"], force_keys=True)
+            path = "/join/%s" % urllib.quote(args["roomname"])
+            reactor.callFromThread(self._run_and_pprint, "PUT", path, {})
+        except Exception as e:
+            print e
+
+    def do_topic(self, line):
+        """"topic [set|get] <roomid> [<newtopic>]"
+        Set the topic for a room: topic set <roomid> <newtopic>
+        Get the topic for a room: topic get <roomid>
+        """
+        try:
+            args = self._parse(line, ["action", "roomid", "topic"])
+            if "action" not in args or "roomid" not in args:
+                print "Must specify set|get and a room ID."
+                return
+            if args["action"].lower() not in ["set", "get"]:
+                print "Must specify set|get, not %s" % args["action"]
+                return
+
+            path = "/rooms/%s/topic" % urllib.quote(args["roomid"])
+
+            if args["action"].lower() == "set":
+                if "topic" not in args:
+                    print "Must specify a new topic."
+                    return
+                body = {
+                    "topic": args["topic"]
+                }
+                reactor.callFromThread(self._run_and_pprint, "PUT", path, body)
+            elif args["action"].lower() == "get":
+                reactor.callFromThread(self._run_and_pprint, "GET", path)
+        except Exception as e:
+            print e
+
+    def do_invite(self, line):
+        """Invite a user to a room: "invite <userid> <roomid>" """
+        try:
+            args = self._parse(line, ["userid", "roomid"], force_keys=True)
+
+            user_id = args["userid"]
+
+            reactor.callFromThread(self._do_invite, args["roomid"], user_id)
+        except Exception as e:
+            print e
+
+    @defer.inlineCallbacks
+    def _do_invite(self, roomid, userstring):
+        if (not userstring.startswith('@') and
+                    self._is_on("complete_usernames")):
+            url = self._identityServerUrl()+"/matrix/identity/api/v1/lookup"
+
+            json_res = yield self.http_client.do_request("GET", url, qparams={'medium':'email','address':userstring})
+
+            mxid = None
+
+            if 'mxid' in json_res and 'signatures' in json_res:
+                url = self._identityServerUrl()+"/matrix/identity/api/v1/pubkey/ed25519"
+
+                pubKey = None
+                pubKeyObj = yield self.http_client.do_request("GET", url)
+                if 'public_key' in pubKeyObj:
+                    pubKey = nacl.signing.VerifyKey(pubKeyObj['public_key'], encoder=nacl.encoding.HexEncoder)
+                else:
+                    print "No public key found in pubkey response!"
+
+                sigValid = False
+
+                if pubKey:
+                    for signame in json_res['signatures']:
+                        if signame not in TRUSTED_ID_SERVERS:
+                            print "Ignoring signature from untrusted server %s" % (signame)
+                        else:
+                            try:
+                                verify_signed_json(json_res, signame, pubKey)
+                                sigValid = True
+                                print "Mapping %s -> %s correctly signed by %s" % (userstring, json_res['mxid'], signame)
+                                break
+                            except SignatureVerifyException as e:
+                                print "Invalid signature from %s" % (signame)
+                                print e
+
+                if sigValid:
+                    print "Resolved 3pid %s to %s" % (userstring, json_res['mxid'])
+                    mxid = json_res['mxid']
+                else:
+                    print "Got association for %s but couldn't verify signature" % (userstring)
+
+            if not mxid:
+                mxid = "@" + userstring + ":" + self._domain()
+
+            self._do_membership_change(roomid, "invite", mxid)
+
+    def do_leave(self, line):
+        """Leaves a room: "leave <roomid>" """
+        try:
+            args = self._parse(line, ["roomid"], force_keys=True)
+            path = ("/rooms/%s/members/%s/state" %
+                    (urllib.quote(args["roomid"]), self._usr()))
+            reactor.callFromThread(self._run_and_pprint, "DELETE", path)
+        except Exception as e:
+            print e
+
+    def do_send(self, line):
+        """Sends a message. "send <roomid> <body>" """
+        args = self._parse(line, ["roomid", "body"])
+        msg_id = "m%s" % int(time.time())
+        path = "/rooms/%s/messages/%s/%s" % (urllib.quote(args["roomid"]),
+                                             self._usr(),
+                                             msg_id)
+        body_json = {
+            "msgtype": "m.text",
+            "body": args["body"]
+        }
+        reactor.callFromThread(self._run_and_pprint, "PUT", path, body_json)
+
+    def do_list(self, line):
+        """List data about a room.
+        "list members <roomid> [query]" - List all the members in this room.
+        "list messages <roomid> [query]" - List all the messages in this room.
+
+        Where [query] will be directly applied as query parameters, allowing
+        you to use the pagination API. E.g. the last 3 messages in this room:
+        "list messages <roomid> from=END&to=START&limit=3"
+        """
+        args = self._parse(line, ["type", "roomid", "qp"])
+        if not "type" in args or not "roomid" in args:
+            print "Must specify type and room ID."
+            return
+        if args["type"] not in ["members", "messages"]:
+            print "Unrecognised type: %s" % args["type"]
+            return
+        room_id = args["roomid"]
+        path = "/rooms/%s/%s/list" % (urllib.quote(room_id), args["type"])
+
+        qp = {"access_token": self._tok()}
+        if "qp" in args:
+            for key_value_str in args["qp"].split("&"):
+                try:
+                    key_value = key_value_str.split("=")
+                    qp[key_value[0]] = key_value[1]
+                except:
+                    print "Bad query param: %s" % key_value
+                    return
+
+        reactor.callFromThread(self._run_and_pprint, "GET", path,
+                               query_params=qp)
+
+    def do_create(self, line):
+        """Creates a room.
+        "create [public|private] <roomname>" - Create a room <roomname> with the
+                                             specified visibility.
+        "create <roomname>" - Create a room <roomname> with default visibility.
+        "create [public|private]" - Create a room with specified visibility.
+        "create" - Create a room with default visibility.
+        """
+        args = self._parse(line, ["vis", "roomname"])
+        # fixup args depending on which were set
+        body = {}
+        if "vis" in args and args["vis"] in ["public", "private"]:
+            body["visibility"] = args["vis"]
+
+        if "roomname" in args:
+            room_name = args["roomname"]
+            body["room_alias_name"] = room_name
+        elif "vis" in args and args["vis"] not in ["public", "private"]:
+            room_name = args["vis"]
+            body["room_alias_name"] = room_name
+
+        reactor.callFromThread(self._run_and_pprint, "POST", "/rooms", body)
+
+    def do_raw(self, line):
+        """Directly send a JSON object: "raw <method> <path> <data> <notoken>"
+        <method>: Required. One of "PUT", "GET", "POST", "xPUT", "xGET",
+        "xPOST". Methods with 'x' prefixed will not automatically append the
+        access token.
+        <path>: Required. E.g. "/events"
+        <data>: Optional. E.g. "{ "msgtype":"custom.text", "body":"abc123"}"
+        """
+        args = self._parse(line, ["method", "path", "data"])
+        # sanity check
+        if "method" not in args or "path" not in args:
+            print "Must specify path and method."
+            return
+
+        args["method"] = args["method"].upper()
+        valid_methods = ["PUT", "GET", "POST", "DELETE",
+                         "XPUT", "XGET", "XPOST", "XDELETE"]
+        if args["method"] not in valid_methods:
+            print "Unsupported method: %s" % args["method"]
+            return
+
+        if "data" not in args:
+            args["data"] = None
+        else:
+            try:
+                args["data"] = json.loads(args["data"])
+            except Exception as e:
+                print "Data is not valid JSON. %s" % e
+                return
+
+        qp = {"access_token": self._tok()}
+        if args["method"].startswith("X"):
+            qp = {}  # remove access token
+            args["method"] = args["method"][1:]  # snip the X
+        else:
+            # append any query params the user has set
+            try:
+                parsed_url = urlparse.urlparse(args["path"])
+                qp.update(urlparse.parse_qs(parsed_url.query))
+                args["path"] = parsed_url.path
+            except:
+                pass
+
+        reactor.callFromThread(self._run_and_pprint, args["method"],
+                                                     args["path"],
+                                                     args["data"],
+                                                     query_params=qp)
+
+    def do_stream(self, line):
+        """Stream data from the server: "stream <longpoll timeout ms>" """
+        args = self._parse(line, ["timeout"])
+        timeout = 5000
+        if "timeout" in args:
+            try:
+                timeout = int(args["timeout"])
+            except ValueError:
+                print "Timeout must be in milliseconds."
+                return
+        reactor.callFromThread(self._do_event_stream, timeout)
+
+    @defer.inlineCallbacks
+    def _do_event_stream(self, timeout):
+        res = yield self.http_client.get_json(
+                self._url() + "/events",
+                {
+                    "access_token": self._tok(),
+                    "timeout": str(timeout),
+                    "from": self.event_stream_token
+                })
+        print json.dumps(res, indent=4)
+
+        if "chunk" in res:
+            for event in res["chunk"]:
+                if (event["type"] == "m.room.message" and
+                        self._is_on("send_delivery_receipts") and
+                        event["user_id"] != self._usr()):  # not sent by us
+                    self._send_receipt(event, "d")
+
+        # update the position in the stram
+        if "end" in res:
+            self.event_stream_token = res["end"]
+
+    def _send_receipt(self, event, feedback_type):
+        path = ("/rooms/%s/messages/%s/%s/feedback/%s/%s" %
+               (urllib.quote(event["room_id"]), event["user_id"], event["msg_id"],
+                self._usr(), feedback_type))
+        data = {}
+        reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data,
+                               alt_text="Sent receipt for %s" % event["msg_id"])
+
+    def _do_membership_change(self, roomid, membership, userid):
+        path = "/rooms/%s/members/%s/state" % (urllib.quote(roomid), userid)
+        data = {
+            "membership": membership
+        }
+        reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data)
+
+    def do_displayname(self, line):
+        """Get or set my displayname: "displayname [new_name]" """
+        args = self._parse(line, ["name"])
+        path = "/profile/%s/displayname" % (self.config["user"])
+
+        if "name" in args:
+            data = {"displayname": args["name"]}
+            reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data)
+        else:
+            reactor.callFromThread(self._run_and_pprint, "GET", path)
+
+    def _do_presence_state(self, state, line):
+        args = self._parse(line, ["msgstring"])
+        path = "/presence/%s/status" % (self.config["user"])
+        data = {"state": state}
+        if "msgstring" in args:
+            data["status_msg"] = args["msgstring"]
+
+        reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data)
+
+    def do_offline(self, line):
+        """Set my presence state to OFFLINE"""
+        self._do_presence_state(0, line)
+
+    def do_away(self, line):
+        """Set my presence state to AWAY"""
+        self._do_presence_state(1, line)
+
+    def do_online(self, line):
+        """Set my presence state to ONLINE"""
+        self._do_presence_state(2, line)
+
+    def _parse(self, line, keys, force_keys=False):
+        """ Parses the given line.
+
+        Args:
+            line : The line to parse
+            keys : A list of keys to map onto the args
+            force_keys : True to enforce that the line has a value for every key
+        Returns:
+            A dict of key:arg
+        """
+        line_args = shlex.split(line)
+        if force_keys and len(line_args) != len(keys):
+            raise IndexError("Must specify all args: %s" % keys)
+
+        # do $ substitutions
+        for i, arg in enumerate(line_args):
+            for config_key in self.config:
+                if ("$" + config_key) in arg:
+                    arg = arg.replace("$" + config_key,
+                                      self.config[config_key])
+            line_args[i] = arg
+
+        return dict(zip(keys, line_args))
+
+    @defer.inlineCallbacks
+    def _run_and_pprint(self, method, path, data=None,
+                        query_params={"access_token": None}, alt_text=None):
+        """ Runs an HTTP request and pretty prints the output.
+
+        Args:
+            method: HTTP method
+            path: Relative path
+            data: Raw JSON data if any
+            query_params: dict of query parameters to add to the url
+        """
+        url = self._url() + path
+        if "access_token" in query_params:
+            query_params["access_token"] = self._tok()
+
+        json_res = yield self.http_client.do_request(method, url,
+                                                    data=data,
+                                                    qparams=query_params)
+        if alt_text:
+            print alt_text
+        else:
+            print json.dumps(json_res, indent=4)
+
+
+def save_config(config):
+    with open(CONFIG_JSON, 'w') as out:
+        json.dump(config, out)
+
+
+def main(server_url, identity_server_url, username, token, config_path):
+    print "Synapse command line client"
+    print "==========================="
+    print "Server: %s" % server_url
+    print "Type 'help' to get started."
+    print "Close this console with CTRL+C then CTRL+D."
+    if not username or not token:
+        print "-  'register <username>' - Register an account"
+        print "-  'stream' - Connect to the event stream"
+        print "-  'create <roomid>' - Create a room"
+        print "-  'send <roomid> <message>' - Send a message"
+    http_client = TwistedHttpClient()
+
+    # the command line client
+    syn_cmd = SynapseCmd(http_client, server_url, identity_server_url, username, token)
+
+    # load synapse.json config from a previous session
+    global CONFIG_JSON
+    CONFIG_JSON = config_path  # bit cheeky, but just overwrite the global
+    try:
+        with open(config_path, 'r') as config:
+            syn_cmd.config = json.load(config)
+            try:
+                http_client.verbose = "on" == syn_cmd.config["verbose"]
+            except:
+                pass
+            print "Loaded config from %s" % config_path
+    except:
+        pass
+
+    # Twisted-specific: Runs the command processor in Twisted's event loop
+    # to maintain a single thread for both commands and event processing.
+    # If using another HTTP client, just call syn_cmd.cmdloop()
+    reactor.callInThread(syn_cmd.cmdloop)
+    reactor.run()
+
+
+if __name__ == '__main__':
+    parser = argparse.ArgumentParser("Starts a synapse client.")
+    parser.add_argument(
+        "-s", "--server", dest="server", default="http://localhost:8080",
+        help="The URL of the home server to talk to.")
+    parser.add_argument(
+        "-i", "--identity-server", dest="identityserver", default="http://localhost:8090",
+        help="The URL of the identity server to talk to.")
+    parser.add_argument(
+        "-u", "--username", dest="username",
+        help="Your username on the server.")
+    parser.add_argument(
+        "-t", "--token", dest="token",
+        help="Your access token.")
+    parser.add_argument(
+        "-c", "--config", dest="config", default=CONFIG_JSON,
+        help="The location of the config.json file to read from.")
+    args = parser.parse_args()
+
+    if not args.server:
+        print "You must supply a server URL to communicate with."
+        parser.print_help()
+        sys.exit(1)
+
+    server = args.server
+    if not server.startswith("http://"):
+        server = "http://" + args.server
+
+    main(server, args.identityserver, args.username, args.token, args.config)