|
- #! /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)
|