1#!/usr/bin/env python
2
3# Copyright 2014-2016 OpenMarket Ltd
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17""" Starts a synapse client console. """
18import argparse
19import cmd
20import getpass
21import json
22import shlex
23import sys
24import time
25import urllib
26from http import TwistedHttpClient
27from typing import Optional
28
29import nacl.encoding
30import nacl.signing
31import urlparse
32from signedjson.sign import SignatureVerifyException, verify_signed_json
33
34from twisted.internet import defer, reactor, threads
35
36CONFIG_JSON = "cmdclient_config.json"
37
38# TODO: The concept of trusted identity servers has been deprecated. This option and checks
39#  should be removed
40TRUSTED_ID_SERVERS = ["localhost:8001"]
41
42
43class SynapseCmd(cmd.Cmd):
44
45    """Basic synapse command-line processor.
46
47    This processes commands from the user and calls the relevant HTTP methods.
48    """
49
50    def __init__(self, http_client, server_url, identity_server_url, username, token):
51        cmd.Cmd.__init__(self)
52        self.http_client = http_client
53        self.http_client.verbose = True
54        self.config = {
55            "url": server_url,
56            "identityServerUrl": identity_server_url,
57            "user": username,
58            "token": token,
59            "verbose": "on",
60            "complete_usernames": "on",
61            "send_delivery_receipts": "on",
62        }
63        self.path_prefix = "/_matrix/client/api/v1"
64        self.event_stream_token = "END"
65        self.prompt = ">>> "
66
67    def do_EOF(self, line):  # allows CTRL+D quitting
68        return True
69
70    def emptyline(self):
71        pass  # else it repeats the previous command
72
73    def _usr(self):
74        return self.config["user"]
75
76    def _tok(self):
77        return self.config["token"]
78
79    def _url(self):
80        return self.config["url"] + self.path_prefix
81
82    def _identityServerUrl(self):
83        return self.config["identityServerUrl"]
84
85    def _is_on(self, config_name):
86        if config_name in self.config:
87            return self.config[config_name] == "on"
88        return False
89
90    def _domain(self):
91        if "user" not in self.config or not self.config["user"]:
92            return None
93        return self.config["user"].split(":")[1]
94
95    def do_config(self, line):
96        """Show the config for this client: "config"
97        Edit a key value mapping: "config key value" e.g. "config token 1234"
98        Config variables:
99            user: The username to auth with.
100            token: The access token to auth with.
101            url: The url of the server.
102            verbose: [on|off] The verbosity of requests/responses.
103            complete_usernames: [on|off] Auto complete partial usernames by
104            assuming they are on the same homeserver as you.
105            E.g. name >> @name:yourhost
106            send_delivery_receipts: [on|off] Automatically send receipts to
107            messages when performing a 'stream' command.
108        Additional key/values can be added and can be substituted into requests
109        by using $. E.g. 'config roomid room1' then 'raw get /rooms/$roomid'.
110        """
111        if len(line) == 0:
112            print(json.dumps(self.config, indent=4))
113            return
114
115        try:
116            args = self._parse(line, ["key", "val"], force_keys=True)
117
118            # make sure restricted config values are checked
119            config_rules = [  # key, valid_values
120                ("verbose", ["on", "off"]),
121                ("complete_usernames", ["on", "off"]),
122                ("send_delivery_receipts", ["on", "off"]),
123            ]
124            for key, valid_vals in config_rules:
125                if key == args["key"] and args["val"] not in valid_vals:
126                    print("%s value must be one of %s" % (args["key"], valid_vals))
127                    return
128
129            # toggle the http client verbosity
130            if args["key"] == "verbose":
131                self.http_client.verbose = "on" == args["val"]
132
133            # assign the new config
134            self.config[args["key"]] = args["val"]
135            print(json.dumps(self.config, indent=4))
136
137            save_config(self.config)
138        except Exception as e:
139            print(e)
140
141    def do_register(self, line):
142        """Registers for a new account: "register <userid> <noupdate>"
143        <userid> : The desired user ID
144        <noupdate> : Do not automatically clobber config values.
145        """
146        args = self._parse(line, ["userid", "noupdate"])
147
148        password = None
149        pwd = None
150        pwd2 = "_"
151        while pwd != pwd2:
152            pwd = getpass.getpass("Type a password for this user: ")
153            pwd2 = getpass.getpass("Retype the password: ")
154            if pwd != pwd2 or len(pwd) == 0:
155                print("Password mismatch.")
156                pwd = None
157            else:
158                password = pwd
159
160        body = {"type": "m.login.password"}
161        if "userid" in args:
162            body["user"] = args["userid"]
163        if password:
164            body["password"] = password
165
166        reactor.callFromThread(self._do_register, body, "noupdate" not in args)
167
168    @defer.inlineCallbacks
169    def _do_register(self, data, update_config):
170        # check the registration flows
171        url = self._url() + "/register"
172        json_res = yield self.http_client.do_request("GET", url)
173        print(json.dumps(json_res, indent=4))
174
175        passwordFlow = None
176        for flow in json_res["flows"]:
177            if flow["type"] == "m.login.recaptcha" or (
178                "stages" in flow and "m.login.recaptcha" in flow["stages"]
179            ):
180                print("Unable to register: Home server requires captcha.")
181                return
182            if flow["type"] == "m.login.password" and "stages" not in flow:
183                passwordFlow = flow
184                break
185
186        if not passwordFlow:
187            return
188
189        json_res = yield self.http_client.do_request("POST", url, data=data)
190        print(json.dumps(json_res, indent=4))
191        if update_config and "user_id" in json_res:
192            self.config["user"] = json_res["user_id"]
193            self.config["token"] = json_res["access_token"]
194            save_config(self.config)
195
196    def do_login(self, line):
197        """Login as a specific user: "login @bob:localhost"
198        You MAY be prompted for a password, or instructed to visit a URL.
199        """
200        try:
201            args = self._parse(line, ["user_id"], force_keys=True)
202            can_login = threads.blockingCallFromThread(reactor, self._check_can_login)
203            if can_login:
204                p = getpass.getpass("Enter your password: ")
205                user = args["user_id"]
206                if self._is_on("complete_usernames") and not user.startswith("@"):
207                    domain = self._domain()
208                    if domain:
209                        user = "@" + user + ":" + domain
210
211                reactor.callFromThread(self._do_login, user, p)
212                # print " got %s " % p
213        except Exception as e:
214            print(e)
215
216    @defer.inlineCallbacks
217    def _do_login(self, user, password):
218        path = "/login"
219        data = {"user": user, "password": password, "type": "m.login.password"}
220        url = self._url() + path
221        json_res = yield self.http_client.do_request("POST", url, data=data)
222        print(json_res)
223
224        if "access_token" in json_res:
225            self.config["user"] = user
226            self.config["token"] = json_res["access_token"]
227            save_config(self.config)
228            print("Login successful.")
229
230    @defer.inlineCallbacks
231    def _check_can_login(self):
232        path = "/login"
233        # ALWAYS check that the home server can handle the login request before
234        # submitting!
235        url = self._url() + path
236        json_res = yield self.http_client.do_request("GET", url)
237        print(json_res)
238
239        if "flows" not in json_res:
240            print("Failed to find any login flows.")
241            defer.returnValue(False)
242
243        flow = json_res["flows"][0]  # assume first is the one we want.
244        if "type" not in flow or "m.login.password" != flow["type"] or "stages" in flow:
245            fallback_url = self._url() + "/login/fallback"
246            print(
247                "Unable to login via the command line client. Please visit "
248                "%s to login." % fallback_url
249            )
250            defer.returnValue(False)
251        defer.returnValue(True)
252
253    def do_emailrequest(self, line):
254        """Requests the association of a third party identifier
255        <address> The email address)
256        <clientSecret> A string of characters generated when requesting an email that you'll supply in subsequent calls to identify yourself
257        <sendAttempt> The number of times the user has requested an email. Leave this the same between requests to retry the request at the transport level. Increment it to request that the email be sent again.
258        """
259        args = self._parse(line, ["address", "clientSecret", "sendAttempt"])
260
261        postArgs = {
262            "email": args["address"],
263            "clientSecret": args["clientSecret"],
264            "sendAttempt": args["sendAttempt"],
265        }
266
267        reactor.callFromThread(self._do_emailrequest, postArgs)
268
269    @defer.inlineCallbacks
270    def _do_emailrequest(self, args):
271        # TODO: Update to use v2 Identity Service API endpoint
272        url = (
273            self._identityServerUrl()
274            + "/_matrix/identity/api/v1/validate/email/requestToken"
275        )
276
277        json_res = yield self.http_client.do_request(
278            "POST",
279            url,
280            data=urllib.urlencode(args),
281            jsonreq=False,
282            headers={"Content-Type": ["application/x-www-form-urlencoded"]},
283        )
284        print(json_res)
285        if "sid" in json_res:
286            print("Token sent. Your session ID is %s" % (json_res["sid"]))
287
288    def do_emailvalidate(self, line):
289        """Validate and associate a third party ID
290        <sid> The session ID (sid) given to you in the response to requestToken
291        <token> The token sent to your third party identifier address
292        <clientSecret> The same clientSecret you supplied in requestToken
293        """
294        args = self._parse(line, ["sid", "token", "clientSecret"])
295
296        postArgs = {
297            "sid": args["sid"],
298            "token": args["token"],
299            "clientSecret": args["clientSecret"],
300        }
301
302        reactor.callFromThread(self._do_emailvalidate, postArgs)
303
304    @defer.inlineCallbacks
305    def _do_emailvalidate(self, args):
306        # TODO: Update to use v2 Identity Service API endpoint
307        url = (
308            self._identityServerUrl()
309            + "/_matrix/identity/api/v1/validate/email/submitToken"
310        )
311
312        json_res = yield self.http_client.do_request(
313            "POST",
314            url,
315            data=urllib.urlencode(args),
316            jsonreq=False,
317            headers={"Content-Type": ["application/x-www-form-urlencoded"]},
318        )
319        print(json_res)
320
321    def do_3pidbind(self, line):
322        """Validate and associate a third party ID
323        <sid> The session ID (sid) given to you in the response to requestToken
324        <clientSecret> The same clientSecret you supplied in requestToken
325        """
326        args = self._parse(line, ["sid", "clientSecret"])
327
328        postArgs = {"sid": args["sid"], "clientSecret": args["clientSecret"]}
329        postArgs["mxid"] = self.config["user"]
330
331        reactor.callFromThread(self._do_3pidbind, postArgs)
332
333    @defer.inlineCallbacks
334    def _do_3pidbind(self, args):
335        # TODO: Update to use v2 Identity Service API endpoint
336        url = self._identityServerUrl() + "/_matrix/identity/api/v1/3pid/bind"
337
338        json_res = yield self.http_client.do_request(
339            "POST",
340            url,
341            data=urllib.urlencode(args),
342            jsonreq=False,
343            headers={"Content-Type": ["application/x-www-form-urlencoded"]},
344        )
345        print(json_res)
346
347    def do_join(self, line):
348        """Joins a room: "join <roomid>" """
349        try:
350            args = self._parse(line, ["roomid"], force_keys=True)
351            self._do_membership_change(args["roomid"], "join", self._usr())
352        except Exception as e:
353            print(e)
354
355    def do_joinalias(self, line):
356        try:
357            args = self._parse(line, ["roomname"], force_keys=True)
358            path = "/join/%s" % urllib.quote(args["roomname"])
359            reactor.callFromThread(self._run_and_pprint, "POST", path, {})
360        except Exception as e:
361            print(e)
362
363    def do_topic(self, line):
364        """ "topic [set|get] <roomid> [<newtopic>]"
365        Set the topic for a room: topic set <roomid> <newtopic>
366        Get the topic for a room: topic get <roomid>
367        """
368        try:
369            args = self._parse(line, ["action", "roomid", "topic"])
370            if "action" not in args or "roomid" not in args:
371                print("Must specify set|get and a room ID.")
372                return
373            if args["action"].lower() not in ["set", "get"]:
374                print("Must specify set|get, not %s" % args["action"])
375                return
376
377            path = "/rooms/%s/topic" % urllib.quote(args["roomid"])
378
379            if args["action"].lower() == "set":
380                if "topic" not in args:
381                    print("Must specify a new topic.")
382                    return
383                body = {"topic": args["topic"]}
384                reactor.callFromThread(self._run_and_pprint, "PUT", path, body)
385            elif args["action"].lower() == "get":
386                reactor.callFromThread(self._run_and_pprint, "GET", path)
387        except Exception as e:
388            print(e)
389
390    def do_invite(self, line):
391        """Invite a user to a room: "invite <userid> <roomid>" """
392        try:
393            args = self._parse(line, ["userid", "roomid"], force_keys=True)
394
395            user_id = args["userid"]
396
397            reactor.callFromThread(self._do_invite, args["roomid"], user_id)
398        except Exception as e:
399            print(e)
400
401    @defer.inlineCallbacks
402    def _do_invite(self, roomid, userstring):
403        if not userstring.startswith("@") and self._is_on("complete_usernames"):
404            # TODO: Update to use v2 Identity Service API endpoint
405            url = self._identityServerUrl() + "/_matrix/identity/api/v1/lookup"
406
407            json_res = yield self.http_client.do_request(
408                "GET", url, qparams={"medium": "email", "address": userstring}
409            )
410
411            mxid = None
412
413            if "mxid" in json_res and "signatures" in json_res:
414                # TODO: Update to use v2 Identity Service API endpoint
415                url = (
416                    self._identityServerUrl()
417                    + "/_matrix/identity/api/v1/pubkey/ed25519"
418                )
419
420                pubKey = None
421                pubKeyObj = yield self.http_client.do_request("GET", url)
422                if "public_key" in pubKeyObj:
423                    pubKey = nacl.signing.VerifyKey(
424                        pubKeyObj["public_key"], encoder=nacl.encoding.HexEncoder
425                    )
426                else:
427                    print("No public key found in pubkey response!")
428
429                sigValid = False
430
431                if pubKey:
432                    for signame in json_res["signatures"]:
433                        if signame not in TRUSTED_ID_SERVERS:
434                            print(
435                                "Ignoring signature from untrusted server %s"
436                                % (signame)
437                            )
438                        else:
439                            try:
440                                verify_signed_json(json_res, signame, pubKey)
441                                sigValid = True
442                                print(
443                                    "Mapping %s -> %s correctly signed by %s"
444                                    % (userstring, json_res["mxid"], signame)
445                                )
446                                break
447                            except SignatureVerifyException as e:
448                                print("Invalid signature from %s" % (signame))
449                                print(e)
450
451                if sigValid:
452                    print("Resolved 3pid %s to %s" % (userstring, json_res["mxid"]))
453                    mxid = json_res["mxid"]
454                else:
455                    print(
456                        "Got association for %s but couldn't verify signature"
457                        % (userstring)
458                    )
459
460            if not mxid:
461                mxid = "@" + userstring + ":" + self._domain()
462
463            self._do_membership_change(roomid, "invite", mxid)
464
465    def do_leave(self, line):
466        """Leaves a room: "leave <roomid>" """
467        try:
468            args = self._parse(line, ["roomid"], force_keys=True)
469            self._do_membership_change(args["roomid"], "leave", self._usr())
470        except Exception as e:
471            print(e)
472
473    def do_send(self, line):
474        """Sends a message. "send <roomid> <body>" """
475        args = self._parse(line, ["roomid", "body"])
476        txn_id = "txn%s" % int(time.time())
477        path = "/rooms/%s/send/m.room.message/%s" % (
478            urllib.quote(args["roomid"]),
479            txn_id,
480        )
481        body_json = {"msgtype": "m.text", "body": args["body"]}
482        reactor.callFromThread(self._run_and_pprint, "PUT", path, body_json)
483
484    def do_list(self, line):
485        """List data about a room.
486        "list members <roomid> [query]" - List all the members in this room.
487        "list messages <roomid> [query]" - List all the messages in this room.
488
489        Where [query] will be directly applied as query parameters, allowing
490        you to use the pagination API. E.g. the last 3 messages in this room:
491        "list messages <roomid> from=END&to=START&limit=3"
492        """
493        args = self._parse(line, ["type", "roomid", "qp"])
494        if "type" not in args or "roomid" not in args:
495            print("Must specify type and room ID.")
496            return
497        if args["type"] not in ["members", "messages"]:
498            print("Unrecognised type: %s" % args["type"])
499            return
500        room_id = args["roomid"]
501        path = "/rooms/%s/%s" % (urllib.quote(room_id), args["type"])
502
503        qp = {"access_token": self._tok()}
504        if "qp" in args:
505            for key_value_str in args["qp"].split("&"):
506                try:
507                    key_value = key_value_str.split("=")
508                    qp[key_value[0]] = key_value[1]
509                except Exception:
510                    print("Bad query param: %s" % key_value)
511                    return
512
513        reactor.callFromThread(self._run_and_pprint, "GET", path, query_params=qp)
514
515    def do_create(self, line):
516        """Creates a room.
517        "create [public|private] <roomname>" - Create a room <roomname> with the
518                                             specified visibility.
519        "create <roomname>" - Create a room <roomname> with default visibility.
520        "create [public|private]" - Create a room with specified visibility.
521        "create" - Create a room with default visibility.
522        """
523        args = self._parse(line, ["vis", "roomname"])
524        # fixup args depending on which were set
525        body = {}
526        if "vis" in args and args["vis"] in ["public", "private"]:
527            body["visibility"] = args["vis"]
528
529        if "roomname" in args:
530            room_name = args["roomname"]
531            body["room_alias_name"] = room_name
532        elif "vis" in args and args["vis"] not in ["public", "private"]:
533            room_name = args["vis"]
534            body["room_alias_name"] = room_name
535
536        reactor.callFromThread(self._run_and_pprint, "POST", "/createRoom", body)
537
538    def do_raw(self, line):
539        """Directly send a JSON object: "raw <method> <path> <data> <notoken>"
540        <method>: Required. One of "PUT", "GET", "POST", "xPUT", "xGET",
541        "xPOST". Methods with 'x' prefixed will not automatically append the
542        access token.
543        <path>: Required. E.g. "/events"
544        <data>: Optional. E.g. "{ "msgtype":"custom.text", "body":"abc123"}"
545        """
546        args = self._parse(line, ["method", "path", "data"])
547        # sanity check
548        if "method" not in args or "path" not in args:
549            print("Must specify path and method.")
550            return
551
552        args["method"] = args["method"].upper()
553        valid_methods = [
554            "PUT",
555            "GET",
556            "POST",
557            "DELETE",
558            "XPUT",
559            "XGET",
560            "XPOST",
561            "XDELETE",
562        ]
563        if args["method"] not in valid_methods:
564            print("Unsupported method: %s" % args["method"])
565            return
566
567        if "data" not in args:
568            args["data"] = None
569        else:
570            try:
571                args["data"] = json.loads(args["data"])
572            except Exception as e:
573                print("Data is not valid JSON. %s" % e)
574                return
575
576        qp = {"access_token": self._tok()}
577        if args["method"].startswith("X"):
578            qp = {}  # remove access token
579            args["method"] = args["method"][1:]  # snip the X
580        else:
581            # append any query params the user has set
582            try:
583                parsed_url = urlparse.urlparse(args["path"])
584                qp.update(urlparse.parse_qs(parsed_url.query))
585                args["path"] = parsed_url.path
586            except Exception:
587                pass
588
589        reactor.callFromThread(
590            self._run_and_pprint,
591            args["method"],
592            args["path"],
593            args["data"],
594            query_params=qp,
595        )
596
597    def do_stream(self, line):
598        """Stream data from the server: "stream <longpoll timeout ms>" """
599        args = self._parse(line, ["timeout"])
600        timeout = 5000
601        if "timeout" in args:
602            try:
603                timeout = int(args["timeout"])
604            except ValueError:
605                print("Timeout must be in milliseconds.")
606                return
607        reactor.callFromThread(self._do_event_stream, timeout)
608
609    @defer.inlineCallbacks
610    def _do_event_stream(self, timeout):
611        res = yield defer.ensureDeferred(
612            self.http_client.get_json(
613                self._url() + "/events",
614                {
615                    "access_token": self._tok(),
616                    "timeout": str(timeout),
617                    "from": self.event_stream_token,
618                },
619            )
620        )
621        print(json.dumps(res, indent=4))
622
623        if "chunk" in res:
624            for event in res["chunk"]:
625                if (
626                    event["type"] == "m.room.message"
627                    and self._is_on("send_delivery_receipts")
628                    and event["user_id"] != self._usr()
629                ):  # not sent by us
630                    self._send_receipt(event, "d")
631
632        # update the position in the stram
633        if "end" in res:
634            self.event_stream_token = res["end"]
635
636    def _send_receipt(self, event, feedback_type):
637        path = "/rooms/%s/messages/%s/%s/feedback/%s/%s" % (
638            urllib.quote(event["room_id"]),
639            event["user_id"],
640            event["msg_id"],
641            self._usr(),
642            feedback_type,
643        )
644        data = {}
645        reactor.callFromThread(
646            self._run_and_pprint,
647            "PUT",
648            path,
649            data=data,
650            alt_text="Sent receipt for %s" % event["msg_id"],
651        )
652
653    def _do_membership_change(self, roomid, membership, userid):
654        path = "/rooms/%s/state/m.room.member/%s" % (
655            urllib.quote(roomid),
656            urllib.quote(userid),
657        )
658        data = {"membership": membership}
659        reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data)
660
661    def do_displayname(self, line):
662        """Get or set my displayname: "displayname [new_name]" """
663        args = self._parse(line, ["name"])
664        path = "/profile/%s/displayname" % (self.config["user"])
665
666        if "name" in args:
667            data = {"displayname": args["name"]}
668            reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data)
669        else:
670            reactor.callFromThread(self._run_and_pprint, "GET", path)
671
672    def _do_presence_state(self, state, line):
673        args = self._parse(line, ["msgstring"])
674        path = "/presence/%s/status" % (self.config["user"])
675        data = {"state": state}
676        if "msgstring" in args:
677            data["status_msg"] = args["msgstring"]
678
679        reactor.callFromThread(self._run_and_pprint, "PUT", path, data=data)
680
681    def do_offline(self, line):
682        """Set my presence state to OFFLINE"""
683        self._do_presence_state(0, line)
684
685    def do_away(self, line):
686        """Set my presence state to AWAY"""
687        self._do_presence_state(1, line)
688
689    def do_online(self, line):
690        """Set my presence state to ONLINE"""
691        self._do_presence_state(2, line)
692
693    def _parse(self, line, keys, force_keys=False):
694        """Parses the given line.
695
696        Args:
697            line : The line to parse
698            keys : A list of keys to map onto the args
699            force_keys : True to enforce that the line has a value for every key
700        Returns:
701            A dict of key:arg
702        """
703        line_args = shlex.split(line)
704        if force_keys and len(line_args) != len(keys):
705            raise IndexError("Must specify all args: %s" % keys)
706
707        # do $ substitutions
708        for i, arg in enumerate(line_args):
709            for config_key in self.config:
710                if ("$" + config_key) in arg:
711                    arg = arg.replace("$" + config_key, self.config[config_key])
712            line_args[i] = arg
713
714        return dict(zip(keys, line_args))
715
716    @defer.inlineCallbacks
717    def _run_and_pprint(
718        self,
719        method,
720        path,
721        data=None,
722        query_params: Optional[dict] = None,
723        alt_text=None,
724    ):
725        """Runs an HTTP request and pretty prints the output.
726
727        Args:
728            method: HTTP method
729            path: Relative path
730            data: Raw JSON data if any
731            query_params: dict of query parameters to add to the url
732        """
733        query_params = query_params or {"access_token": None}
734
735        url = self._url() + path
736        if "access_token" in query_params:
737            query_params["access_token"] = self._tok()
738
739        json_res = yield self.http_client.do_request(
740            method, url, data=data, qparams=query_params
741        )
742        if alt_text:
743            print(alt_text)
744        else:
745            print(json.dumps(json_res, indent=4))
746
747
748def save_config(config):
749    with open(CONFIG_JSON, "w") as out:
750        json.dump(config, out)
751
752
753def main(server_url, identity_server_url, username, token, config_path):
754    print("Synapse command line client")
755    print("===========================")
756    print("Server: %s" % server_url)
757    print("Type 'help' to get started.")
758    print("Close this console with CTRL+C then CTRL+D.")
759    if not username or not token:
760        print("-  'register <username>' - Register an account")
761        print("-  'stream' - Connect to the event stream")
762        print("-  'create <roomid>' - Create a room")
763        print("-  'send <roomid> <message>' - Send a message")
764    http_client = TwistedHttpClient()
765
766    # the command line client
767    syn_cmd = SynapseCmd(http_client, server_url, identity_server_url, username, token)
768
769    # load synapse.json config from a previous session
770    global CONFIG_JSON
771    CONFIG_JSON = config_path  # bit cheeky, but just overwrite the global
772    try:
773        with open(config_path, "r") as config:
774            syn_cmd.config = json.load(config)
775            try:
776                http_client.verbose = "on" == syn_cmd.config["verbose"]
777            except Exception:
778                pass
779            print("Loaded config from %s" % config_path)
780    except Exception:
781        pass
782
783    # Twisted-specific: Runs the command processor in Twisted's event loop
784    # to maintain a single thread for both commands and event processing.
785    # If using another HTTP client, just call syn_cmd.cmdloop()
786    reactor.callInThread(syn_cmd.cmdloop)
787    reactor.run()
788
789
790if __name__ == "__main__":
791    parser = argparse.ArgumentParser("Starts a synapse client.")
792    parser.add_argument(
793        "-s",
794        "--server",
795        dest="server",
796        default="http://localhost:8008",
797        help="The URL of the home server to talk to.",
798    )
799    parser.add_argument(
800        "-i",
801        "--identity-server",
802        dest="identityserver",
803        default="http://localhost:8090",
804        help="The URL of the identity server to talk to.",
805    )
806    parser.add_argument(
807        "-u", "--username", dest="username", help="Your username on the server."
808    )
809    parser.add_argument("-t", "--token", dest="token", help="Your access token.")
810    parser.add_argument(
811        "-c",
812        "--config",
813        dest="config",
814        default=CONFIG_JSON,
815        help="The location of the config.json file to read from.",
816    )
817    args = parser.parse_args()
818
819    if not args.server:
820        print("You must supply a server URL to communicate with.")
821        parser.print_help()
822        sys.exit(1)
823
824    server = args.server
825    if not server.startswith("http://"):
826        server = "http://" + args.server
827
828    main(server, args.identityserver, args.username, args.token, args.config)
829